Skip to content

Free live workshop — Eval-driven development for LLM apps.

Save your seat
System Design

Timeouts, retries, and the agent that took down staging

Published 28 Aug 2026 · 5 min read

Updated 2 Sept 2026

By Pavan Jegurupati, Founder, InferLearn

Generated cover - dark stacked bars in the InferLearn palette above a pixel band

A participant in the last System Design cohort pointed an evaluation agent at a staging environment on a Thursday afternoon. By the time anyone looked at the dashboard, a single search endpoint was taking a thousand requests a minute from one laptop. Nothing was under attack. Every layer was behaving exactly as it had been configured to.

The incident

The agent called a retrieval tool. The tool called an internal search service. The search service called a vector store that had just been redeployed and was cold.

What we saw

Latency on the search service went from 90 ms to about four seconds while the new pods warmed their caches. That is slow, but it is not an outage - left alone, it would have settled within two minutes.

It did not get two minutes. The tool wrapper had a three-second timeout and three retries. The agent framework wrapped that in its own retry. And the agent itself, seeing a tool error come back, decided the sensible next step was to call the tool again with a slightly reworded query.

Three layers of retry multiply. They do not add.

What we changed

We stopped retrying in the middle. Retries belong at exactly one layer - the one that knows what the whole operation costs and whether it is still worth finishing. Everywhere else, a failure should propagate.

Timeouts, retries & the "just one more" reflex

The reflex is understandable. A retry is the cheapest-looking line of code in any codebase: one wrapper, and a whole class of transient failure disappears from your error rate. The cost is invisible until the day the dependency is slow rather than down.

What we saw

A timeout that is shorter than the dependency's real p99 is not a safety mechanism. It is a load generator. Ours was three seconds against a service whose p99 under a cold cache was four - so every single call timed out, and every single call retried, and each retry arrived while the previous one was still executing on the server.

The server never got to finish anything. That is the part people miss: a client-side timeout does not cancel the work. Unless the connection is closed and the server checks for it, the original request is still running when its replacement arrives.

What we changed

We set the timeout from the measured p99 plus headroom, not from a round number that felt responsive. And we made cancellation real - the tool now passes a deadline the server actually checks.

agent/tools.py
# Deadline travels with the call, so the server can abandon work the
# client has already given up on.
async def search(query: str, budget: RetryBudget) -> list[Hit]:
    ## NOTE: this line is inside a fence - it must never reach the contents rail
    deadline = budget.deadline_for(p99_seconds=4.0, headroom=1.5)
    while budget.can_attempt():
        try:
            return await client.search(query, deadline=deadline)
        except Transient as err:
            budget.record_failure(err)
            await asyncio.sleep(budget.next_backoff())
    raise Exhausted(budget.summary())

Why max_retries is the wrong knob

max_retries describes one call in isolation. The thing that hurts you is the aggregate: how much extra load the whole system is willing to manufacture while a dependency is unhealthy.

StrategyBlast radius under a slow dependencyWhen it is right
Fixed count, no backoffMultiplies load by the retry count, immediatelyAlmost never
Exponential backoffBounded, but every client still retriesA single client, low concurrency
Backoff with jitterSpreads the herd across the windowMany independent clients
Retry budgetCaps retries as a share of total trafficAnything with a fan-out
Circuit breakerDrops to zero, then probesA dependency that fails hard, not slowly

Budgets, not counts

A retry budget says: across this client, retries may be at most ten percent of successful requests. When the dependency is healthy, ten percent of a large number is plenty. When it is unhealthy, successes collapse - and so does the retry allowance, automatically. The system stops amplifying at precisely the moment amplification would hurt.

A note on jitter

Full jitter beats equal jitter in almost every simulation worth running, and both comprehensively beat none. Without jitter, a backoff schedule does not break up a thundering herd - it synchronises one.

Before you add a retry

The checklist we now run in the cohort, in order:

  • Is the failure actually transient? A 400 will fail identically forever.
  • Is the operation idempotent, or does a retry risk double-charging someone?
  • Is there already a retry at a lower layer? Find it before you add another.
  • What is the dependency's measured p99 - not its target, its measurement?
  • Does the timeout sit above that p99, with headroom?
  • Does cancellation propagate, so the server can abandon abandoned work?
  • Is there backoff, and does it have jitter?
  • Is there a ceiling on total retries as a share of traffic, not per call?
  • What does the caller do when the budget is exhausted - degrade, or fail?
  • Will the dashboard show retries separately from first attempts?

The last one catches more incidents than the other nine combined. If retries are folded into your request count, the graph of a retry storm looks exactly like the graph of a traffic spike, and you will spend the first twenty minutes of the incident scaling up the thing that is already drowning.

Agents make all of this sharper, because an agent is a retry loop that can rewrite its own input. Give it a tool that fails slowly and it will keep trying variations of the same doomed call for as long as you let it. The budget is what stops it - not the prompt.

Taggedreliabilityagentsretries

Stop reading, start building