Sign in

APIs

intermediate~40 minapisstreamingsserate-limitingretriesidempotency

Designing APIs for AI products — streaming responses, SSE, idempotency, rate limits, and retries against flaky model providers.

APIs

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

Client sends an idempotency keyOne key per user intent, not per attempt.
Server checks the keySeen before? Return the stored result.
This is the thing that stops a retry double-charging someone.
Call the modelWith a timeout and bounded exponential backoff.
Stream backForward chunks as they arrive.
Persist once, on completionBuffer the stream. You cannot write a row per token.
A completion request, made safe to retry.

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 503 from the provider up to three times before falling back to an error message.
  • A "regenerate this image" button that sends an Idempotency-Key so 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.

  1. Build POST /api/complete requiring an Idempotency-Key header, returning 400 if it's missing.
  2. Check for an existing result under that key before calling the model; return the saved result if found.
  3. Wrap the model call in withRetry, retrying only 429/500/503.
  4. Persist the result keyed by the idempotency key once the call succeeds.
  5. Manually test: send the same request twice with the same key and confirm the second response is marked replayed: true and 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 EventSource API for consuming it directly.
  • Stripe — Idempotent requests: the reference implementation this lesson's Idempotency-Key pattern 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.