Repository navigation
fix(revoke): keep the in-flight flag on an ambiguous Azure Return failure and refuse a late Lambda claim - #1579
Conversation
…lure and refuse a late Lambda claim A Return that failed without a definite answer from Azure (a transport error, a 5xx, the 3m Return deadline, a response that could not be read) cleared the in-flight flag and answered a flat failure. If Azure had taken the Return, a refunded purchase read as active and failed until an operator fixed it. Only a definite refusal now clears the flag: Azure answered the only request the client sent with a 4xx other than 408. A per-retry policy on the production Return client counts the sends, because azcore retries a POST on transport errors, 408, 429 and 5xx, and the 4xx that ends a retried call may be Azure refusing a second copy of a Return it already took. Every other failure keeps the flag (so no request can post a second Return), logs the row for an operator, and answers 500 "may already have been accepted by Azure; verify the reservation state in the Azure portal before retrying", the RI exchange handler's pattern for its ambiguous failures. A retry gets RECONCILE_PENDING; if no Return is recorded, finalize_revocations releases the flag after its 2h stale window, unrevoked, and logs it. On Lambda the claim is now refused with a retryable 503, before the flag is set or the Return posted, when less than 3m35s of invocation time is left: the 3m detached Return, one 30s post-Return write budget (the record of an accepted Return or the clear after a refusal, never both), and 5s for the response. Shipped Lambda timeouts are at least 4m (budgets plus the 30s lambdatimeout margin), so 25s or more remain for the work before the claim. Off Lambda the process outlives the request, the detached Return and writes finish after the 30s request deadline, and that deadline already guards the claim itself, which runs on the request context; no extra check applies. Tests (stub Azure clients, or the real armreservations Return client against a local TLS server; nothing calls Azure): 5xx, 408, timeout, transport error, cut-off response and a 4xx after a retried 5xx keep the flag and answer uncertain; a single 4xx clears it; a Lambda claim with too little time posts nothing; enough time, no deadline, or a non-Lambda runtime proceeds. On real Postgres an uncertain Return keeps the row flagged, a retry posts no second Return, and the sweep leaves the fresh flag and releases it past 2h without revoking. Closes LeanerCloud#1529 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n a regression Refs LeanerCloud#1529 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 59 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rM2. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rM1. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Local verification at |
|
Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 2 approving independent review(s) on |
Problem
Owner decision D26 (acting owner, 2026-10-28): treat an ambiguous Azure Return failure as uncertain.
handleAzureReturnErrorstill cleared therevocation_in_flightflag and answered with a plain failure. If Azure had actually taken the Return, the purchase was refunded but showed as active and failed until an operator fixed the record.Fix
Ambiguous Return failure (item 1), in
internal/api/handler_purchases_revoke_uncertain.goandhandleAzureReturnError:returnAttemptPolicy) on the production Return client counts how many requests were sent. azcore retries a POST after transport errors, 408, 429 and 5xx, so the 4xx that ends a retried call can be Azure refusing a second copy of a Return it already accepted (for example, the connection reset after the 202). The policy runs after the bearer-token policy, so it counts only requests that reach the transport.executeAzureExchange). The SDK accepts only a 202, so an unexpected 2xx also counts as uncertain.RECONCILE_PENDINGand posts nothing. If no Return gets recorded, the fix(revoke): finalize only revocations whose Azure Return was recorded; release stale flags #1524finalize_revocationssweep clears the flag after its 2h stale window, leaves the purchase unrevoked, and logs the row so an operator can reconcile it. I checked this on real Postgres (test below). The sweep code itself is unchanged; only its comment now mentions this case.Too little Lambda time (item 2), in
checkRevokeTimeLeft, called at the top ofclaimRevocationInFlight:revokeClaimMinRemaining= 3m35s of invocation time is left, the claim is refused with a retryable 503 ("nothing was sent to Azure and the purchase is unchanged, so retry the revoke"). This happens before the flag is set and before any Return is posted.revokeReturnTimeout, 3m), plus one post-Return write budget (revokePersistTimeout, 30s; that is the accepted-Return record or the clear after a refusal, never both, and an uncertain failure writes nothing), plus 5s (revokeResponseMargin) to build and return the response. Shipped Lambda timeouts are at least these budgets plus the 30slambdatimeout.Margin(TestLambdaTimeoutCoversRevokeBudget), so at least 25s is left for the work before the claim. A test pins that the threshold stays below that floor; otherwise every Lambda revoke would be refused.internal/server/http.go) takes no claim and posts nothing (TestCallAzureReturn_RequestCanceledBeforeReturn_PostsNothing). The process also outlives the request, so a Return started just before the deadline still finishes and is recorded on its detached budgets. Applying the 3m35s threshold to a 30s request deadline would refuse every revoke on Fargate, Cloud Run and Container Apps; a test pins that this does not happen.Money path: the only new write path is not writing (the flag stays set). Nothing new posts a Return. A refunded purchase can no longer be shown as active-and-failed, because only a single-send 4xx clears the flag. There are no DB or migration changes, and no new concurrent writers. A concurrent retry still hits the existing flip CAS, and the integration test checks that a retry posts no second Return.
Closes #1529
Verification
All evidence is local: unit tests with mocks, stub Azure clients, and the real
armreservationsReturn client pointed at a localhttptestTLS server; one integration test on real Postgres (testcontainers). Nothing called Azure.New tests (
internal/api/handler_purchases_revoke_uncertain_test.go,..._uncertain_integration_test.go):TestCallAzureReturn_AmbiguousFailure_KeepsFlagAndAnswersUncertaincontext.DeadlineExceeded(bare and in*url.Error), transport error, cut-off body: flag kept, uncertain 500, nothing recorded or revoked, one ReturnTestCallAzureReturn_RealClient_AmbiguousKeepsFlagTestCallAzureReturn_RealClient_Single4xxClearsFlag/_AcceptedRevokesTestCallAzureReturn_DefiniteRefusal_ClearsFlagTestIsAzureDefiniteRefusal_AttemptsAndStatusTestCallAzureReturn_LambdaTooLittleTime_RefusesBeforeClaimTestCallAzureReturn_EnoughTimeOrNotLambda_ProceedsTestRevokeClaimMinRemaining_BudgetTestRevokeAzure_UncertainReturn_FlagKeptUntilSweepReleases(integration)return_posted_at, not revoked; retry getsRECONCILE_PENDINGand posts no second Return; the sweep leaves the fresh flag alone; after agingrevocation_in_flight_atpast 2h the sweep releases it, still unrevokedExisting tests changed to match the decision:
TestCallAzureReturn_TransientError_ClearsInFlightis now..._KeepsInFlight, andTestRevokePurchase_AzureReturnClientErrornow expects the uncertain 500. The two #1527 "canceled during a failed Return clears the flag on a live context" tests (unit and integration) now use a definite 400 instead of a 503, so they still test the live-context clear.Fail-before / pass-after (unit and integration, run in throwaway copies of the tree):
git show origin/main:forhandler_purchases_revoke.goandfinalize_revocations.go, new files removed) with the changed tests:TestCallAzureReturn_TransientError_KeepsInFlightFAIL andTestRevokePurchase_AzureReturnClientErrorFAIL. The two 503-to-400 tests PASS on both versions, as intended.checkRevokeTimeLefta no-op):..._AmbiguousFailure_...FAIL,..._RealClient_AmbiguousKeepsFlagFAIL,..._LambdaTooLittleTime_...FAIL, and the integrationTestRevokeAzure_UncertainReturn_FlagKeptUntilSweepReleasesFAIL ("want the uncertain-outcome ClientError, got revoke azure: Return failed"). The definite-refusal, accepted and enough-time tests PASS on both versions; they guard against an over-eager fix.attempts > 1rule failsTestIsAzureDefiniteRefusal_AttemptsAndStatusand twoRealClient_AmbiguousKeepsFlagcases. Building the default factory's Return client without the counting policy fails the same two cases. Checking againstrevokeReturnTimeoutinstead of the full threshold failsLambdaTooLittleTime/just_under_the_needed.Combined-tree verification. Tested on a local, never-pushed tree:
queue/platform@40dbbdaf= origin/main6e2033cdplus:Linux, Go 1.26.9,
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true; Docker Postgres via testcontainers.go build ./...,go vet ./...,go vet -tags integration ./...and the fullgo test -race -short -count=1 ./....--build-tags integration.gocyclo -over 10is silent on the changed non-test files.-tags integration -count=1, one package at a time):internal/api,internal/database/postgres/migrations,internal/purchaseandinternal/serverpass.internal/confighad one failure, which comes from the fix(marketplace): close RI Marketplace poller gaps (pending rows with a listing id, superseded listings, deployment coverage) #1494 branch (a mid-claim listing gets settled); that branch is being fixed and isn't opened yet.scripts/test-git-secrets-allowlist.shpasses.Left out of scope / notes for reviewers
RECONCILE_PENDING, "Refund already issued. We will reconcile your audit record shortly. Do not retry." For a row whose Return is merely uncertain, "already issued" overstates it. Changing that response (for example keying the wording onreturn_posted_at) is outside fix(revoke): treat an ambiguous Azure Return failure as uncertain (keep the flag), and refuse the claim when too little Lambda time is left #1529's two items, so I left it alone; it may deserve its own issue.ResponseError, so it counts as uncertain and the flag stays for up to 2h. This is conservative. It is unlikely in practice because CalculateRefund has just used the same credential and cached token.attempts == 0). Production always builds the client throughnewAzureReturnClient.ClientErrormessage, the same as RI exchange; the frontend already shows it as "Failed to revoke: ".Frontend follow-up for the UI owner
No frontend change is needed for correctness. Optional polish:
frontend/src/history.tsshows the uncertain answer as an error toast, "Failed to revoke: Azure reservation return failed and may already have been accepted by Azure; verify the reservation state in the Azure portal before retrying". A warning style that does not say "Failed", and offers no immediate retry, would fit the uncertain case better. The new 503 ("not enough time was left ... retry the revoke") fits the existing error toast as it is.Maintainers: please mirror the issue's triage labels on this PR:
priority/p3,type/bug.🤖 Generated with Claude Code