Repository navigation
fix(revoke): detach the Azure Return once the in-flight claim is held - #1528
Conversation
The Azure Return ran on the request context. The app's HTTP server gives every request 30s (internal/server/http.go), so that deadline or a client disconnect could land while the Return was in flight: Azure could still accept it, but the handler saw context.Canceled, recorded nothing, and cleared the in-flight flag on the same dead context, which also failed. The sweep then released the row after 2h and a refunded purchase read as active. Once the claim is held, the Return now runs under ctxutil.DetachedWithTimeout(ctx, 3m), as the RI exchange handler (LeanerCloud#790) and purchase/manager.go do. 3m lets azcore's default pipeline (4 tries, up to about 11.4s of backoff, 30s dial + 10s TLS timeouts per try, about 171s worst case) end a failing call itself. The claim and everything before it stay on the request context. If the Return fails, the in-flight clear runs on its own detached 30s budget so it can land after the request is gone. The persist retries now respect their 30s budget: each attempt is capped at 4s, so a hung first attempt leaves time for the retries, and a backoff that would outlast the budget is not taken (before, plain sleeps could run the worst case to about 43s). TestLambdaTimeoutCoversRevokeBudget keeps every shipped Lambda timeout above 3m30s plus margin. Also correct the comments that said Fargate and Container Apps do not cap request time: the app's own server caps every request at 30s. Tests (stub Azure clients; nothing calls Azure): the request is canceled while the Return is in flight and Azure accepts it, so the Return is recorded and the purchase revoked (unit, and integration on real Postgres); the same with a failed Return clears the flag on a live context (unit and integration); a 1h backoff is skipped against the 30s budget; a hung first attempt is cut at its cap and the retry revokes. The cancel-before-claim test still asserts no claim and no Return. Closes LeanerCloud#1527 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked: I checked that the claim and everything before it (CalculateRefund, quote check) stay on the request context, so a request canceled before the claim still takes no claim (the #1526 test still passes). I checked that only the Return, the error-path clear, and the persist are detached, each with its own bound. I checked that the attempt cap plus backoff (4x4s + 1+3+9s = 29s) fits the 30s budget and that Body-stale: none. Every claim I checked holds (root cause, the 30s server timeout in internal/server/http.go, the budget arithmetic, the Lambda floor, and the fail-before table). Reviewer: rF18. 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: rF17. 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 |
Root cause
After #1526, the writes that follow an accepted Azure Return survive request cancellation, but the Return call itself still ran on the request context. The app's HTTP server gives every request a 30s deadline (
handleHTTPRequestininternal/server/http.go). If that deadline or a client disconnect landed while the Return was in flight, Azure could still accept it, but the handler sawcontext.Canceled. It then recorded nothing and calledClearRevocationInFlighton the same dead context, which also failed. The finalize sweep released the row 2h later, and a refunded purchase read as active. Azure refuses a second Return, so there was no double refund.Fix (
internal/api/handler_purchases_revoke.go)Detached Return. Once
claimRevocationInFlightholds the claim, the newpostAzureReturnposts the Return underctxutil.DetachedWithTimeout(ctx, revokeReturnTimeout). This is the pattern the RI exchange handler (fix(ri-exchange): run ExecuteExchange outside the request context #790,handler_ri_exchange.go) andpurchase/manager.gouse. The claim and everything before it (CalculateRefund, the quote check) stay on the request context, so a request canceled before the claim still takes no claim and posts no Return.revokeReturnTimeout = 3m, sized from the Azure client's own limits. The armreservations client runs on azcore v1.23.3's default pipeline:sdk/internal/transport), and no per-try timeout.If all four tries use up the dial and handshake timeouts, that is about 171s, so 3m lets the client end a failing call with its own error. Only a response that never arrives, or Azure throttling with long Retry-After waits, runs into the 3m deadline. On Lambda, 3m plus the 30s persist budget plus the 30s margin is 240s, under the 300s
lambda_timeoutfloor. The newTestLambdaTimeoutCoversRevokeBudgetpins this, using the samelambdatimeout.AssertCovershelper as the RI exchange and purchase budgets (fix(iac): default Lambda timeout (30s) is shorter than detached in-process budgets #913).Error path.
handleAzureReturnErrornow runsClearRevocationInFlighton its own detachedrevokePersistTimeout(30s) context, so the clear can land after the request is gone. The status mapping (422/400/500) is unchanged.Retry waits respect the budget. The
time.Sleepbetween persist attempts is replaced bywaitRevokeRetry. It skips a backoff that would outlast the 30s budget and stops early if the context ends. Before, the worst case was about 43s against a 30s budget.Per-attempt cap. Each persist attempt (two single-row UPDATEs by primary key) is now capped at
revokePersistAttemptTimeout = 4s, so a first attempt stuck on a hung connection cannot use up the whole budget (issue proposal item 2). Four attempts at the cap plus 13s of backoff is 29s, which fits in 30s;TestRevokePersistScheduleFitsBudgetpins this. Both writes are idempotent (COALESCEonreturn_posted_at;MarkPurchaseRevokedtreats an already-revoked row as success), so retrying an attempt that was cut off after its commit is safe. The 207 log line now reports the number of attempts actually made.Comments. The
staleRevocationInFlightAftercomment ininternal/purchase/finalize_revocations.goand its test comment said Fargate and Container Apps don't cap request time. They now say that the app's own server caps every request at 30s (internal/server/http.go), and that past the flip the handler bounds the Return and its writes at 3m and 30s.No migration and no schema change. The claim (the only concurrency-relevant DB operation) is unchanged, so this adds no new race. A concurrent revoke during the detached Return still sees the flag and gets RECONCILE_PENDING, as before.
Closes #1527
Verification
All of this is unit, mock or fixture evidence plus local integration on real Postgres (testcontainers), run with stub Azure clients. Nothing called Azure.
Commands run on this branch (head
69c05a5d), withAWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true:Fail-before / pass-after. I swapped in the
origin/mainhandler, keeping only the two new constant names so the tests compile, and ran the new and changed tests:TestCallAzureReturn_RequestCanceledAfterReturn_StillRecordsAndMarks: request canceled while the Return is in flight, then acceptedcontext.CanceledTestRevokeAzure_RequestCanceledAfterReturn_RecordsAndRevokes: same casereturn_posted_atandrevoked_atset, flag clearedTestCallAzureReturn_RequestCanceledDuringFailedReturn_ClearsInFlight: canceled mid-Return, Azure answers 503TestRevokeAzure_RequestCanceledDuringFailedReturn_ClearsFlag: same caserevocation_in_flight=false, nothing recordedTestPersistAzureRevocation_RetryWaitRespectsBudget: 1h backoff against the 30s budgetTestPersistAzureRevocation_HungAttemptLeavesBudgetForRetry: first attempt hangsTestCallAzureReturn_RequestCanceledBeforeReturn_PostsNothing: canceled before the claimTestLambdaTimeoutCoversRevokeBudget,TestRevokePersistScheduleFitsBudgetMutation probes (each applied alone to the fixed code; every one was caught):
time.Sleepinstead ofwaitRevokeRetry: the budget test fails;context.WithoutCancelwith no deadline: the detached-deadline assertion fails.Test changes:
cancelAfterReturnClientnow behaves like the real SDK: it returns the context's error if its context was canceled. Its old assertion "the Return itself must run under the request's context" is replaced by asserting that the Return is detached with a 3m deadline.ClearRevocationInFlightmock expectations now match any context.Full-suite verification at local queue commit
1c0ef0b0= origin/maine3de1523+fix/issue-1527. Passed:go build ./...;go vetwith and without-tags integration;go test -race -short -count=1 ./...; golangci-lint v2.10.1 with and without the integration tag; gocyclo -over 10;go test -race -tags integrationoninternal/apiandinternal/purchase; the git-secrets scan andscripts/test-git-secrets-allowlist.sh.Out of scope (noted, not changed)
handleAzureReturnErrorclears the in-flight flag as before (the brief said to keep this path). Azure refuses a second Return, so there is no double refund, but the row can read as active while it has been refunded. The Azure RI exchange path treats non-4xx failures as ambiguous (#1642). A follow-up could keep the flag set for non-4xx Return failures and leave them to the sweep or an operator.WriteTimeoutis 30s, so if the detached Return takes longer, the response cannot be delivered. The record is still written. A retry from the UI gets the existing RECONCILE_PENDING (207), because the flag is set. This matches the RI exchange behavior after fix(ri-exchange): run ExecuteExchange outside the request context #790.No frontend change is needed.
Maintainer: please mirror the issue's triage labels on this PR:
priority/p3,type/bug(the issue has notriagedlabel).🤖 Generated with Claude Code