Repository navigation
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
Description
Symptom
QA reproduces with a freshly-created Azure purchase (minutes between create and approve, not a stale session):
Failed to approve: execution ed9b0f07-3e84-436a-b501-92d47b5fbb70 could not be approved:
some purchases failed: [Standard_B2ats_v2: purchase failed:
reservation purchase failed with status 400:
{"error":{"code":"BadRequest",
"message":"Session timed out - Call CalculatePrice again and provide the new Reservation Order ID for purchase"}}]
Standard_B2ats_v2 is a Burstable v2 VM SKU (a relatively new family). The error is returned by Azure's Reservations API at PUT time.
Root cause
Every Azure reservation-purchase client uses the direct-PUT pattern:
PUT https://management.azure.com/providers/Microsoft.Capacity/reservationOrders/{orderID}?api-version=2022-11-01
Body: { sku, location, properties{ reservedResourceType, billingScopeId, term, quantity, displayName, appliedScopeType, renew } }
The orderID is generated client-side (deterministic from idempotency token per #641, or a fresh UUID).
Azure now rejects this pattern for at least Burstable v2 VMs (and likely other newer SKU families) with the "Session timed out" error. The Reservations API is shifting to a two-step flow that requires the caller to first call calculatePrice to mint a session-bound order ID and a price quote, then call purchase with the quote ID.
This is a structural API contract change on Azure's side, not a bug in our code per se — but our code now produces 400s on real-world purchase attempts within minutes of creation.
Affected clients (all 6 reservation-based Azure services)
| File | Pattern |
|---|---|
providers/azure/services/compute/client.go:466 |
Direct PUT |
providers/azure/services/database/client.go:285 |
Direct PUT |
providers/azure/services/cache/client.go:281 |
Direct PUT |
providers/azure/services/search/client.go:254 |
Direct PUT |
providers/azure/services/cosmosdb/client.go:277 |
Direct PUT |
providers/azure/services/managedredis/client.go:259 |
Direct PUT |
(compute/exchange.go references reservationOrders/{orderID}/reservations/{resID} but is the exchange flow, not the purchase flow — separate scope.)
Fix
Switch from the direct-PUT pattern to Azure's documented two-step flow:
POST /providers/Microsoft.Capacity/calculatePrice?api-version=<v>with the reservation request body (sku, location, properties). Returns{ properties: { reservationOrderId, paymentSchedule, billingCurrencyTotal, ... } }. The returnedreservationOrderIdis session-bound and short-lived (minutes).POST /providers/Microsoft.Capacity/reservationOrders/{reservationOrderId}/purchase?api-version=<v>to commit the order using the quote.
The session is short enough that the calculate → purchase round-trip must happen within the same handler invocation (no human-in-the-loop between). Our flow is: approve email click → executePurchase synchronous → calls Azure. Steps 1+2 fit within that single call without a human delay.
Idempotency: the new flow no longer accepts a client-supplied order ID; Azure mints one in step 1. To keep #641's idempotency guarantee:
- Cache the (executionID, recIndex) → Azure-returned reservationOrderId mapping on the execution row's
recommendations[i]JSONB (new field) on the FIRST calculate call. - On a re-drive, look up the cached order ID and skip straight to the purchase POST. If Azure has already retired that order ID (session expired), the purchase POST will return the "Session timed out" error and we re-run the calculate.
- Alternative: skip the idempotency cache entirely and accept that re-drives create a new order ID each time, but always check via the purchaseURL whether the previous attempt's commitment already exists by tag before re-purchasing. Less clean but doesn't need a schema change.
Acceptance criteria
- All 6 Azure reservation-purchase clients use the calculatePrice → purchase two-step flow
- The change is verified end-to-end against the live Azure subscription for at least Standard_B2ats_v2 (the reported SKU) AND one other family to ensure the fix isn't SKU-specific
- Idempotency story is preserved (feat(purchases): extend IdempotencyToken to all commitment executors (AWS RDS/ElastiCache/MemoryDB/OpenSearch/Redshift, Azure, GCP) — blocks #639 #641 invariant): a re-drive of the same execution does not create a duplicate Azure commitment
- Unit tests cover: calculate-then-purchase happy path, calculate failure (4xx), purchase-after-calculate timeout (re-run calculate), idempotent re-drive
- OAS / API-version pinning: pick the current GA api-version, document the choice in a comment
Severity
P0 / critical. Every freshly-created Azure VM reservation purchase to a Burstable v2 SKU (and likely other newer families) fails today. Customer impact is total: dashboard "Approve" button on those purchases is broken.
Cross-references
- feat(purchases): extend IdempotencyToken to all commitment executors (AWS RDS/ElastiCache/MemoryDB/OpenSearch/Redshift, Azure, GCP) — blocks #639 #641 (idempotency token derivation for Azure — needs to be re-thought under the new flow)
- fix(purchases): EC2 RI purchase times out under transient AWS SDK retries; Lambda 60s budget blown (P0) #667 (sync-execute timeout — the new flow's two API calls increase the in-Lambda window; verify Lambda timeout headroom)
- bug(purchases): approve strands execution in 'approved' (purchase never runs, no error) on interrupted sync execution #632 (execution stranding when sync-execute fails)
- Azure docs: https://learn.microsoft.com/en-us/rest/api/reserved-vm-instances/reservation-order/purchase + https://learn.microsoft.com/en-us/rest/api/reserved-vm-instances/reservation-order/calculate
Activity
fix(azure): switch 7 reservation clients to two-step calculatePrice->…
cristim commentedon May 28, 2026
Verified shipped on feat/multicloud-web-frontend via PR #680 (merged 2026-05-22). All 7 Azure reservation clients now use
DoPurchaseTwoStep(calculatePrice -> purchase), retrying up to 3 times on 'Session timed out' 400s by re-running calculatePrice from scratch. PR #729 (merged 2026-05-26) restored idempotency token threading through this flow.sec(azure): re-check idempotency between in-process purchase retries
sec(azure): re-check idempotency between in-process purchase retries (#…