0%

Talking to an API · practice

Bound Every Wait

Here is a failure mode that surprises people the first time it bites them: a program that does not crash, does not error, and does not finish. It just sits there. Something on the other end of the network stopped answering, and nothing told your code to stop waiting.

A timeout is the instruction to stop waiting. It is not an advanced option you reach for after being burned once. It belongs on every request you ever write.

Two waits, not one

Requests wants a pair, and the two halves mean different things:

DEFAULT_TIMEOUT = (1.0, 1.0)

The first number bounds the connect phase: how long to wait for the service to pick up the phone at all. The second bounds the read phase: how long to wait between pieces of the answer once the conversation has started.

Splitting them is useful because the two failures mean different things. A service that never accepts the connection is probably down or unreachable. A service that accepts and then goes quiet is probably up and struggling.

Watch the same delay produce two different outcomes

The practice API has a route that deliberately takes its time. Ask it to wait 250 milliseconds, with a read bound of a full second:

request_json(
    "/scenarios/slow",
    params={"delay_ms": 250},
    timeout=(1.0, 1.0),
)

It succeeds. The delay fits inside the bound, so nothing unusual happens.

The second route is /scenarios/timeout with params={"delay_ms": 750}, as recorded in API_CONTRACT.md. Run it once with each timeout below; the identifies which side stops waiting first:

import requests
from api_catalog import request_json

for timeout in [(1.0, 1.5), (1.0, 0.2)]:
    try:
        request_json(
            "/scenarios/timeout",
            params={"delay_ms": 750},
            timeout=timeout,
        )
    except requests.HTTPError as exc:
        print("HTTP response:", exc.response.status_code)
    except requests.ReadTimeout:
        print("Client stopped waiting; no response arrived.")

These are two separate probes, not a retry policy. Keep the in this inspection code; request_json still catches nothing.

With a generous read bound of (1.0, 1.5), the server gets far enough to send its own answer: a deliberate HTTP 504. That is a response. It arrived. raise_for_status() turns it into an HTTPError, and you can inspect exc.response.status_code and read 504 off it.

With a tight read bound of (1.0, 0.2), your client gives up first. Requests raises requests.ReadTimeout. There is no response , no status code, nothing to inspect. Your program stopped listening before anything came back.

Same slow service, two genuinely different events:

What happenedExceptionIs there a status?
Server answered, slowly, with an errorrequests.HTTPErrorYes, 504
Client stopped waiting firstrequests.ReadTimeoutNo

The distinction matters because the two suggest different next steps. In this controlled scenario, a 504 means an HTTP error response arrived from the server-side path. A ReadTimeout means the client stopped waiting first and says nothing certain about what the server eventually did.

Do not guess from the clock

Two rules for request_json, and both are about restraint.

Catch nothing here. This makes a request and returns a decoded body. It has no idea whether its caller wants to print a message, exit with a status, or fall back to a cache. Deciding that here would force the policy on every future caller.

Do not infer the boundary outcome from elapsed time. It is tempting to measure how long the call took and reason backwards. The exception class distinguishes a received HTTP error response from a client-side timeout. Wall-clock timing alone cannot do that reliably on a slow machine or a busy day.

Forward the caller’s timeout unchanged and let the exception carry the meaning.

What does a client ReadTimeout tell you?

Why does request_json send a timeout on every call rather than only on routes known to be slow?

Task

Keep DEFAULT_TIMEOUT exactly (1.0, 1.0) and ensure every request forwards its timeout explicitly. Preserve a supplied (1.0, 1.5) or (1.0, 0.2) unchanged, keep one-call behavior, and let HTTP and timeout escape.