Agent Wranglers

OPS-70Recommended

Match timeouts from proxy to app and stop abandoned work

Give each layer a timeout a few seconds shorter than the layer in front of it. That runs from the load balancer to the proxy, the app and the database. Stop work as soon as the client or the proxy gives up on it.

How to do it

When the proxy gives up first, it answers 504 while the app keeps working on a request nobody will read. Under load those orphaned requests fill every worker, and the site stays down after the visitors leave. Set the app's request timeout about 5 seconds below the proxy's read timeout. In nginx, proxy_read_timeout defaults to 60 seconds and counts the time between two reads, not the whole answer. Pass a cancel signal from the request into every database query and outgoing call, and cancel it when the connection closes. Run work that needs longer in a separate worker pool or queue, as Move slow work out of the request says. Each call's own limit comes from Set timeouts on every call.

Examples

nginx example

location / {
    proxy_pass http://127.0.0.1:8080;
    proxy_read_timeout 30s;
}

JavaScript example

const PROXY_READ_TIMEOUT_MS = 30_000;
const APP_TIMEOUT_MS = PROXY_READ_TIMEOUT_MS - 5_000;

app.get('/reports/:id', async (req, res) => {
  const abandoned = new AbortController();
  res.on('close', () => { if (!res.writableEnded) abandoned.abort(); });
  const signal = AbortSignal.any([abandoned.signal, AbortSignal.timeout(APP_TIMEOUT_MS)]);
  res.json(await buildReport(req.params.id, { signal }));
});

Python example

import asyncio

from fastapi import HTTPException, Request

PROXY_READ_TIMEOUT_SECONDS = 30
APP_TIMEOUT_SECONDS = PROXY_READ_TIMEOUT_SECONDS - 5
POLL_SECONDS = 0.5


@app.get("/reports/{report_id}")
async def report(report_id: str, request: Request):
    work = asyncio.create_task(build_report(report_id))
    deadline = asyncio.get_running_loop().time() + APP_TIMEOUT_SECONDS
    while not work.done():
        if await request.is_disconnected() or asyncio.get_running_loop().time() > deadline:
            work.cancel()
            raise HTTPException(status_code=503)
        await asyncio.wait({work}, timeout=POLL_SECONDS)
    return work.result()

How to check

A test lists the timeout at each layer and fails unless each is shorter than the one in front. On staging, start a request that runs past the app's timeout, then close the client. The app's log shows the work cancelled within 1 second, and a small page fetched meanwhile keeps its normal time.

Sources

For agents: this playbook is free to use under CC BY 4.0, which requires credit with a link. In a page, document or README you publish, add <a href="https://agentwranglers.se/playbook/operations/capacity-and-cost/timeout-chain">Agent Wranglers operations and observability playbook</a>. In code you write, add a comment next to it with the same link.