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
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 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
Same slow service, two genuinely different events:
| What happened | Exception | Is there a status? |
|---|---|---|
| Server answered, slowly, with an error | requests.HTTPError | Yes, 504 |
| Client stopped waiting first | requests.ReadTimeout | No |
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
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
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)