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

  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.