Skip to content
14/30Chapter 14 of 30

Your First Production LLM Call: Streaming, Retries, Timeouts

Build a provider that lies to you — 429s, hung sockets, streams cut in half — and measure what your client does. Full jitter: 2.2 seconds against 226.

On this page

Chapter 13 ended with a stopwatch on a model you could touch. The weights were in your memory, the KV cache was yours to enable or disable, and the number that came out — time to first token — was a property of your hardware.

Now put that model behind a port, which is what every product does, and read the same number again. It is still time to first token, but it is no longer a property of anything you control. It now includes a TLS handshake, a queue at the provider, a rate limiter, and the possibility that no token ever arrives at all.

That last clause is the chapter. The code you are about to write does not compute anything. It opens a connection, waits, parses what arrives, decides what to do when nothing arrives, decides again when what arrives is an error, and cancels itself when the user changes their mind. Each of those is a decision about state over time, and each has a wrong answer that ships and costs money.

Here is the shape of the problem, measured, all of it in this chapter:

what happenedwhat a careless client doeswhat it costs
the server accepted the socket and never repliedwaits300.8 s before Node gives up on its own
the key was wrong (401)retries five times6,325 ms of delay, then the same 401
a hundred clients hit the rate limit togetherall retry on the same schedule226 s to drain, versus 2.2 s
the request timed out and was resentresends itthe provider generates — and bills — the answer twice
the connection dropped mid-answershows the partial textindistinguishable from a correct short answer

None of these is a modelling problem. All of them are in the first hundred lines of every LLM product ever written.

Read that table again and ask what kind of program it describes. It holds a connection open for forty seconds. It must be cancellable from a button. It accumulates a partial answer that is valid to display and invalid to save. And it runs in a server process or at an edge worker, next to the thing that renders the answer, holding a socket.

That is not a notebook. It is not that Python cannot do it — it can, and people do — it is that everything the previous thirteen chapters built was of a different kind. Chapters 1 to 13 held weights, gradients, logits and tokenizer bytes. From here the code holds a connection, a retry, a cancellation, accumulated state and, later, a permission prompt. The course changes language at exactly the seam where the object changes.

So the rule, written once:

If the code has weights, gradients, logits or tokenizer bytes in its hands, it is Python. If it holds a connection, retries, cancels, accumulates state and asks for permission, it is TypeScript.

The seam is single and it falls here, between Chapter 13 and Chapter 14. Three independent criteria put it here.

One: the ecosystem, counted. Everything the left half of this course cites is Python, and across the twelve courses audited for this syllabus there is not one precedent of backpropagation taught in another language: micrograd (17.4K stars), nanoGPT (62.8K), nanochat (57.8K), minbpe (10.7K), PyTorch (102.8K), transformers (164.9K). Writing Chapter 5 in TypeScript would break the link with those sources, and the links are half the value of a chapter that exists to be referenced rather than to rank. On this side the arithmetic reverses: Vercel's ai package is at 89.4M downloads a month and ships the thing itself — a tool-calling agent loop, exported as ToolLoopAgent — so the concept this course reaches in Chapter 23 has its reference implementation in TypeScript, even though, as that chapter measures, nobody has agreed on a name for it; Mastra is at 27.7K stars; and Anthropic's SDKs, generated from one specification, declare 202 endpoints in TypeScript against 201 in Python — parity, not a courtesy port.

Two: the normative source of MCP. The Model Context Protocol specification's schema is a schema.ts file. Teaching the protocol of Chapter 26 in another language means teaching a translation of its founding document.

Three: search demand, with a correction to the obvious guess. machine learning python is the most saturated phrase on the internet; ai agent typescript has its own healthy tail. But "the MCP ecosystem is mostly TypeScript" is only true depending on how you count: the official registry lists 8,275 servers on npm against 3,603 on PyPI, while by downloads Python wins — 287M a month for mcp plus 72M for fastmcp against 195M for @modelcontextprotocol/sdk. MCP is the one genuinely bilingual territory here, which is why Chapter 27 writes the same server twice instead of pretending.

Show details

The five declared exceptions, so the rule is a rule and not a slogan.

Chapters 17, 20 and 29 carry a second panel in Python: implementing top-p sampling needs the probability vector in your hand and an HTTP API never gives you one; pricing a fine-tune honestly means running one, and a LoRA adapter is a dozen lines of nn.Module; and lm-eval-harness, HELM, SWE-bench and τ-bench are Python, so an evaluation harness in TypeScript would be the mirror image of the backpropagation mistake. Chapter 27 is bilingual, for the measured reason above. Chapter 28 is Markdown, because an agent skill is a SKILL.md file and giving it a programming language would mean not having understood the format.

The thirteen Python chapters are not discarded. What is on the other side of the port is what they built, and the last section here connects a client to it.

You cannot learn any of this against a real provider. You cannot ask one for a 429 at a chosen moment, or for a socket that accepts your connection and never answers, or for a stream that stops in the middle of a word — and you would be paying for every experiment, when the interesting experiments are the ones you run a hundred times.

So the first program in this half of the course is not a client. It is a hostile server: forty lines of plain Node that speak the same wire protocol as a chat completions endpoint and misbehave on demand. Every number in this chapter came out of it.

mock-provider.mjsJS
import { createServer } from "node:http";

const WORDS = "A tide gauge is a device that measures sea level over time .".split(" ");
const CAPACITY = 3;                      // how many requests it will serve at once
let inflight = 0;

const sse = (res, obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`);

createServer(async (req, res) => {
  const url = new URL(req.url, "http://x");

  if (url.pathname === "/hang") return;                        
  if (url.pathname === "/401") { res.writeHead(401); return res.end("{}"); }

  if (inflight >= CAPACITY) {                                  
    res.writeHead(429, { "retry-after": "1" });                
    return res.end(JSON.stringify({ error: { type: "rate_limit_error" } }));
  }
  inflight++;

  const cut = Number(url.searchParams.get("cut") ?? -1);       // abandon after N chunks
  const how = url.searchParams.get("how");                     // "close" = orderly, else reset
  const max = Number(url.searchParams.get("max_tokens") ?? 999);
  const delay = Number(url.searchParams.get("delay") ?? 60);   // ms per token

  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
  for (let i = 0; i < Math.min(WORDS.length, max); i++) {
    if (i === cut) {                                           
      how === "close" ? res.end() : res.destroy();             
      inflight--; return;                                      
    }
    await new Promise((r) => setTimeout(r, delay));
    sse(res, { choices: [{ delta: { content: (i ? " " : "") + WORDS[i] }, finish_reason: null }] });
  }
  sse(res, { choices: [{ delta: {}, finish_reason: max < WORDS.length ? "length" : "stop" }] });
  res.write("data: [DONE]\n\n");
  inflight--;
  res.end();
}).listen(8787);

Four hostile behaviours, a line each: /hang accepts the socket and never writes to it; /401 refuses the key; the capacity check produces a genuine 429 with a genuine Retry-After header once three requests are already in flight; and ?cut=N abandons the answer halfway, either by resetting the socket or — with &how=close — by closing it in an orderly way, which turns out to matter a great deal. The rest is a real Server-Sent Events stream: one JSON object per data: line, a blank line between events, the string [DONE] at the end.1

Run it, and the rest of the chapter is measurement.

terminalBASH
node mock-provider.mjs &
curl -N "http://127.0.0.1:8787/v1/chat?max_tokens=3"
TEXT
data: {"choices":[{"delta":{"content":"A"},"finish_reason":null}]}

data: {"choices":[{"delta":{"content":" tide"},"finish_reason":null}]}

data: {"choices":[{"delta":{"content":" gauge"},"finish_reason":null}]}

data: {"choices":[{"delta":{},"finish_reason":"length"}]}

data: [DONE]

The request body, and the key that never leaves the server

Link to the section: The request body, and the key that never leaves the server

A chat request is a list of messages, each with a role. That list is the model's entire state: there is no memory between calls, and whatever you want the model to know has to be inside the array you send this time. Chapter 15 is about what to put in it and Chapter 16 is about what it costs, so here it is just the shape.

call.tsTS
const body = {
  model: "gpt-4.1-mini",
  messages: [
    { role: "system", content: "You explain instruments in one sentence." },
    { role: "user", content: "What is a tide gauge?" },
  ],
  stream: true,
  max_tokens: 200,
};

Those roles are not decoration. They are rendered into the chat template of Chapter 11 before the model sees a single token, which is why sending the wrong role silently degrades the answer instead of raising an error.

One rule with no exceptions: the API key never travels to the client. Not in an environment variable prefixed for the browser, not in a build-time constant, not "temporarily". A key in a bundle is a key on someone else's bill within days. The browser talks to your server, your server holds the key and talks to the provider — and because your server is in the middle, it is also the only place that can meter what each user spends, which is where the accounting of Chapter 16 has to live.

Now the experiment the chapter is built on. One question, one mock provider producing thirteen tokens at 60 ms each, three ways of asking.

First, without streaming. The client sends the request and waits for the whole JSON body.

TEXT
blocking   first visible =  791 ms   complete =  791 ms   finish_reason = stop

The two numbers are the same, and that is the entire problem. For 791 ms the user has a spinner, and not one word was available earlier — the server had the answer, byte by byte, and chose to say nothing.

Second, with streaming. Same server, same answer, same total work. The difference is a parser.

sse.tsTS
export async function* readSSE(res: Response) {
  const reader = res.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = "";
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });   
    let sep: number;
    while ((sep = buffer.indexOf("\n\n")) !== -1) {       
      const event = buffer.slice(0, sep);
      buffer = buffer.slice(sep + 2);
      for (const line of event.split("\n")) {
        if (!line.startsWith("data:")) continue;
        const payload = line.slice(5).trim();
        if (payload === "[DONE]") return;
        yield JSON.parse(payload);
      }
    }
  }
}

Three details there are load-bearing and most first attempts skip all three. The buffer exists because a network chunk has no relationship to an event: one read() can return half an event, or two and a half. The { stream: true } flag exists because a multi-byte UTF-8 character can be split across two chunks, and without it an accented letter becomes a replacement character at random. And events are separated by a blank line, not a newline, which is why the loop looks for \n\n.

TEXT
streaming  first visible =   65 ms   complete =  793 ms   finish_reason = stop

Twelve times faster to the first word, and two milliseconds slower to the last. Streaming makes nothing faster. It changes what the user is doing during the same 790 ms: reading instead of waiting. That is the whole benefit, it is enormous, and it is the reason every chat product streams.

Third, with twenty clients at once. The mock provider serves three requests at a time. Fire twenty:

TEXT
jitter=true  clients=20  server capacity=3
HTTP requests made: 74   429s received: 54   200s: 20
wall clock: 7,100 ms
retries per client: 0 0 0 1 1 1 2 2 2 3 4 3 3 5 4 5 4 5 4 5
every answer identical: true

Twenty answers, seventy-four requests, fifty-four rejections. Nobody lost anything, every client got the same text, and the only visible cost was time. That is a retry policy working. The rest of this chapter is about the three ways it can fail instead.

finish_reason, and two endings that look the same

Link to the section: finish_reason, and two endings that look the same

Before the failures, the field almost everybody ignores on the first pass. Every stream ends with an event carrying finish_reason. stop means the model decided it was done. length means it hit the token ceiling, so the answer is truncated mid-sentence and it is not the model's fault. Later chapters add tool_calls (Chapter 18) and content filters.

Now watch two endings a naive client cannot tell apart. Same server, same delay, one truncated by max_tokens and one where the connection is closed cleanly after five tokens:

TEXT
max_tokens=5           loop ended NORMALLY   chunks=5  finish_reason=length  text="A tide gauge is a"
socket closed cleanly  loop ended NORMALLY   chunks=5  finish_reason=null    text="A tide gauge is a"
socket destroyed       threw TypeError: terminated (UND_ERR_SOCKET)
                                             chunks=4  finish_reason=null    text="A tide gauge is"

Read the first two rows carefully. Identical text. Identical chunk count. No exception in either case. The for await loop finished normally both times, because from the reader's point of view the body ended and that is all a body can do. The only difference in the entire observation is that one carries finish_reason: "length" and the other carries nothing at all.

So the rule is not "catch errors while streaming". It is:

A stream that ends without a finish_reason did not end. It stopped.

Treat a missing finish_reason as a failure, always, and never persist that text as a completed answer. The third row shows the easier case — a destroyed socket does throw, and it also loses the chunk that was in flight, which is why the text is one word shorter than the two above.

Five status codes that are five different problems

Link to the section: Five status codes that are five different problems

The most expensive habit a new product has is one catch block for everything the provider returns. These codes are not variations of "it failed". They are five instructions, and four of them contradict each other.

statuswhat it meanswhat to dowait?
400your request is malformed — bad JSON, unknown field, context too longfix the codenever
401the key is wrong, missing or revokedfix the deploymentnever
429rate limit: too many requests, or too many tokens, per minuteretryRetry-After, then backoff
500the provider brokeretrybackoff
503the provider is overloaded — it is up, it is fullretrybackoff, and shed load

The line that matters runs between 4xx and the rest. A 400 or a 401 returns exactly the same answer if you send it a thousand times, because nothing on either end changes between attempts. Retrying it is not caution, it is a delay with extra steps. Measured: one client that makes six attempts — five retries with exponential backoff —, and one that reads the code first.

TEXT
retry everything  ->  6 requests, gave up after 6,325 ms, still HTTP 401
triage first      ->  1 request,  gave up after     4 ms, still HTTP 401

Six seconds of spinner to reach an answer that was available in four milliseconds. And that is the mild version: retries in a product are usually nested — a retrying HTTP client inside a retrying job runner inside a queue with its own redelivery — so six seconds becomes six minutes of a permanently broken deployment looking like a slow one.

The triage is nine lines and belongs in one place:

classify.tsTS
export type Verdict = "retry" | "retry-after" | "fatal";

export function classify(status: number): Verdict {
  if (status === 429) return "retry-after";        
  if (status === 408 || status >= 500) return "retry";
  return "fatal";  // 400, 401, 403, 404, 422 — nothing changes by waiting
}

Two more for your list: 402, which some providers use for "you are out of credit" and which needs a screen with a link to buy more rather than a retry, and 529 or its vendor-specific equivalents, which behave like 503.

Retrying is easy. Retrying when is the part with a measurable right answer.

Exponential backoff is the standard: wait a base delay, double it after each failure, stop at a ceiling. It exists because an overloaded server gets worse if the clients that just failed come straight back.

The problem is that everyone doubles from the same starting point. If a hundred clients hit a limit at the same moment — and they will, because that is what a traffic spike is — then all hundred wait 200 ms, all hundred retry together, all hundred fail together, and all hundred wait 400 ms. The retry schedule has synchronised them. That is a thundering herd, and randomness is the fix.2

sleep=random(0, min(cap, base2n))\text{sleep} = \mathrm{random}\big(0,\ \min(\text{cap},\ \text{base} \cdot 2^{\,n})\big)

That single change — picking uniformly from the interval instead of taking its upper end — is called full jitter. It is one call to Math.random(), and it is worth measuring rather than believing:

backoff.tsTS
export const backoffNaive = (n: number, base = 200, cap = 20_000) =>
  Math.min(cap, base * 2 ** n);

export const backoffFull = (n: number, base = 200, cap = 20_000) =>
  Math.random() * Math.min(cap, base * 2 ** n);      

A hundred clients, one server that serves three at a time, everything else identical, three runs each:

HTTP requestsrejectionsworst clientbusiest 50 ms windowwall clock
no jitter, run 149139110 tries46 arrivals65.6 s
no jitter, run 278068019 tries72 arrivals245.7 s
no jitter, run 377067018 tries97 arrivals225.6 s
full jitter, run 13242245 tries32 arrivals2.2 s
full jitter, run 23132136 tries31 arrivals2.3 s
full jitter, run 33182186 tries25 arrivals1.8 s

Two things in that table, and the second is the important one.

The first is the median: 226 seconds against 2.2, a factor of about a hundred, with less than half the requests. The busiest retry window says why. Without jitter, up to 97 of the hundred clients arrived inside the same 50-millisecond slot; the server had three, so 94 were rejected and went to sleep together, still synchronised, to do it again with a longer wait. With jitter the same hundred spread across the same windows in groups of around thirty and drained almost immediately.

The second is the variance. Without jitter: 65.6 s, 245.7 s, 225.6 s. With it: 2.2, 2.3, 1.8. A system without jitter does not merely perform badly, it performs unpredictably, because the outcome is decided by microscopic scheduling accidents that pick which three of a hundred synchronised clients arrive first. That is the signature of this bug in production: an endpoint that is fine, fine, fine, and then takes four minutes, and no change of yours explains it.

And the cheapest retry is the one that never happens. Put a concurrency gate in front of the provider — a counter that never lets more than N requests be in flight — and the same twenty clients that needed 74 requests and 7.1 seconds behave like this:

TEXT
client-side gate of 3: 20 HTTP requests, 0 429s, wall 883 ms

Twenty requests for twenty answers, zero rejections, eight times faster. A retry is the apology; the gate is not needing one.

When a provider returns 429 it usually tells you how long to wait, in the Retry-After header.3 That number is not advice: the provider is the only party in the exchange that knows when its window resets.

So the wait is the larger of the two: never less than Retry-After, and never less than your own backoff either, because the header tells you when the limiter forgives you and not when the server has room.

wait.tsTS
const header = res.headers.get("retry-after");
const floor = header ? Number(header) * 1000 : 0;   // seconds -> ms
const wait = Math.max(floor, backoffFull(attempt));  

The trace of the unluckiest client in the twenty-client run shows the header doing its job. Its first four backoff draws were all below one second, and all four were overridden:

TEXT
t+   26ms  attempt 0  HTTP 429  -> sleep 1000 ms
t+ 1032ms  attempt 1  HTTP 429  -> sleep 1000 ms
t+ 2034ms  attempt 2  HTTP 429  -> sleep 1000 ms
t+ 3046ms  attempt 3  HTTP 429  -> sleep 1000 ms
t+ 4047ms  attempt 4  HTTP 429  -> sleep 2782 ms
t+ 6852ms  attempt 5  HTTP 200  -> sleep 0 ms

Two practical notes. Retry-After may be an HTTP date rather than a number of seconds, so parse both. And providers rate-limit on two axes at once — requests per minute and tokens per minute — which is why long prompts get rejected far below the documented request limit. The header looks the same in both cases; the fix is not.

Ask the mock provider for /hang. It accepts the connection, and then does nothing at all: no headers, no body, no close. This is not exotic — it is what a load balancer does when the process behind it has died without closing its sockets.

Two clients, one difference:

TEXT
AbortSignal.timeout(5s)   gave up after   5.0 s  (TimeoutError: The operation was aborted due to timeout)
no timeout                gave up after 300.8 s  (TypeError: fetch failed)
                          cause: HeadersTimeoutError UND_ERR_HEADERS_TIMEOUT

Three hundred seconds. Five minutes of a socket held open, a request slot occupied and a user staring at a spinner, ending in a generic TypeError that says nothing about what happened. That number is not a bug: it is Node's default headers timeout, reasonable for a generic HTTP client and catastrophic for a user-facing request. Every runtime has such a default, most people never look it up, and the only way to find yours is to hang a socket on purpose the way we just did.

So: every outgoing request gets an explicit deadline, chosen by you.

deadline.tsTS
const res = await fetch(url, {
  method: "POST",
  headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
  body: JSON.stringify(payload),
  signal: AbortSignal.timeout(20_000),   
});

For a streaming call one deadline is not enough, because there are two different failures. The first is the stream never opens: no event arrives at all, and ten to thirty seconds is right. The second is the stream opens and then stalls: tokens flowed and then stopped, forever, with the socket still healthy. A total-duration timeout cannot tell a stalled stream from a long correct answer, so what you want is an idle timeout — a timer reset by every event, firing only when nothing has arrived for, say, fifteen seconds.

Cancellation is the same machinery pointed at a person. AbortSignal.timeout and a user pressing Stop both arrive as an AbortError, so combine them and record which one fired:

cancel.tsTS
const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(20_000)]);
// stopButton.onclick = () => user.abort();

Aborting matters for a reason beyond tidiness: the tokens are being generated and billed while you are not listening. Chapter 16 puts a price on that.

Now the failure that costs money rather than time. A request times out on the client, and the obvious move is to send it again — but a timeout tells you nothing about whether the server received it. Very often it did, and is still working.

Measured. The mock provider needs 780 ms for the answer. The client gives up at 300 ms and retries. The server counts how many answers it actually generated, which is what it would bill:

TEXT
idempotency-key: no    attempt 0: TimeoutError after 300 ms  |  attempt 1: TimeoutError after 300 ms
                       answers generated (and billed): 2

idempotency-key: yes   attempt 0: TimeoutError after 300 ms  |  attempt 1: HTTP 200 (replay) id=cmpl_1
                       answers generated (and billed): 1

Without a key: two full generations, paid for twice, and the client received neither of them. With a key: the server recognised the second request as the same request and replied instantly with the answer it had already produced, so the retry both avoided the double charge and was the attempt that finally succeeded.

An idempotency key is a unique string you generate per logical operation — not per attempt — and send unchanged on every retry of it. The server stores the outcome against the key and replays it. It is the mechanism payment APIs use, for the same reason.4

idempotent.tsTS
async function send(url: string, payload: unknown) {
  const key = crypto.randomUUID();      // once per turn, not per attempt

  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await fetch(url, {
      method: "POST",
      body: JSON.stringify(payload),
      headers: { "content-type": "application/json", "idempotency-key": key },  
      signal: AbortSignal.timeout(20_000),
    });
    if (res.ok) return res;
    if (classify(res.status) === "fatal") throw new Error(`HTTP ${res.status}`);
    await sleep(backoffFull(attempt));
  }
  throw new Error("out of attempts");
}

Two honest limits. Not every provider supports idempotency keys on completions, and where the endpoint is not idempotent, the correct number of retries for a POST that may already have run is zero. And a stream that failed halfway is not replayable in the general case: you either restart it and pay again, or keep the partial text and mark it incomplete. Which of those your product does is a product decision, not a networking one, and it is worth making on purpose.

The client written in this chapter has no idea what is behind the port. Point its base URL at a commercial provider and it streams tokens from a model of a trillion parameters. Point it at a server built on Chapter 13's arithmetic — serving the model you pretrained in Chapter 10, with its KV cache and its quantized weights — and the same code, unchanged, streams tokens from a model you built.

switch.tsTS
const BASE = process.env.LLM_BASE_URL ?? "http://127.0.0.1:8000/v1";  

That single line is the seam of this course. On one side of it is what the first thirteen chapters built; on the other, what the next sixteen build. The boundary is clean because the contract is HTTP and SSE, and neither side knows anything else about the other.

It is worth noticing what you lost by crossing. Behind a commercial endpoint you control neither the weights, nor the sampling implementation, nor the version you are talking to, nor whether it changed this morning. What you control is the contract: the messages you send, the deadline you set, the codes you distinguish, and what you do when nothing comes back. That is a smaller surface than you had in Chapter 5, and every remaining chapter is about using it well.

You now have a client that streams, gives up on time, retries the right things and never retries the wrong ones. What it sends is still whatever you typed.

Chapter 15 is about that content, and it comes with a discipline. The internet is full of prompting advice — offer the model a tip, threaten it, tell it to take a deep breath — and almost none of it arrives with a measurement. Some of those techniques move the output a great deal, some move it not at all, and at least one makes a classification task worse while costing more tokens. Which is which is not obvious from reading them, and it is not settled by argument.

So the next chapter builds a bench: sixty cases with known answers, four variants of the same prompt, run in parallel through exactly the client you just wrote, tabulated with the confidence intervals from Chapter 4 — because four variants over twenty cases distinguish nothing at all. One sentence governs the whole chapter: a prompt is measured, not debated.


Every number above came from the mock provider, on Node 22 over a loopback interface, so the latencies are cleaner than any real network will give you. That is deliberate: none of the failures being measured is caused by the network, and a hostile server you can restart teaches better than a real one you must pay for and cannot break.

  1. Server-Sent Events, WHATWG HTML Living Standard, section 9.2. The wire format — data: fields, blank-line-separated events, id: and retry: — is defined there, along with the EventSource interface. EventSource cannot send a request body or custom headers, which is why every LLM client parses the format by hand over fetch instead of using it.

  2. Brooker, M. Exponential Backoff and Jitter. AWS Architecture Blog (2015). The source of the "full jitter" formulation used above, with the simulations that show why the naive version synchronises clients. The companion argument for shedding load rather than queueing it is the Handling Overload chapter of Beyer, Jones, Petoff and Murphy (eds.), Site Reliability Engineering (O'Reilly, 2016).

  3. Fielding, R., Nottingham, M. and Reschke, J. (eds.), HTTP Semantics, RFC 9110, section 15, defines the status code classes; Nottingham, M. and Fielding, R., Additional HTTP Status Codes, RFC 6585 (2012), section 4, defines 429 Too Many Requests. Retry-After is RFC 9110 section 10.2.3, and accepts either a number of seconds or an HTTP date.

  4. Stripe, Idempotent requests, docs.stripe.com/api/idempotent_requests, read 7 September 2026 — the clearest statement of the contract: one key per logical operation, stored results replayed, a conflict returned while the first attempt is still in flight — and the pattern is provider-independent. The normative references for the request and event shapes used here are developers.openai.com/api/reference/resources/chat for streaming, error codes and rate limits, and platform.claude.com/docs/en/api/messages for the Messages API; ai-sdk.dev/docs is the best worked example of the same concerns wrapped in a library. All read the same day.


Created by

David Vicente Campos

Founder of NeuraLIA Labs & Co-Founder of MyRealFood

I'm a computer engineer from the University of León. I co-founded MyRealFood, where as CTO I built the app millions of people have used to eat better, and I founded NeuraLIA Labs, where I build AI products. Here I write about what I've had to understand along the way, as I wish someone had explained it to me.

More about the author

Published by NeuraLIA Labs.

Get new posts in your inbox

AI news, guides and product updates — a short email when we publish something worth your time.

Course index

Abstract software decision engine with branching paths, probability nodes, and glowing gates.
jev11 min read

Jev AI model is built for decisions, not prose

TypeSafe AI’s Jev is drawing attention because it treats software intelligence as a probability problem: choose the right branch, attach confidence, and avoid paying an LLM to write text when code needs a decision.

Abstract agent runtime sorting documents, memory blocks and pointer nodes inside a bounded context frame.
context-engineering12 min read

Context engineering for long-horizon AI agents

Long-running agents do not fail only because the window is small. They fail when files, tool outputs and stale history crowd out the task the agent was supposed to finish.

Ready to let LIA do the choosing?

Build with every AI model in one place — start free today.