Skip to content

sec(azure): re-check idempotency between in-process purchase retries - #1793

Merged
cristim merged 1 commit into
mainfrom
sec/1774-recheck-idempotency-between-retries
Aug 9, 2026
Merged

cristim merged 1 commit into
mainfrom
sec/1774-recheck-idempotency-between-retries

Conversation

@cristim

@cristim cristim commented Aug 9, 2026 •

Copy link
Copy Markdown
Member

Closes #1774

The gap

DoIdempotentPurchaseTwoStep looked up the idempotency token exactly once, before the retry loop was entered. Each retry then minted a new reservationOrderId via doCalculatePrice and purchased again, with nothing linking attempt N+1 to a purchase attempt N may have committed before reporting failure.

The guard covered a re-drive across invocations and nothing within one. The bound was purchaseMaxAttempts, not the guard.

Option 2 is ruled out by the vendor contract — established, not assumed

The issue asked to verify Azure's behaviour before choosing a shape, since an unverified vendor assumption created this class. Reusing one reservationOrderId across attempts so Azure deduplicates is not available:

  1. Azure's own error says the opposite. The 400 that triggers this retry path reads "Session timed out — Call CalculatePrice again and provide the new Reservation Order ID for purchase". The order ID is session-bound and explicitly dead after the only failure that reaches here. Reusing it is precisely what the vendor tells callers not to do.
  2. The SDK documents no idempotency. armreservations@v1.1.0: BeginPurchase is "Purchase ReservationOrder and create resource under the specified URI", and ReservationOrderClientBeginPurchaseOptions carries only ResumeToken. No idempotency-key parameter exists anywhere in the package.
  3. The repo already established this. fix(providers/azure): direct PUT to reservationOrders/{id} rejected by Azure ("Session timed out - Call CalculatePrice again") — switch to two-step CalculatePrice→Purchase flow (P0) #677's "Option B" note records that a client-supplied dedupe ID is unavailable on this API — which is why the tag-and-search strategy exists at all.

So this is option 1, and it is chosen on evidence rather than as a fallback.

The fix

The same lookup runs before every attempt after the first. A committed-but-failed attempt is found and its order returned instead of buying again. A lookup that cannot be completed refuses the retry rather than falling through — matching the pre-loop guard's contract, since treating an unusable lookup as "no existing order" would reinstate the hole.

The check runs before doCalculatePrice, so an already-committed purchase short-circuits without minting yet another order ID.

Cost: one list call per retry, on the retry path only. The happy path is unchanged.

Verification — both directions

The issue is explicit that a fix which simply refused to retry would pass a duplicate-only test while breaking the recovery this loop exists for. Three tests, driven through the real loop with a counting client:

test asserts without the re-check
DoesNotDoubleBuyWhenFirstAttemptCommitted exactly 1 purchase and 1 calculatePrice; the committed order is adopted FAILS
StillRecoversWhenFirstAttemptGenuinelyFailed the retry happens and succeeds — 2 purchases, 2 order IDs passes (the control)
RefusesRetryWhenRecheckLookupFails an unusable lookup stops the retry; 1 purchase FAILS — 3 purchases

The third is the clearest statement of the defect: three purchase calls reach Azure where the fix allows one. The recovery test passing both before and after is exactly its job — it is what proves the fix did not buy safety by disabling retries.

Two things surfaced while doing this

gocyclo caught the inlined version at 13, over the 10 the pre-merge check enforces. The re-check, the retry predicate and the inter-attempt delay are extracted — the same pattern fetchReservationOrdersPage already uses in this file for the pagination loop.

An existing test asserted exactly one idempotency lookup and had to change. Splitting it into two registrations surfaced the stateful-fixture hazard the issue itself warns about: testify hands back the same *http.Response for .Times(2), and the first read drains its bytes.Buffer, so the second lookup decoded an empty body. Each response is now minted separately.

Comments corrected, not left stale

Three claims asserted that no recheck exists between attempts — on errRetryabilityUnknown, on DoIdempotentPurchaseTwoStep, and in the package doc. All three now describe the new behaviour. The loop's trailing "failed after N attempts (session timeout)" is unreachable and named only one of two triggers; it is now accurate and marked unreachable with the reason.

Gates

providers/azure: gofmt clean, build, vet, go test -race -count=1 ./... green. Root: build, vet, gocyclo -over 10 -ignore "_test\.go" . exit 0 empty, golangci-lint v2.10.1 exit 0 with a genuine 0 issues. line.

providers/azure lint set diff vs origin/main (a37e14790, after #1792 merged): 589 → 588, zero only-in-HEAD, one only-in-BASE. Rebased onto that merge; the branch is now a single commit as expected.

Getting there took three iterations, and the method earned itself again: my first version added three findings (a bodyclose from the extra fixture, a honouring misspelling, and a gocritic unnamedResult on the new helper). A totals check would have shown 589 → 592 without naming any of them. All three fixed; the one removed line is a fakeResp call site replaced by an inline literal.

Lint note: two runs came back exit 3 with an empty findings block — Error: parallel golangci-lint is running, the false-clean trap. Neither was recorded; the numbers above come from runs that produced a real findings block.

Summary by CodeRabbit

  • Bug Fixes
    • Improved Azure reservation purchase reliability during transient session timeouts and unreadable error responses.
    • Prevented duplicate reservation orders when a purchase succeeds but the response is uncertain.
    • Purchases now re-check existing orders before retrying and stop safely when verification fails.
    • Added bounded, cancellation-aware retry handling for more predictable failure recovery.

@cristim cristim added triaged Item has been triaged priority/p1 Next up; this sprint severity/high Significant harm urgency/this-sprint Within the current sprint impact/many Affects most users effort/m Days type/security Security finding labels Aug 9, 2026
@coderabbitai

coderabbitai Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The Azure reservation purchase flow now uses a shared guarded retry loop. Idempotent purchases recheck existing orders before retries, handle unreadable 400 responses, respect cancellation during delays, and stop duplicate purchases. Tests cover committed purchases, genuine retries, failed rechecks, and independent response bodies.

Changes

Azure reservation retry safety

Layer / File(s) Summary
Shared guarded retry flow
providers/azure/services/internal/reservations/purchase.go
Retry classification, cancellation-aware delays, bounded attempts, and retry-time idempotency checks are centralized.
Idempotent wrapper integration
providers/azure/services/internal/reservations/purchase.go
The idempotent wrapper delegates to the guarded loop while preserving the idempotency token and failing closed when lookup fails.
Retry regression coverage
providers/azure/services/internal/reservations/purchase_test.go
Tests verify committed-order adoption, genuine retry recovery, failed-recheck aborts, unreadable response handling, and independent lookup responses.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

  • LeanerCloud/CUDly#1767 — This PR extends its unreadable-response retry handling in the Azure purchase flow.
  • LeanerCloud/CUDly#1792 — Both PRs modify Azure reservation retry behavior and tests for unreadable responses and session-timeout retries.
  • LeanerCloud/CUDly#680 — Both PRs modify the Azure reservation purchase implementation and its retry tests.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address all requirements in [#1774], including rechecks, duplicate prevention, failed-lookup handling, recovery, and regression tests.
Out of Scope Changes check ✅ Passed The code, tests, comments, and fixture updates directly support the linked issue and stated retry-safety objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding an idempotency re-check between Azure purchase retries.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sec/1774-recheck-idempotency-between-retries

Comment @coderabbitai help to get the list of available commands.

DoIdempotentPurchaseTwoStep looked up the idempotency token exactly once,
before the retry loop was entered. Each retry then minted a NEW
reservationOrderId via doCalculatePrice and purchased again, with nothing
linking attempt N+1 to a purchase attempt N may have committed before
reporting failure. The guard covered a re-drive ACROSS invocations and
nothing within one; the bound was purchaseMaxAttempts, not the guard.

Option 2 from the issue -- reuse one reservationOrderId across attempts so
Azure deduplicates -- is ruled out by the vendor contract, established rather
than assumed:

  - Azure's own 400 says "Session timed out - Call CalculatePrice again and
    provide the NEW Reservation Order ID for purchase". The order ID is
    session-bound and explicitly dead after the only failure that reaches
    this retry path, so reusing it is precisely what Azure tells callers not
    to do.
  - armreservations v1.1.0 documents no idempotency on BeginPurchase, and
    ReservationOrderClientBeginPurchaseOptions carries only ResumeToken. No
    idempotency-key parameter exists anywhere in the SDK.
  - The package already records (#677 Option B) that a client-supplied
    dedupe ID is unavailable on this API, which is why the tag-and-search
    strategy exists at all.

So option 1: the same lookup runs before every attempt after the first. A
committed-but-failed attempt is found and its order returned instead of
buying again. A lookup that cannot be completed refuses the retry rather
than falling through, matching the pre-loop guard's contract -- treating an
unusable lookup as "no existing order" would reinstate the hole.

Cost: one list call per retry, on the retry path only. The happy path is
unchanged.

Three tests, both directions, because a fix that simply refused to retry
would pass a duplicate-only test while breaking the recovery this loop
exists for:

  - a first attempt that committed before failing must yield exactly one
    purchase and one calculatePrice (the re-check runs before minting
    another order ID)
  - a genuinely failed first attempt must still be recovered by the retry
  - a re-check whose lookup fails must stop the retry

The first and third fail without the re-check; the third shows 3 purchases
where the fix gives 1. The recovery test passes both before and after, which
is its job as the control.

purchaseTwoStepGuarded reached gocyclo 13 with the re-check inlined, over the
10 the pre-merge check enforces, so the re-check, the retry predicate and the
inter-attempt delay are extracted -- the same pattern fetchReservationOrdersPage
already uses for the pagination loop.

An existing test asserted exactly one idempotency lookup and had to change.
Splitting it into two registrations surfaced the stateful-fixture hazard the
issue warns about: testify returns the same *http.Response for .Times(2), and
the first read drains its bytes.Buffer, so the second lookup decoded an empty
body. Each response is now minted separately.

Three comments that claimed no recheck exists between attempts -- on
errRetryabilityUnknown, on DoIdempotentPurchaseTwoStep and in the package doc
-- are corrected rather than left asserting the opposite of the new behavior.

Closes #1774

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cristim

cristim commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

Merging. Closes #1774.

Independently reviewed, with every claim re-derived by mutation rather than read. The reviewer is the agent that found this gap during #1767, and did not write the fix, so independence holds.

The defect

DoIdempotentPurchaseTwoStep looked up the idempotency token exactly once, before DoPurchaseTwoStep was entered. The internal loop then ran up to purchaseMaxAttempts with no recheck between attempts, each minting a fresh reservationOrderId. So the guard caught a re-drive across top-level invocations and nothing within one: if an attempt committed on the provider side and returned a failure the client classified as retryable, the next attempt would commit again.

Pre-existing on the IsSessionTimeout path since #677/#1550. #1767 widened what could reach it and was narrowed to 400 as the mitigation; this is the fix.

The shape was chosen on evidence, and the evidence contradicted my preference

I favoured reusing the reservationOrderId so the provider would deduplicate. The vendor contract rules that out, and the answer was already in this repo:

  • Azures own 400 says "Call CalculatePrice again and provide the new Reservation Order ID for purchase." The ID is session-bound and dead after exactly the failure that reaches this path.
  • armreservations@v1.1.0 exposes no idempotency parameter. Spot-checked in review with go doc against the installed version: ReservationOrderClientBeginPurchaseOptions has exactly one field, ResumeToken.
  • #677s own Option-B note already recorded that a client-supplied dedupe ID is unavailable on this API, which is why the tag-and-search strategy exists.

Had the attractive option been taken on its attractiveness, the fix would have reused an ID Azure had already retired, inside a change whose purpose is preventing a double purchase. Worth recording that establishing this took about ten minutes and no external documentation: it went unknown because nobody asked, which is how the original class started.

The fix

Lookup before every attempt after the first. A committed-but-failed attempt is found and adopted; a lookup that cannot complete refuses the retry rather than falling through. Placed before doCalculatePrice, so nothing mints another order ID once the purchase is known to exist. Cost is one list call per retry, on the retry path only.

Verified by execution, not by reading

Reviewer reverted purchaseTwoStepGuarded to skip the recheck entirely and re-ran:

test under the pre-fix mutation
DoesNotDoubleBuyWhenFirstAttemptCommitted FAILS
StillRecoversWhenFirstAttemptGenuinelyFailed passes (the control)
RefusesRetryWhenRecheckLookupFails FAILS: 3 purchases

Three purchase calls reaching Azure where one is allowed is the defect in a single number. The control passing in both configurations is what proves safety was not bought by disabling retries, which a duplicate-only suite would never catch.

Also verified independently:

  • Lookup-failed is distinguished from not-found. FindReservationOrderByIdempotencyToken returns a clean three-way split; the error is propagated as a refusal rather than treated as not-found. Both branches exercised for real.
  • No off-by-one. A probe forcing three attempts, with the commit happening on attempt 2, produced exactly 2 purchases: the recheck runs before attempt 3 as well.
  • The adoption path returns the same shape as a clean success (return existingID, nil), so callers store it identically with no special-casing.
  • The extracted helpers are behaviour-preserving — gocyclo forced them out at 13, over the 10 gate.
  • No stale exhaustiveness claims left. One remaining "no recheck" comment was checked and is correct: the bare DoPurchaseTwoStep entry point has no token, so no recheck is possible there. A true scoped limitation, not a stale claim.

The fixture hazard is real, and was reproduced

Reverting the two .Once() calls to a shared .Times(2) reproduces the exact failure: unexpected end of JSON input on the second lookup, because testify replays the same *http.Response and the first read drains its buffer. Each response is now minted separately. Necessary, not decorative.

Gates

20/20 checks pass. Reviewer re-ran everything against the final head rather than carrying it over, using separate clones at exact commits rather than git checkout <ref> -- <path> on a shared tree, which stages and leaves the index disagreeing with the working tree.

golangci-lint v2.10.1 run sequentially (base then head, never parallel) to avoid the exit-3-with-empty-findings false clean: base a37e1479 = 589, head = 588, both with genuine non-empty findings blocks. comm set diff normalized by file+message: zero only-in-HEAD, exactly one only-in-BASE (the bodyclose this removes).


Note for anyone reading this after deploying: Azure deploys have been failing on every main run since 2026-07-19 (#1794, p0). This fix, like every other providers/azure change in that window, is merged but not necessarily live.

@cristim
cristim merged commit eca603a into main Aug 9, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/many Affects most users priority/p1 Next up; this sprint severity/high Significant harm triaged Item has been triaged type/security Security finding urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sec(azure): idempotency is not re-checked between in-process purchase retries, so a committed-but-failed attempt can double-buy

1 participant