API Design Patterns with Azure

Your API works on the happy path. The interesting part starts when a request times out, a client retries, or a dependency goes down. So I looked at various common API design patterns: RESTful resources, versioning, pagination, consistent errors, idempotency keys, conditional requests, rate limiting, async request-reply, API gateway, webhooks, circuit breaker, and aggregation. Moreover, I wanted to see how they behave on Azure, so I built all in one repo and wrote four implementations of idempotency keys. Three of them pass the test most teams write. One of them is correct. Then I ran all four against the deployed app and counted the charges.

The code is on GitHub: steefjan1/api-design-patterns-azure.

Twelve patterns, three kinds of decision

The sample runs Azure API Management (BasicV2) in front of one Azure Functions app on Flex Consumption (.NET 8 isolated), with Table Storage and Storage queues behind it. Managed identity everywhere, no storage keys.

Placing each pattern on the diagram sorts the list into three groups:

GroupPatternsThe question it answers
Contract01 resources, 02 versioning, 03 pagination, 04 errors, 06 conditional requestsWhat can the client rely on?
Retries and time05 idempotency, 08 async request-reply, 10 webhooksWhat happens when one request outlives one round trip?
Capacity and failure07 rate limiting, 09 gateway, 11 circuit breaker, 12 aggregationWhere do we say no, or answer with less?

The gateway commenter is right. In the repo, rate limiting (07) is one rate-limit-by-key policy on the gateway (09), keyed on the APIM subscription. The same policy file renders the gateway’s 429 as application/problem+json (04), so a client parses a throttled request exactly like a validation error from the function. A pattern list draws these as separate boxes. In a deployment, they are one policy.

One measured caveat: 70 simultaneous calls against a limit of 60 per minute let 69 through and throttled one, with Retry-After: 9. The gateway’s counters are synchronized asynchronously, so a burst overshoots before the limit bites. Treat a gateway rate limit as capacity protection, not as an exact quota.

The behavior-first commenter is right too, and the repo shows why. Look at what the “retries and time” patterns do underneath. The idempotency store claims a key with an insert that fails if the row exists. The webhook inbox dedups webhook-id with the same insert. The PATCH handler rejects lost updates with a compare-and-swap on the ETag, and so does the idempotency store when it completes a claim. Twelve names, two primitives: insert-if-absent and compare-and-swap. Table Storage gives you both (AddEntity, and UpdateEntity with an ETag). So do Cosmos DB, SQL Server, and Redis.

The timeout that tells you nothing

A payment call times out. Did it succeed? As one commenter wrote, a timeout tells you almost nothing. You retry because you think it failed, and later you find out the first payment went through too.

Idempotency keys fix this by turning “do it again” into “give me the result of the first one”. The client sends Idempotency-Key: K and reuses K on every retry of that operation. The server remembers K and replays the stored response. That description fits on a slide. The bugs live in the three things it does not say.

Three ways to implement idempotency keys without the semantics

The check is racy. The handler reads the store (“have I seen K?”), finds nothing, charges, then saves K. Two retries that arrive within the same 400 milliseconds both read nothing and both charge. Both callers get a 201.

The key is scoped to the endpoint. The sample exposes the same payment through POST /payments and POST /orders/{id}/pay. If the key is stored as route plus K, a retry through the second route is new work. This is what you get from middleware that keys on the request path, or from one key table per service.

The result is not durable. The claim sits in Table Storage; the response sits in process memory or in a cache with eviction. After a restart, or when the retry lands on another instance, the key exists, and the response does not. The handler answers “already processed” without a payment id. The client is back to the timeout problem, and its likely next move is a new key.

The fourth implementation does three things differently:

// 1. Claim the key atomically. Exactly one concurrent caller gets an ETag back.
var claim = new IdempotencyRecord(scope, key, Pending, fingerprint, now.Add(lease));
var etag = await store.TryInsertAsync(claim, ct); // AddEntity: 409 if it exists
// 2. Pass the key downstream. Every retry asks the provider for the same charge.
var receipt = await provider.ChargeAsync(command, ChargeIdFor(scope, key), ct);
// 3. Store the response durably, only if we still own the claim.
await store.TryReplaceAsync(claim with { Status = Completed, ResponseBody = body }, etag, ct);

When the insert fails, the handler reads the row and decides. A different payload under the same key is a 422. A completed claim is a replay with Idempotent-Replayed: true. A pending claim within its lease is a 409 with Retry-After: 1. An expired lease is taken over with a conditional update. The scope is the caller plus the key, and behind the gateway the caller comes from the APIM subscription, not from a header the client picked.

Step 2 is the one most write-ups skip. If the provider charged the card and the answer got lost, the handler releases its claim and returns 503. The client retries with the same key, the handler runs again, and the provider recognizes the charge id. Without a key that travels downstream, the most careful upstream store still double charges on that path. The racy version fails it for exactly that reason.

Why the tests stay green

The usual test sends the same request three times, one after another, and asserts one charge. All four implementations pass it. Each broken one fails a condition the sequential test never creates: concurrency, a second route, a restart, a lost reply. The xUnit project asserts those failures on purpose, so the evidence stays in the build.

scripts/race.ps1 runs the same twenty cells against the deployed app and reads the ledger. On Azure, the matrix came out exactly as predicted. The one number worth quoting: a burst of twenty identical requests with one key made the racy version charge the card 16 times, and all twenty callers received a 201.

Building the race script produced its own example. Windows PowerShell 5.1 runs on .NET Framework, which allows two connections per host by default. A burst of twenty requests quietly becomes ten pairs, the race window never opens, and the racy implementation looks fine. One line, ServicePointManager.DefaultConnectionLimit = 64, separates a passing test from a real one. In-process tests have the same trap in another form: a lock around a dictionary works on one instance, and Flex Consumption can run the app on forty.

What idempotency keys do not solve

Idempotency keys make retries safe for one operation. They leave three gaps; the sample names in code comments:

  • The response must fit. A table property holds 64 KiB. Large responses go to Blob Storage with a pointer in the row.
  • Side effects after the claim are still two writes. The sample enqueues a payment. captured webhook after storing the response. A crash between those writes loses the event. A transactional outbox closes that gap.
  • Per-instance state stays per instance. The in-code circuit breaker learns about an outage once per instance. In the demo it rejected calls in about 40 ms once open, against about 150 ms per failing call before. The APIM backend breaker sees the whole app but trips for every route. Neither is wrong; pick per dependency.

Where this is the wrong answer

Not every write needs an Idempotency-Key header.

  • PUT and DELETE are idempotent by definition. The sample’s DELETE answers 204 twice. Use conditional requests (If-Match) for lost updates instead.
  • If the business already has a natural unique id, use it. A unique constraint on “one payment per order” is simpler and stronger than a generic key store.
  • For long-running, multi-step work, Durable Functions already gives you instance ids, replay and the 202 status shape of pattern 08.
  • If your payment provider does not accept an idempotency key, no upstream design gives you exactly-once charging. Reconcile against the provider’s ledger and make that part of the design.

Try it

azd up # Functions, Storage, APIM BasicV2 in swedencentral; postdeploy seeds data
dotnet test # the failure matrix, no Azure needed
./scripts/race.ps1 # the same matrix against Azure, counted on the ledger
./scripts/demo.ps1 # all twelve patterns: status codes and headers
azd down --purge # APIM BasicV2 bills by the hour

One deploy lesson for free: the seed function first lived on admin/seed. The Functions host reserves routes that start with admin for its own API. The function still appears in az functionapp function list, and every call returns an empty 404. It now lives on ops/seed.

Conclusion

The list was right that idempotency keys matter. The comment was right about why: the key is the easy part. The atomic claim, the scope, the durable result and the key that travels downstream are the pattern. Test those under concurrency, or the green build is telling you about a different system than the one in production.

Leave a Reply