fix: stop retrying INSUFFICIENT_FUND indefinitely - #211
Conversation
InfiniteRetryContext guards CreateTransaction and DebitWallet and sets no MaximumAttempts, so any error code missing from NonRetryableErrorTypes is retried for the life of the workflow. INSUFFICIENT_FUND was missing: the ledger re-evaluates the same postings against an unchanged source balance and rejects them identically on every attempt, so the send stage spun forever instead of surfacing the failure to the caller. Add INSUFFICIENT_FUND to that list. Both the ledger (V2ErrorsEnum) and the wallets (wallets.ErrorCode) APIs return this exact code, and ledger v2 maps numscript.MissingFundsErr onto it too, so direct postings and Numscript shortfalls are both covered. Left PaymentInitiationRetryContext alone: it guards PSP activities that never return this code, and it is already bounded at 15 attempts.
✅ Approve — automated reviewThe retry policy correctly marks INSUFFICIENT_FUND as non-retryable without mutating the shared policy slice, and the added tests and harness assertions are consistent with the intended behavior. No findings. |
|
PR #211 correctly fixes a real production hazard: a Recommendation: approve with comments. StandardsNo confirmed material finding. The repo has no documented coding standards, and both review passes' style observations (fixture duplication in SpecThe diff faithfully implements the PR body's spec: the policy line, the unbounded-context rationale, the untouched PSP context, and the new harness fields and tests all check out, with no scope creep. Two low-severity gaps remain:
Reviewed independently by GLM (glm-5.3-flash) and DeepSeek (deepseek-v4-pro-0813) via Shipfox; verified and synthesized by GLM. |
Comparing commonNonRetryableErrorCodes against a policy that was assigned that same slice compares a slice to itself, so the assertion could not fail for the reason its comment claimed.
|
This PR adds StandardsNo confirmed material finding. The repo has no documented coding standards governing this code (the only Spec
No scope creep: every hunk in the diff maps to the spec (policy change, PSP context untouched, policy-pinning tests, harness additions, and the send regression case), and the behavioural effect on non- Reviewed independently by GLM (glm-5.3-flash) and DeepSeek (deepseek-v4-pro-0813) via Shipfox; verified and synthesized by GLM. |
|
This PR adds StandardsNo confirmed material finding. Both reviews' candidate smells (repeated SpecNo formal spec is available; the PR description was used as the reference. All deliverables are present and
Reviewed independently by GLM (glm-5.3-flash) and DeepSeek (deepseek-v4-pro-0813) via Shipfox; verified and synthesized by GLM. |
Problem
A
sendstage whose source account cannot cover the amount retries forever.InfiniteRetryContext(internal/workflow/stages/internal/context.go) guardsCreateTransactionandDebitWalletand deliberately sets noMaximumAttempts, soNonRetryableErrorTypesis the only thing that can stop a retry loop.INSUFFICIENT_FUNDwas not in it — but the ledger just re-evaluates the same postings against an unchanged source balance and rejects them identically on every attempt. The workflow never surfaces the failure to the caller; it just spins.Fix
Add
INSUFFICIENT_FUNDtoInfiniteRetryContext'sNonRetryableErrorTypes.ledger.V2ErrorsEnumInsufficientFundandwallets.ErrorCodeInsufficientFundare both"INSUFFICIENT_FUND"— so this covers both activities the context guards.numscript.MissingFundsErronto the same code (internal/api/v2/controllers_transactions_create.go), so Numscript shortfalls are covered as well as direct postings.PaymentInitiationRetryContextis untouched: it guards PSP activities that never return this code, and it is already bounded at 15 attempts.Tests
internal/workflow/stages/internal/context_test.go— pins both retry policies. The second test guards against theappend()inInfiniteRetryContextmutating the sharedcommonNonRetryableErrorCodesbacking array and leaking ledger-only codes into the PSP context.internal/workflow/stages/send/run_test.go— asendcase whereCreateTransactionreturnsINSUFFICIENT_FUND, asserting the workflow fails with that code after exactly one activity call. Reverting the one-line policy change fails it with 10 calls andretryable: true, so it genuinely covers the regression.stagestesting.WorkflowTestCase):ExpectedErrorCodeandExpectedActivityCalls, since it previously only knew how to assert a workflow succeeded. Existing cases are unaffected.go build ./...,go vet ./internal/...andgo test ./internal/...all pass.Note for reviewers
This is correct for ledger v2, which is what the affected stack runs. Ledger v3 emits
INSUFFICIENT_FUNDS(plural), which SDK v5.0.1'sV2ErrorsEnum.UnmarshalJSONrejects outright — the SDK then returns a plain unmarshal error rather than a*ledger.V2ErrorResponseError, the activity falls into itsdefault:branch, and the loop comes back through a different route that this change cannot reach. Worth a separate ticket on the SDK or the ledger side if flows is ever pointed at v3.Deploying this
A Temporal retry policy is captured when the activity is scheduled, not when it runs, so this change only applies to newly scheduled activities. Workflows already wedged in the
INSUFFICIENT_FUNDloop — exactly the population this fixes — will keep retrying after the deploy and have to be terminated or reset by hand.Behavioural change worth a second opinion
For a mixed-ledger
account to accountsend, the destination-sidethroughAccountdefaults toworld, and the ledger skips the balance check onworld, so the common path is unaffected. If someone configures a non-world destinationthroughAccountand does not fund it, the second transaction (run.go:604) now fails permanently where it previously retried until the bridge account happened to be funded — with the first transaction already committed and no compensation.I do not think that argues for weakening the fix:
allowOverdrafton the destination is the explicit, designed escape hatch for an unfunded bridge account (run.go:578), and a bounded retry would only convert a silent infinite loop into a slow one. But it is a real change for that configuration, so flagging it rather than burying it.