{"componentChunkName":"component---src-templates-blog-post-js","path":"/blog/2026-09-04-idempotency-keys-fastapi-safe-retries/","result":{"data":{"site":{"siteMetadata":{"title":"M.Hassan Ahmed","author":"Hassan11196"}},"markdownRemark":{"id":"7350b3d1-3f0a-518b-b0f4-a13039e927bb","excerpt":"A client sends , and the handler runs. Somewhere between the database commit and the response reaching the browser, the connection drops. The client saw a…","html":"<p>A client sends <code class=\"language-text\">POST /requests</code>, and the handler runs. Somewhere between the database commit and the response reaching the browser, the connection drops. The client saw a timeout, not a <code class=\"language-text\">201</code>, so it does the reasonable thing and retries. Now the same action has run twice. On an ops console, that means two copies of a production request queued into the grid. On a payments API, it means charging a card twice.</p>\n<p>I hit this while building the operator console for <a href=\"/project/cms-workflow-operations/\">CMS workflow operations</a> at CERN. Operators trigger actions that clone requests, change site policies, and re-queue work across the Worldwide LHC Computing Grid. A double-submit there is not cosmetic. It schedules real compute.</p>\n<p>The fix is old and well understood: idempotency keys. This post is for backend engineers who have a <code class=\"language-text\">POST</code> endpoint with side effects and need a retry to be safe, whether the retry comes from a nervous user double-clicking, a load balancer, or a client library with automatic backoff. I cover how the keys work, why a simple cache is not enough, an atomic implementation in Postgres and FastAPI, and where it breaks in production.</p>\n<h2>What “idempotent” actually buys you</h2>\n<p>An operation is <a href=\"https://developer.mozilla.org/en-US/docs/Glossary/Idempotent\">idempotent</a> if doing it twice leaves the system in the same state as doing it once. The HTTP spec defines <code class=\"language-text\">GET</code>, <code class=\"language-text\">PUT</code>, and <code class=\"language-text\">DELETE</code> as idempotent. <code class=\"language-text\">POST</code> is not, and that is the whole problem: <code class=\"language-text\">POST</code> is where you create things, and creating the same thing twice is exactly the failure you want to avoid.</p>\n<p>You cannot make <code class=\"language-text\">POST</code> idempotent by wishing. What you can do is attach a client-generated token to each <em>logical</em> operation, so the server can recognise a repeat and refuse to do the work a second time. That token is the idempotency key. The client sends it in a header, by convention <code class=\"language-text\">Idempotency-Key</code> (now written up in an <a href=\"https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header\">IETF draft</a>), and reuses the same value on every retry of that one operation.</p>\n<p><a href=\"https://docs.stripe.com/api/idempotent_requests\">Stripe’s API</a> popularised this pattern. Their docs are worth reading even if you never touch payments, because they document the edge cases most homegrown versions miss.</p>\n<p>The key belongs to the operation, not the request. Two different button presses are two operations and get two keys. Five retries of one button press are one operation and share one key. Getting that boundary right is the client’s job; the server just trusts the key.</p>\n<h2>How the mechanism works</h2>\n<p>The server keeps a small record for each key it has seen. The first request with a given key does the work and saves its response against the key. Any later request carrying the same key gets that saved response replayed, and the side effect never runs again.</p>\n<p><img src=\"/2667ddaabbce686bce588e6d7ad12699/dedup-flow.svg\" alt=\"A sequence diagram with three lanes: client, API handler, and key store. The first POST carries Idempotency-Key k-91; the handler inserts k-91 into the store as a miss and claims it, runs the side effect exactly once, stores the response body and status, and returns 201 with id 4471. A network hiccup means the client never saw the 201, so it retries the identical request. The handler selects k-91, gets a hit, runs no side effect, and replays the stored 201 with id 4471.\"></p>\n<p>That is the happy path, and it really is simple. The interesting engineering is in the record itself, because a naive version has a race condition that shows up the first time two retries arrive close together.</p>\n<h2>Why a plain cache is not enough</h2>\n<p>The tempting implementation goes like this: look up the key; if it is there, return the stored response; otherwise, run the handler and store the result. That is a check-then-act sequence, and check-then-act across two requests is a race.</p>\n<p>Picture a client that fires a request, does not see a response fast enough, and fires the retry while the first is still running. Both requests look up the key, both miss, and both run the handler. You now have the duplicate you were trying to prevent, and you built a whole caching layer to still get it. On a busy service this is not a rare timing window, because aggressive client retry policies are designed to fire quickly.</p>\n<p>So the record needs three states, not two, and the move into the “claimed” state has to be atomic: one indivisible step that no other request can slip into.</p>\n<p><img src=\"/156d11a9b633b58716d5beb2d75d2091/record-states.svg\" alt=\"A state diagram for one idempotency record. It starts as no record, meaning the key was never seen. The first request atomically inserts the row and moves it to in_progress, where the row is claimed and the work is running. When the work finishes, the response body and status are saved and the state becomes completed. A retry that arrives while the record is in_progress gets a 409 Conflict telling it to try again shortly. A retry that arrives after completion gets the stored response replayed with no work. After a TTL the row expires and the key is free again. A note says a payload arriving with a used key but a different body should be rejected with 422, never silently served the old answer.\"></p>\n<p>The <code class=\"language-text\">in_progress</code> state is the part people skip and then debug in production. It means “someone is already doing this work.” It exists so a concurrent retry has something to collide with, instead of quietly starting a second copy.</p>\n<h2>Making the claim atomic in Postgres</h2>\n<p>The cleanest way to claim a key atomically is to let the database enforce it. Put a unique constraint on the key, and use an insert that fails loudly on collision. In Postgres, <a href=\"https://www.postgresql.org/docs/current/sql-insert.html\"><code class=\"language-text\">INSERT ... ON CONFLICT</code></a> does exactly this in a single statement, so there is no window between checking and claiming. The table below makes the key its primary key and stores the request hash, the state, and the saved response:</p>\n<div class=\"gatsby-highlight\" data-language=\"sql\"><pre class=\"language-sql\"><code class=\"language-sql\"><span class=\"token keyword\">CREATE</span> <span class=\"token keyword\">TABLE</span> idempotency_keys <span class=\"token punctuation\">(</span>\n    <span class=\"token keyword\">key</span>            <span class=\"token keyword\">TEXT</span> <span class=\"token keyword\">PRIMARY</span> <span class=\"token keyword\">KEY</span><span class=\"token punctuation\">,</span>\n    request_hash   <span class=\"token keyword\">TEXT</span> <span class=\"token operator\">NOT</span> <span class=\"token boolean\">NULL</span><span class=\"token punctuation\">,</span>\n    <span class=\"token keyword\">status</span>         <span class=\"token keyword\">TEXT</span> <span class=\"token operator\">NOT</span> <span class=\"token boolean\">NULL</span> <span class=\"token keyword\">DEFAULT</span> <span class=\"token string\">'in_progress'</span><span class=\"token punctuation\">,</span>\n    response_code  <span class=\"token keyword\">INT</span><span class=\"token punctuation\">,</span>\n    response_body  JSONB<span class=\"token punctuation\">,</span>\n    created_at     TIMESTAMPTZ <span class=\"token operator\">NOT</span> <span class=\"token boolean\">NULL</span> <span class=\"token keyword\">DEFAULT</span> <span class=\"token function\">now</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span>\n<span class=\"token punctuation\">)</span><span class=\"token punctuation\">;</span></code></pre></div>\n<p>The <code class=\"language-text\">request_hash</code> column matters more than it looks. A key identifies an operation, but a buggy or malicious client could reuse a key with a <em>different</em> body. If you blindly replayed the stored response, you would answer the wrong question. Storing a hash of the request lets you detect that mismatch and reject it instead.</p>\n<h2>Wiring it into FastAPI as a dependency</h2>\n<p>FastAPI’s <a href=\"https://fastapi.tiangolo.com/tutorial/dependencies/\">dependency system</a> is the right seam for this. The idempotency logic is cross-cutting, and copy-pasting it into every mutating handler is how the copies drift out of sync. A dependency lets you attach it declaratively to the routes that need it.</p>\n<p>Here is the claim step, which runs before the handler body. It tries to insert the key; if the key already exists, it reads the existing record and decides how to answer:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">import</span> hashlib<span class=\"token punctuation\">,</span> json\n<span class=\"token keyword\">from</span> fastapi <span class=\"token keyword\">import</span> Request<span class=\"token punctuation\">,</span> HTTPException<span class=\"token punctuation\">,</span> Header\n\n<span class=\"token keyword\">async</span> <span class=\"token keyword\">def</span> <span class=\"token function\">claim_key</span><span class=\"token punctuation\">(</span>\n    request<span class=\"token punctuation\">:</span> Request<span class=\"token punctuation\">,</span>\n    idempotency_key<span class=\"token punctuation\">:</span> <span class=\"token builtin\">str</span> <span class=\"token operator\">=</span> Header<span class=\"token punctuation\">(</span><span class=\"token punctuation\">.</span><span class=\"token punctuation\">.</span><span class=\"token punctuation\">.</span><span class=\"token punctuation\">,</span> alias<span class=\"token operator\">=</span><span class=\"token string\">\"Idempotency-Key\"</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\n<span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    body <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> request<span class=\"token punctuation\">.</span>body<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span>\n    request_hash <span class=\"token operator\">=</span> hashlib<span class=\"token punctuation\">.</span>sha256<span class=\"token punctuation\">(</span>body<span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span>hexdigest<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span>\n\n    <span class=\"token keyword\">async</span> <span class=\"token keyword\">with</span> request<span class=\"token punctuation\">.</span>app<span class=\"token punctuation\">.</span>state<span class=\"token punctuation\">.</span>db<span class=\"token punctuation\">.</span>acquire<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token keyword\">as</span> conn<span class=\"token punctuation\">:</span>\n        row <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> conn<span class=\"token punctuation\">.</span>fetchrow<span class=\"token punctuation\">(</span>\n            <span class=\"token triple-quoted-string string\">\"\"\"\n            INSERT INTO idempotency_keys (key, request_hash)\n            VALUES ($1, $2)\n            ON CONFLICT (key) DO NOTHING\n            RETURNING key\n            \"\"\"</span><span class=\"token punctuation\">,</span>\n            idempotency_key<span class=\"token punctuation\">,</span> request_hash<span class=\"token punctuation\">,</span>\n        <span class=\"token punctuation\">)</span>\n\n        <span class=\"token keyword\">if</span> row <span class=\"token keyword\">is</span> <span class=\"token keyword\">not</span> <span class=\"token boolean\">None</span><span class=\"token punctuation\">:</span>\n            <span class=\"token comment\"># We won the insert. This request owns the work.</span>\n            <span class=\"token keyword\">return</span> <span class=\"token punctuation\">{</span><span class=\"token string\">\"key\"</span><span class=\"token punctuation\">:</span> idempotency_key<span class=\"token punctuation\">,</span> <span class=\"token string\">\"fresh\"</span><span class=\"token punctuation\">:</span> <span class=\"token boolean\">True</span><span class=\"token punctuation\">}</span>\n\n        <span class=\"token comment\"># Key already exists, so load the existing record.</span>\n        existing <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> conn<span class=\"token punctuation\">.</span>fetchrow<span class=\"token punctuation\">(</span>\n            <span class=\"token string\">\"SELECT request_hash, status, response_code, response_body \"</span>\n            <span class=\"token string\">\"FROM idempotency_keys WHERE key = $1\"</span><span class=\"token punctuation\">,</span>\n            idempotency_key<span class=\"token punctuation\">,</span>\n        <span class=\"token punctuation\">)</span>\n\n    <span class=\"token keyword\">if</span> existing<span class=\"token punctuation\">[</span><span class=\"token string\">\"request_hash\"</span><span class=\"token punctuation\">]</span> <span class=\"token operator\">!=</span> request_hash<span class=\"token punctuation\">:</span>\n        <span class=\"token keyword\">raise</span> HTTPException<span class=\"token punctuation\">(</span><span class=\"token number\">422</span><span class=\"token punctuation\">,</span> <span class=\"token string\">\"Idempotency-Key reused with a different body\"</span><span class=\"token punctuation\">)</span>\n\n    <span class=\"token keyword\">if</span> existing<span class=\"token punctuation\">[</span><span class=\"token string\">\"status\"</span><span class=\"token punctuation\">]</span> <span class=\"token operator\">==</span> <span class=\"token string\">\"in_progress\"</span><span class=\"token punctuation\">:</span>\n        <span class=\"token comment\"># A twin request is still running. Tell the client to back off.</span>\n        <span class=\"token keyword\">raise</span> HTTPException<span class=\"token punctuation\">(</span><span class=\"token number\">409</span><span class=\"token punctuation\">,</span> <span class=\"token string\">\"Request with this key is still processing\"</span><span class=\"token punctuation\">)</span>\n\n    <span class=\"token comment\"># Completed: replay the stored response.</span>\n    <span class=\"token keyword\">raise</span> ReplayResponse<span class=\"token punctuation\">(</span>existing<span class=\"token punctuation\">[</span><span class=\"token string\">\"response_code\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span> existing<span class=\"token punctuation\">[</span><span class=\"token string\">\"response_body\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span></code></pre></div>\n<p><code class=\"language-text\">ON CONFLICT (key) DO NOTHING ... RETURNING key</code> is the whole trick:</p>\n<ul>\n<li>If the insert succeeds, <code class=\"language-text\">RETURNING</code> hands back the row, and this request is the owner.</li>\n<li>If the key already existed, <code class=\"language-text\">DO NOTHING</code> suppresses the row and <code class=\"language-text\">fetchrow</code> returns <code class=\"language-text\">None</code>, so the code falls through to the read path. There, a different body gets a 422, a record still <code class=\"language-text\">in_progress</code> gets a 409, and a completed record is replayed.</li>\n</ul>\n<p>Only one concurrent request can ever win the insert, because the primary key constraint serialises them at the database. There is no application-level lock and no Redis <code class=\"language-text\">SETNX</code> dance. The constraint you already have does the work.</p>\n<p>The handler then does its job and writes the response back against the key before returning:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token decorator annotation punctuation\">@app<span class=\"token punctuation\">.</span>post</span><span class=\"token punctuation\">(</span><span class=\"token string\">\"/requests\"</span><span class=\"token punctuation\">,</span> dependencies<span class=\"token operator\">=</span><span class=\"token punctuation\">[</span>Depends<span class=\"token punctuation\">(</span>claim_key<span class=\"token punctuation\">)</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span>\n<span class=\"token keyword\">async</span> <span class=\"token keyword\">def</span> <span class=\"token function\">create_request</span><span class=\"token punctuation\">(</span>payload<span class=\"token punctuation\">:</span> RequestIn<span class=\"token punctuation\">,</span> claim<span class=\"token operator\">=</span>Depends<span class=\"token punctuation\">(</span>claim_key<span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    result <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> schedule_workflow<span class=\"token punctuation\">(</span>payload<span class=\"token punctuation\">)</span>     <span class=\"token comment\"># the real side effect</span>\n\n    <span class=\"token keyword\">async</span> <span class=\"token keyword\">with</span> app<span class=\"token punctuation\">.</span>state<span class=\"token punctuation\">.</span>db<span class=\"token punctuation\">.</span>acquire<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token keyword\">as</span> conn<span class=\"token punctuation\">:</span>\n        <span class=\"token keyword\">await</span> conn<span class=\"token punctuation\">.</span>execute<span class=\"token punctuation\">(</span>\n            <span class=\"token string\">\"UPDATE idempotency_keys \"</span>\n            <span class=\"token string\">\"SET status='completed', response_code=201, response_body=$2 \"</span>\n            <span class=\"token string\">\"WHERE key=$1\"</span><span class=\"token punctuation\">,</span>\n            claim<span class=\"token punctuation\">[</span><span class=\"token string\">\"key\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">,</span> json<span class=\"token punctuation\">.</span>dumps<span class=\"token punctuation\">(</span>result<span class=\"token punctuation\">)</span><span class=\"token punctuation\">,</span>\n        <span class=\"token punctuation\">)</span>\n    <span class=\"token keyword\">return</span> result</code></pre></div>\n<p><code class=\"language-text\">ReplayResponse</code> is a small custom exception, with a handler registered via <code class=\"language-text\">@app.exception_handler</code> that turns the stored code and body back into a real response. Using the exception path keeps the replay out of the normal handler, so on a repeat the side-effect code is never even reached.</p>\n<h2>Where it bites</h2>\n<p>The skeleton above is maybe forty lines. The reasons it goes wrong in production are more interesting than the code, and most of them are about what happens when the <em>first</em> request does not finish cleanly.</p>\n<p><strong>A crash between the side effect and the response write leaves a stranded <code class=\"language-text\">in_progress</code> row.</strong> If <code class=\"language-text\">schedule_workflow</code> succeeds but the process dies before the <code class=\"language-text\">UPDATE</code>, the key is stuck in <code class=\"language-text\">in_progress</code> forever, and every retry gets a <code class=\"language-text\">409</code>. This is the hardest case, because it forces a question you cannot dodge: is your side effect itself idempotent? If scheduling the same workflow twice is safe, you can let a stuck key expire and be retried. If it is not, you need the side effect and the key update in the <em>same</em> database transaction, so they commit or roll back together. That only works when the side effect is a database write. An external API call cannot join your transaction, which is the real reason distributed idempotency is hard.</p>\n<p><strong>Keys need a TTL, and the TTL is a real tradeoff.</strong> You cannot keep every key forever. Expire them too fast, and a legitimate slow retry (a mobile client on a bad connection coming back after two minutes) misses the record and re-runs the work. Expire them too slowly, and the table grows without bound. Stripe keeps keys for 24 hours, which is a sane default for user-facing retries. Sweep expired rows on a schedule. This is the same lifecycle-of-a-record thinking behind <a href=\"/blog/2026-09-03-opensearch-ism-index-lifecycle/\">OpenSearch index expiry</a>, just at a different scale.</p>\n<p><strong>The <code class=\"language-text\">409</code> is a signal, not an error.</strong> A client that gets a <code class=\"language-text\">409</code> should wait and retry with the same key, because the work is genuinely still running. That is exactly the <a href=\"/blog/2026-07-11-llm-api-rate-limits-retries-backoff/\">retry-with-backoff</a> behaviour I have written about for rate limits, pointed at a different status code. If your clients treat <code class=\"language-text\">409</code> as fatal, the pattern does not help them.</p>\n<p><strong>A replayed response can be stale.</strong> The record captures the response as it was the first time. If the underlying resource changed between the original request and the retry, the replayed body is out of date. For a create-style endpoint that returns an ID, this is fine, because the ID does not change. For anything returning mutable state, decide deliberately whether the client wants “what happened when you asked” or “what is true now.”</p>\n<p><strong>Connection pool pressure is easy to overlook.</strong> The claim adds an extra database round trip to every write request, and it holds a connection while the handler runs. On a busy endpoint, that changes your pool math. Read up on <a href=\"/blog/2026-08-10-async-connection-pooling-fastapi/\">async connection pooling</a> before you turn this on for a high-traffic route, because a too-small pool turns your safety feature into a bottleneck.</p>\n<h2>What I would keep and what I would change</h2>\n<p>The Postgres-native approach earns its place when the side effect is also a Postgres write. Then the claim and the effect share one transaction, and the stranded-<code class=\"language-text\">in_progress</code> problem disappears. That is the version I would reach for first on the CMS console, where most operator actions land in the same database anyway.</p>\n<p>If the side effect is an external call (a payment, or a message to another service), I would stop pretending a single transaction solves it. Instead, I would reach for an <a href=\"/blog/2026-08-04-fastapi-background-tasks-vs-task-queue/\">outbox</a>-style pattern: record the intent transactionally, then have a separate worker perform the external call with its own idempotency guarantees and mark it done.</p>\n<p>Idempotency keys at the edge and idempotency at the effect are two different layers. Conflating them is how you end up with a system that is safe against retries in the demo and duplicates work in production. Pair this with a <a href=\"/blog/2026-08-29-circuit-breaker-llm-api-calls/\">circuit breaker</a> on the external dependency, and you have a request path that degrades predictably instead of amplifying every retry storm into duplicate work.</p>\n<p>Idempotency is unglamorous, and that is the point. It is the difference between an ops action you can retry without thinking and one where every timeout makes an operator wonder whether they just scheduled the same grid job twice. On a system where the side effects cost real compute, that peace of mind is the feature.</p>\n<hr>\n<p><em>Diagrams by M. Hassan Ahmed, released under CC0. No external image was used for this post; the figures are original work by the author.</em></p>","frontmatter":{"title":"Idempotency Keys: Safe API Retries in FastAPI","date":"2026-09-04T00:00:00.000Z","description":"A network retry can submit the same request twice. How idempotency keys in FastAPI make POST endpoints safe to retry without duplicate side effects.","thumbnail":{"childImageSharp":{"fluid":{"base64":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAALCAIAAADwazoUAAAACXBIWXMAAAsSAAALEgHS3X78AAACBElEQVQozyWQSW/bMBCFdUmT2BIXkZS4aCO1UJvlJXEqK3Z7CZqgKBrk0FP///8onQIPgzcDfngz9Nr82clmi1OdzMfx9fvx43z4vex/zdPPy8P75fH9ZflzaF+a9OTetNnzf8TJu4XyCxBrWiBeu8r1pp6WcvjqatE9VtNcTadmt8R69CPtx2ZFshvIb6C4hcoDxATEAFriuI6SHkW1H+qriAahjomluOasy+U2V7tcbZ2PiGXM+jj3AK0wb1HsZHU7s2SEkcWiA1EdkiqnY8IGmx531WWqzl32ZMQ+pYOivY8zL6AlURuWToi3zmDeEzVG2dbBKDQpHbUD5LHgDzY/p2yqslnxSYR2jVIHG+hCeOu7zV0gq1yLuPWpwaSUoWVAR9BMyfIx/R3EPIinQRxd+BolnjvYLUzUAFkZpRscN0S0VA1XODQOplATUAhq++bs4Ev59q181Wy7gtJzH6PKp7Q7i8i8maYT+pBUP3RDmYahVp8wCwzpO3zZctVleMjIIEl7hQOi3cGxPpi0v5T9Ju/H1O5VRZiBuJC4IUERoVKOkzjuRd67LUKQx7haA+kBZgJWhdw23cnY2faLLHYrWrshxkVGhxhWJt2P/aWrT2N/TqNBIqtItwLcuwdyjdQKqjsg74C4BeIeqjV0E7kCgqAi/BQKMgQyV50nSCOU3fnRPyWKUHOhjkAHAAAAAElFTkSuQmCC","aspectRatio":1.899441340782123,"src":"/static/2f02f9ab751572adeacc0c1abc490f53/40a76/hero.png","srcSet":"/static/2f02f9ab751572adeacc0c1abc490f53/c972b/hero.png 340w,\n/static/2f02f9ab751572adeacc0c1abc490f53/27625/hero.png 680w,\n/static/2f02f9ab751572adeacc0c1abc490f53/40a76/hero.png 1360w,\n/static/2f02f9ab751572adeacc0c1abc490f53/ed396/hero.png 2000w","sizes":"(max-width: 1360px) 100vw, 1360px"}}}}}},"pageContext":{"slug":"/2026-09-04-idempotency-keys-fastapi-safe-retries/","previous":"blog/2026-08-31-continuous-batching-llm-serving/","next":"blog/2026-09-03-opensearch-ism-index-lifecycle/"}},"staticQueryHashes":["32046230"]}