AI APIs & Structured Outputs· Lesson 7 of 7
- 1The First Call — Reaching the Model From Code7 min
- 2Streaming — The Reply Arrives Word by Word7 min
- 3Structured Outputs — JSON You Can Rely On8 min
- 4Errors & Retries — When the Call Fails9 min
- 5Rate Limits — Your Ceiling per Minute6 min
- 6API Keys & Secrets — Guarding Your Key6 min
- 7Timeouts & Idempotency — Requests That Don't Double-Fire8 min
Timeouts & Idempotency — Requests That Don't Double-Fire
In one line · You set a time limit on a request (a timeout) so you don't wait forever, and you use an idempotency key so a retried request isn't executed twice.
Models are sometimes slow, and without a timeout your product hangs; without idempotency a retry can do the same thing twice — send two emails or charge twice.
A timeout is like an iron's auto-shutoff: run too long and it cuts itself off. Idempotency is like an order number: resend the same number and the system knows it's the same order and won't repeat it.
An idempotency key is like a receipt number at a restaurant: if the waiter vanished and you're not sure your order was heard, reordering with the same receipt number tells the kitchen it's the same order — so they don't cook it twice.
A request hangs 30 seconds, so the timeout cancels it — but note, cancelling tells you nothing about whether the server finished it; so you retry with the same idempotency key, and if the first one had gone through, the server returns the same result instead of doing it twice.
You put a timeout on the call (with AbortController, say) so a hung request gets cut instead of hanging your product. But here's the key point: the timeout only cuts your side — it tells you nothing about whether the server did the work; it may have finished after you gave up. That's why side-effectful requests get an idempotency key: a unique ID for the logical operation, sent in a header. When you retry with the same key, the server recognizes it — if the operation already ran, it returns the same stored result instead of executing again.
import { randomUUID } from "crypto";
const idempotencyKey = randomUUID(); // one key per OPERATION, not per attempt
async function safeCall() {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30_000);
try {
return await fetch(url, {
method: "POST",
signal: controller.signal,
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
"Idempotency-Key": idempotencyKey, // same key on every retry
},
body,
});
} finally {
clearTimeout(timer);
}
}🧪 Try it yourself: build a mock endpoint that stores results by Idempotency-Key, and a client whose first attempt times out (make the server deliberately outlast the timeout) then retries with the same key. Verify the operation ran exactly once — then try a fresh key per retry and watch the disaster yourself.
(1) Assuming a timeout means 'it didn't execute' and retrying a side-effectful operation without a key — so two emails go out or a charge lands twice. (2) Generating a fresh idempotency key on every retry — that defeats the whole point; the key belongs to the operation, not the attempt. (3) Setting a too-short timeout on model generation — long non-streaming replies routinely take 30+ seconds, and you end up cancelling healthy requests.
A timeout on every network call, no exceptions; an idempotency key on any request whose retry could double a side effect: charging money, sending an email, writing to a database.
Pure reads (GETs and the like) don't need idempotency keys — repeating them is harmless, so don't add machinery for nothing. The timeout still applies there — the key doesn't.