dotnet/orleans · error · InvalidOperationException
Withdrawing {amount} credits from account "{this.GetPrimaryK
Error message
Withdrawing {amount} credits from account "{this.GetPrimaryKeyString()}" would overdraw it. This account has {balance.Value} credits. What it means
An InvalidOperationException thrown inside the AccountGrain.Withdraw transactional update when the requested withdrawal amount exceeds the current balance. The grain uses ITransactionalState<Balance>.PerformUpdate so the check executes atomically; if it throws, the transaction aborts and rolls back, leaving the balance unchanged. The message includes the grain's string key and the live balance for diagnosis.
Source
Thrown at samples/BankAccount/AccountTransfer.Grains/AccountGrain.cs:30
public sealed class AccountGrain : Grain, IAccountGrain
{
private readonly ITransactionalState<Balance> _balance;
public AccountGrain(
[TransactionalState("balance")] ITransactionalState<Balance> balance) =>
_balance = balance ?? throw new ArgumentNullException(nameof(balance));
public Task Deposit(int amount) =>
_balance.PerformUpdate(
balance => balance.Value += amount);
public Task Withdraw(int amount) =>
_balance.PerformUpdate(balance =>
{
if (balance.Value < amount)
{
throw new InvalidOperationException(
$"Withdrawing {amount} credits from account " +
$"\"{this.GetPrimaryKeyString()}\" would overdraw it." +
$" This account has {balance.Value} credits.");
}
balance.Value -= amount;
});
public Task<int> GetBalance() =>
_balance.PerformRead(balance => balance.Value);
}
View on GitHub (pinned to fca799fa70)
Solutions
- Have the caller call GetBalance() first (or check within the same transaction) and reject/handle insufficient-funds before calling Withdraw.
- Catch InvalidOperationException at the transfer/caller layer and surface an 'insufficient funds' result to the user instead of crashing.
- For transfers, use Orleans transactions across both accounts so the debit+credit is atomic and a failed debit cancels the credit.
Example fix
// before
public Task Withdraw(int amount) =>
_balance.PerformUpdate(balance =>
{
if (balance.Value < amount)
throw new InvalidOperationException($"Withdrawing {amount} ... overdraw it.");
balance.Value -= amount;
});
// after (return a result instead of throwing for expected business failures)
public Task<bool> TryWithdraw(int amount) =>
_balance.PerformUpdate(balance =>
{
if (balance.Value < amount) return false;
balance.Value -= amount;
return true;
}); Defensive patterns
Strategy: validation
Validate before calling
var balance = await account.GetBalance();
if (balance < amount)
return Results.BadRequest("Insufficient funds");
await account.Withdraw(amount); Try / catch
try {
await account.Withdraw(amount);
} catch (InvalidOperationException ex) when (ex.Message.Contains("overdraw")) {
// surface as a domain result, not a crash
return Results.BadRequest("Insufficient funds");
} Prevention
- Pre-check the balance before withdrawing for a friendlier UX.
- Prefer returning a result (TryWithdraw) over throwing for expected business failures.
- Use Orleans transactions for transfers so debit+credit are atomic and consistent.
When it happens
Trigger: Calling IAccountGrain.Withdraw(amount) where amount > current balance.Value inside the PerformUpdate lambda. Because it is a transactional state update, the read of balance.Value is consistent within the transaction.
Common situations: A caller (transfer sender) requests more than the account holds. Concurrent withdrawals racing on the same account — the transactional guard serializes them and one will overdraw against the now-lower balance. A bug computing the amount.
Related errors
- Orleans:AdvertisedIPAddress must contain the Container Apps
- {key} must be a valid TCP port.
- graintype, siloNameFilter, and upperbound must be specified
- Invalid {nameof(AdoNetStreamOptions)} values for ADO.NET Str
- No credentials specified. Use the AzureBlobStorageOptions.Co
AI-assisted analysis of dotnet/orleans@fca799fa70 (2026-08-13).
Data as JSON: /api/errors/26f1e5ae695d2365.
Report an issue: GitHub.