Overview
Calling a model API is one fetch. Calling it in a way that survives a bad network, a rate limit, and an impatient user who hits the button twice is the actual job.
This lesson is the request path: why these APIs stream, how an idempotency key stops a retry becoming a duplicate charge, which failures are worth retrying and which are not, and why a timeout you set yourself beats a generic 504 from the platform.
Theory
Streaming exists because generation is slow and incremental. A model produces tokens one at a time, so waiting for the whole reply before showing anything wastes seconds you could have spent showing progress. Streaming does not make it faster — it makes the wait visible, which is most of what "fast" means to a user.
Idempotency stops a retry becoming a duplicate. The client sends a key with the request; the server records it alongside the result. A retry with the same key returns the stored result instead of calling the model again. One key per user intent, not per attempt — regenerating it on retry defeats the whole thing.
Rate limits are two-sided. The provider limits you, and you should limit your users. Without the second half, one script hammering your endpoint spends your entire quota and everyone else gets a 429 they did nothing to earn.
Retry only what is worth retrying. A 429 or a 503 is transient — back off exponentially, add jitter so your clients do not all return in lockstep, and cap the attempts. A 400 or a 401 is permanent; retrying it burns time and buries the real error.
Timeouts protect everything downstream. A model call with no timeout can hold a connection until the platform kills it, and you get a generic 504 with nothing to debug. Set your own budget, shorter than the platform's, so the failure is yours and it says something useful.
Visual Diagram
Read it as a pipeline: the client sends a request carrying an Idempotency-Key. Your API first checks whether
that key has already been processed — if so, it replays the stored result and skips the model entirely. If not,
it calls the model provider wrapped in a retry-with-backoff loop, which only retries the failure types worth
retrying. Once a response starts coming back, it's streamed to the client as SSE chunks rather than buffered and
sent as one block, and the final result is persisted against the idempotency key before the response completes —
so any retry of the exact same request short-circuits to the saved answer instead of calling the model again.
Examples
- A chat completion endpoint that streams tokens via SSE and retries a
503from the provider up to three times before falling back to an error message. - A "regenerate this image" button that sends an
Idempotency-Keyso a flaky connection retry doesn't produce (and bill for) two images from one click. - A batch summarization job processing a thousand documents overnight, where each document's request is rate-limited to stay under the provider's requests-per-minute ceiling instead of firing all thousand at once.
- A webhook receiver for a provider's async batch API, which itself needs to be idempotent, since webhook delivery systems commonly retry on their own.
Code
interface RetryOptions {
maxAttempts?: number;
baseDelayMs?: number;
}
/**
* Retries a model call with exponential backoff and jitter. A model
* provider is just another third-party API — it rate-limits, it has bad
* minutes, and a blind retry-forever loop makes an outage worse for
* everyone sharing that provider capacity. This retries only transient
* failures and gives up immediately on the rest.
*/
async function withRetry<T>(
fn: () => Promise<T>,
{ maxAttempts = 3, baseDelayMs = 500 }: RetryOptions = {},
): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
const status = (err as { status?: number }).status;
// Only retry transient failure classes — a bad request or bad API
// key will fail identically every time, so retrying just delays the
// inevitable error.
const retryable = status === 429 || status === 500 || status === 503;
if (!retryable || attempt === maxAttempts) break;
const jitter = Math.random() * 250;
const delay = baseDelayMs * 2 ** (attempt - 1) + jitter;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw lastError;
}import { generateText } from "ai";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { createClient } from "@supabase/supabase-js";
const provider = createOpenAICompatible({
name: "lovable-ai-gateway",
baseURL: "https://ai.gateway.lovable.dev/v1",
headers: { "Lovable-API-Key": process.env.LOVABLE_API_KEY ?? "" },
});
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
/**
* POST /api/complete. The client sends an Idempotency-Key header — a UUID
* it generates once per user action — so that a retried request (e.g.
* after a flaky mobile connection times out and the client resends) never
* triggers a second, separately billed model call.
*/
export async function POST(req: Request) {
const idempotencyKey = req.headers.get("Idempotency-Key");
if (!idempotencyKey) {
return new Response("Idempotency-Key header is required", { status: 400 });
}
const { data: existing } = await supabase
.from("completions")
.select("result")
.eq("idempotency_key", idempotencyKey)
.maybeSingle();
if (existing) {
// Same key seen before — replay the saved result instead of calling
// the model again.
return Response.json({ result: existing.result, replayed: true });
}
const { prompt } = await req.json();
const { text } = await withRetry(() =>
generateText({ model: provider("gpt-4o-mini"), prompt }),
);
await supabase.from("completions").insert({ idempotency_key: idempotencyKey, result: text });
return Response.json({ result: text, replayed: false });
}Real Product Example
Stripe's API is the industry-reference implementation of idempotency, and the exact mechanism this lesson's
Idempotency-Key header borrows from: every POST request can carry an Idempotency-Key, Stripe stores the
resulting status code and body against that key, and any retry with the same key — whether from a network
timeout, a client bug, or a user double-clicking "Pay" — returns the original result instead of charging a card
twice. Stripe prunes keys after 24 hours, so retries are safe within the window that actually matters (a client
recovering from a dropped connection) without the server needing to remember every request forever. AI products
adopt the identical pattern for the identical reason: a model call, like a charge, is expensive and has a
side effect (tokens billed, a row written) that must not happen twice because a client retried.
Common Mistakes
Retrying every error, including ones that will never succeed
Retrying a 401 (invalid API key) or a 400 (malformed request body) three times with backoff just delays an error the user needed to see immediately, and can look like your app is hanging. Only retry failure classes that are actually transient — rate limits and temporary unavailability, not client-side mistakes.
Retrying without jitter
Fixed-delay backoff (retry after exactly 1s, then exactly 2s, then exactly 4s) means every client that got rate-limited by the same burst of traffic retries in near-perfect sync, recreating the same spike that caused the rate limit in the first place. Add randomness to each delay so retries spread out instead of re-synchronizing.
Treating idempotency keys as optional or generating them server-side
An idempotency key only works if the client generates it once and resends the same value on every retry of the same logical action. If the server generates a new key per request, or the client generates a fresh key on retry, the mechanism protects nothing — every retry looks like a brand-new request.
Buffering the entire streamed response server-side before forwarding it
Reading a provider's full SSE stream into memory and only then sending one complete Response to the client
defeats the purpose of streaming — the client waits the full generation time anyway, just with extra server-side
latency added on top. Forward chunks as they arrive.
Senior Tips
Senior tip
Expire idempotency keys, don't keep them forever. Stripe prunes keys after 24 hours because that covers every realistic retry scenario (a client recovering from a dropped connection within minutes to hours) without an unbounded table of every request ever made. Pick a retention window that matches your actual retry behavior, not "forever by default."
Senior tip
Cap total retry time, not just attempt count. Three retries with exponential backoff can still add up to 10+ seconds of added latency before finally succeeding or failing. If your product has a hard UX budget (e.g. a user won't wait more than 8 seconds for a first token), enforce a total-time ceiling across all attempts, not just a max attempt count.
Senior tip
Distinguish "the provider is down" from "the provider rejected this specific request" in your logs. A spike of 503s across every request type points at a provider incident and should page someone; a spike of 400s on one specific endpoint points at a bug you shipped. Logging status codes without this distinction makes on-call triage slower than it needs to be.
Senior tip
Design your own API's error shape before you need it, not while debugging an incident. A consistent
{ error: { type, message, retryable } } response body lets every client — web, mobile, internal tooling — make
the same retry/give-up decision without special-casing each endpoint's error format.
Exercises
Exercise
Take the withRetry function from the Code section and add a total-time ceiling (see the Senior Tip above): stop
retrying once cumulative elapsed time exceeds a configurable limit, even if maxAttempts hasn't been reached yet.
Exercise
List three HTTP status codes a model provider might return, and for each, state whether your API should retry it, and why. Then do the same for one error that isn't a status code at all — a request that times out with no response received.
Exercise
Using the streaming Route Handler pattern from Day 7, add SSE-formatted output (event:/data: lines) instead of
raw text chunks, and describe in one paragraph what a client would need to parse that raw-text streaming does not
require.
Mini Project
Resilient completion endpoint. Combine every piece from this lesson into one endpoint.
- Build
POST /api/completerequiring anIdempotency-Keyheader, returning400if it's missing. - Check for an existing result under that key before calling the model; return the saved result if found.
- Wrap the model call in
withRetry, retrying only429/500/503. - Persist the result keyed by the idempotency key once the call succeeds.
- Manually test: send the same request twice with the same key and confirm the second response is marked
replayed: trueand no second model call was made (check your provider's usage dashboard or a log line).
Resources
- MDN — Server-sent events: the protocol
underlying most model-provider streaming and the
EventSourceAPI for consuming it directly. - Stripe — Idempotent requests: the reference implementation
this lesson's
Idempotency-Keypattern is modeled on, including key retention and generation guidance. - Anthropic — Streaming Messages: a concrete,
production SSE event stream (
message_start,content_block_delta,message_stop) from a real model provider.