dotnet/orleans · error · InvalidOperationException

Withdrawing credits from account " " would overdraw it…

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.

Solutions

  1. Have the caller call GetBalance() first (or check within the same transaction) and reject/handle insufficient-funds before calling Withdraw.
  2. Catch InvalidOperationException at the transfer/caller layer and surface an 'insufficient funds' result to the user instead of crashing.
  3. 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

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


AI-assisted analysis of dotnet/orleans@fca799fa70 (2026-08-13). Data as JSON: /api/errors/26f1e5ae695d2365. Report an issue: GitHub.

Appendix: 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)