Repository navigation
fix(api/azure): RI exchange execute has no submit-time idempotency; a client retry double-spends #1642
Description
Summary
The Azure RI exchange execute endpoint (POST /api/ri-exchange/azure-instances/exchange) has no submit-time idempotency. A client retry after a timeout can commit the same exchange twice, spending roughly twice the intended amount while each individual request stays inside MaxPurchaseAmount.
ExecuteExchange's doc comment presents Azure's CalculateExchange session ID as the replay-protection mechanism. It is not one as used here: executeAzureExchange deliberately mints a fresh session on every request (internal/api/handler_ri_exchange.go, the client.CalculateExchange(...) re-quote before commit) precisely so a client-supplied or stale session can never bypass the guardrails. The consequence is that two POSTs of the same logical exchange carry two different session IDs, so Azure's own replay protection never fires. There is no request-level dedupe either, unlike the purchase path's purchaseIdempotencyKey (internal/api/handler_purchases.go, refs #644 / #643).
Concrete scenario
- Source reservation has quantity 10. Caller POSTs an exchange of 4 of them.
- The Azure LRO is polled with
PollUntilDoneand takes minutes. - The client times out and retries the identical request.
- The second request re-quotes against the now-quantity-6 reservation, passes every guardrail (ownership, region, currency, cap), and commits a second exchange of 4 plus a second target purchase.
- Net effect: twice the intended spend, each half individually under the cap.
Compounding factor: a ctx cancellation mid-ExecuteExchange is mapped to a flat 500 "exchange execution failed" (mapAzureExchangeError), even though BeginPost may already have submitted the operation. That is exactly the response that provokes the retry in step 3.
Fix direction
- Fingerprint the request over
(creator, subscription_id, sorted sources incl. quantity, sorted targets, cap, currency)and dedupe on it, mirroringpurchaseIdempotencyKey. - Per the feat(mcp): CUDly MCP server for RI/SP/CUD purchases across AWS, Azure, GCP #1495 precedent, the fingerprint MUST fold in
subscription_id:ListExchangeableReservationsis tenant-wide, so a scope-blind token can alias across subscriptions. - A post-submit cancellation should surface as "submitted, outcome unknown - verify in the portal", not a flat failure that invites a retry.
Scope note
The AWS executeExchange handler in the same file has the same absence, so this is a pattern gap across both providers, not a regression introduced by #1515 / PR for issue #596. Fixing it should cover both paths.
Found during adversarial review of PR #1515 (Azure RI exchange execute). Out of scope for that PR, which fixed the Regions-constraint source gap.
Activity
docs(api): a 409 from RI exchange execute asserts no outcome (#1642)
test(api): split the RI exchange idempotency tests under the file-siz…
test(api): assert an invalid exchange body never reaches the constrai…