Skip to content
MINH VO A working notebook
by an engineer in Vietnam
Foundation9 min read

Set an LLM timeout from the user's remaining deadline

Propagate a user request deadline through retrieval and generation, reserve time for a fallback, and avoid retries that continue after the answer is no longer useful.

Open notebook with connected abstract shapes and olive thread linking paper swatches

For an engineer serving an LLM feature, a timeout should follow the user’s remaining deadline rather than restart at every internal step. Retrieval, model generation, tool calls, and response delivery all consume the same end-to-end budget. Giving each step the full budget can make a supposedly bounded request last several times longer than intended.

Consider a hypothetical support interface that allows eight seconds for a response. That value is a product choice for this example, not a recommended universal threshold. The application reserves half a second to format and deliver either an answer or a useful fallback.

Work backward from the deadline

At request arrival, record a monotonic start time and calculate a deadline. A monotonic clock avoids confusion from wall-clock adjustments while measuring elapsed time. Each stage receives the remaining budget, with any final-response reserve subtracted before starting expensive work.

Suppose authentication and routing take 0.5 seconds, then retrieval takes 1.5 seconds. Two seconds have elapsed, leaving six. After preserving the half-second delivery reserve, generation has at most 5.5 seconds under this policy. It does not receive a fresh eight-second timeout.

Total request budget:          8.0 seconds
Authentication and routing:   0.5 seconds
Retrieval:                    1.5 seconds
Reserved response handling:   0.5 seconds
Remaining generation budget:  5.5 seconds

These numbers are an illustrative trace, not measured service latency. Real workloads need distributions, including slow cases, before you can choose a useful deadline and fallback policy.

An eight-second request subtracts routing, retrieval, and response reserve to leave five and a half seconds for generationView full-size image ↗

The diagram shows the remaining budget after each stage. If retrieval instead takes five seconds, generation receives less time or does not start. That decision should be explicit so an expensive call is not launched when the application already knows it cannot use the result.

Distinguish time to first output from completion

Streaming can let a user see text before generation finishes. It does not mean that the complete answer is ready or valid. A structured extraction may be unusable until the full object arrives and passes validation. A conversational answer may be readable early but still need a citation or caveat that appears later.

The OpenAI latency optimization guide, retrieved on September 20, 2026, discusses latency across model work and application design. Choose the metric that matches the product: time to first visible token, time to a validated object, or time to a completed task. Reporting only the fastest of these can hide a poor user experience.

If the user cancels a streaming request, propagate cancellation where the provider and transport support it. Closing the browser connection does not automatically prove that every downstream operation stopped. Record the actual cancellation result or treat the operation as potentially still running until its state is known.

Reserve a truthful fallback

A useful timeout response depends on the task. A search assistant can return retrieved document links when generation is unavailable, if those links are authorized and relevant. A financial extraction should not return a partially filled object as if validation succeeded. It may create a review item or ask the user to retry later.

The fallback needs its own small execution budget. If every millisecond is spent waiting on the model, the application may fail to send a clear terminal response. Reserving time for response handling also lets the system record an operation identifier that a user can reference when resuming.

Avoid silently switching to an alternate model unless that model satisfies the feature’s requirements. Different models may have different schema support, quality, or tool behavior. Evaluate a fallback path before using it during an outage, and make degraded behavior visible when it changes what the user can rely on.

Retry only while the request can still succeed

A transient error can justify a retry if enough time remains and the operation is safe to repeat. The rate limits guide describes handling rate limits, including backoff. A backoff policy still needs a total deadline; otherwise, retries can continue after the user has already received a failure.

For a model-only read operation, a repeat may mainly add cost. For an agent tool that creates a record, repeating after an ambiguous timeout can duplicate a side effect. Use the tool’s operation identity and result record to determine what happened before retrying. A network timeout is an observation failure, not a transaction rollback.

Count retries in the same trace as the original request. Track time spent waiting, time spent executing, and the final outcome. A request that succeeds on its third attempt should contribute the full cost and latency to the feature’s metrics, not only the last successful call.

Test deadlines with controlled delays

Use a stub retrieval service and a stub model endpoint that can delay or fail at known points. Test fast retrieval with slow generation, slow retrieval with no remaining generation budget, a cancelled client, and a retry whose backoff would exceed the deadline. Verify that the application reaches a defined terminal state in each case.

Also inspect what remains running after the user response ends. A background operation may be intentional if the product supports later retrieval, but then it needs durable identity and a clear status endpoint. An abandoned expensive call with no owner is a different outcome.

The first operational goal is an explainable trace: the request began with a budget, each stage consumed part of it, and the application used the remaining time according to a stated policy. That evidence makes a timeout a designed behavior rather than an arbitrary exception at the end of a long wait.

Observe what cancellation actually waits for

There are two different claims to test: the caller stops accepting a model result, and the model operation has stopped. They need not occur together. Python’s asyncio.wait_for documentation explicitly says that it waits for cancellation to finish, so elapsed time can exceed the requested timeout. A library timeout is therefore not a hard wall-clock promise to the user.

This runnable example uses a local coroutine instead of a provider. The coroutine waits forever until cancellation, then performs a short asynchronous cleanup. The one-millisecond timeout is chosen to trigger that path in a small demonstration; it is not a service recommendation. The event ordering is the observation, and scheduler delays are not interpreted as a latency benchmark.

import asyncio
import time

async def model_stub(events):
    events.append("model_started")
    try:
        await asyncio.Event().wait()
    except asyncio.CancelledError:
        events.append("cancellation_observed")
        await asyncio.sleep(0.02)
        events.append("cleanup_finished")
        raise

async def cancellation_trace():
    events = []
    started = time.monotonic()
    try:
        await asyncio.wait_for(model_stub(events), timeout=0.001)
    except TimeoutError:
        events.append("caller_received_timeout")
    elapsed = time.monotonic() - started
    assert events == ["model_started", "cancellation_observed",
                      "cleanup_finished", "caller_received_timeout"]
    assert elapsed >= 0.02
    print(events)
    return elapsed

if __name__ == "__main__":
    asyncio.run(cancellation_trace())

The caller observes its timeout only after cleanup finishes. The example deliberately propagates CancelledError; swallowing it would change the coroutine’s contract again. There is no network request, background worker or remote cancellation acknowledgment in this run. A blocking function that never yields can behave worse: cooperative cancellation cannot make it observe an exception while it continues blocking the event loop.

A local coroutine starts, observes cancellation, finishes cleanup, and only then lets the caller receive a timeout; the requested timeout is not a hard completion boundView full-size image ↗

This also changes how a reserve should be interpreted. Half a second set aside in arithmetic does not ensure that uncontrolled cleanup, response serialization or a stalled socket finishes within it. Enforcing a response policy requires ownership of those activities, bounded work where possible and observation of the terminal response. If remote work can outlive that response, keep its operation identity and handle the later result deliberately.

Google’s SRE chapter Handling Overload, read on September 21, 2026, explains that degraded responses still consume resources and may themselves become unavailable. That is a useful constraint on an LLM fallback: define the cheaper result and the resources needed to produce it. A stale answer or reduced search scope must also meet the feature’s correctness requirements; being cheaper does not make it suitable for every task.

Check the arithmetic at the launch boundary

The deadline calculation itself can be tested separately from scheduling. The helper below accepts finite numeric clock readings from the same monotonic clock and a nonnegative reserve. It returns a cap, not permission to ignore a provider’s own limits or a guarantee that a coroutine finishes on time.

import math

def generation_budget(deadline, now, reserve):
    values = (deadline, now, reserve)
    if any(type(value) not in (int, float) for value in values):
        raise ValueError("numeric times are required")
    try:
        deadline, now, reserve = map(float, values)
    except OverflowError as error:
        raise ValueError("times exceed this numeric representation") from error
    if not all(math.isfinite(value) for value in (deadline, now, reserve)) or reserve < 0:
        raise ValueError("finite times and a nonnegative reserve are required")
    budget = deadline - now - reserve
    if not math.isfinite(budget):
        raise ValueError("budget exceeds this numeric representation")
    return max(0.0, budget)

assert generation_budget(8.0, 2.0, 0.5) == 5.5
assert generation_budget(8.0, 7.5, 0.5) == 0.0
assert generation_budget(8.0, 9.0, 0.5) == 0.0

Recompute immediately before dispatch, including after a backoff. Do not reuse a positive value calculated before a long wait. If the cap is zero, enter the fallback path without launching generation. A tiny positive cap can still be too short to produce a useful answer; that product-specific admission threshold is separate from the arithmetic shown here.

Do not send the absolute deadline number to another machine as if monotonic clocks shared an epoch. Carry the protocol’s supported deadline representation or a remaining duration, accounting for transit and local processing according to that protocol. The local example tests neither clock synchronization nor distributed deadline propagation. It gives the reader two inspectable pieces of evidence: correct budget subtraction and a concrete reason cancellation can outlast that budget.

Sources & further reading

  1. OpenAI latency optimization guide
  2. OpenAI rate limits guide
  3. Python 3.12 asyncio task cancellation and wait_for
  4. Google SRE, Handling Overload
← Back to the journal
All notes

Illustration

100%