requests became Python's dominant HTTP library specifically because it made a genuinely awkward standard-library API (urllib) feel natural. This lesson goes beyond requests.get(url) to the details ā sessions, timeouts, and status-code handling ā that separate a demo script from production-ready code.
1Timeouts: The Non-Negotiable Default
requests has no default timeout whatsoever ā requests.get(url) with no timeout argument will wait, potentially forever, for the server to respond, if the server (or a network device between you and it) simply never sends anything back rather than actively refusing the connection. This is a genuinely common real-world failure mode: a downstream service that's overloaded, deadlocked, or stuck rather than cleanly erroring out, and a client with no timeout has no defense against it ā the calling code (and potentially an entire request-handling thread or worker process in a web server) simply hangs indefinitely.
timeout=5 (or a tuple, timeout=(3, 10), specifying separate connect and read timeouts) bounds exactly how long requests will wait before raising requests.exceptions.Timeout, converting an indefinite hang into a bounded, catchable failure your code can actually respond to ā retry (using the Retry Strategies techniques from the Advanced Error Handling section), fail gracefully, or surface a clear error to the caller.
The professional default, without exception: every single requests call in production code specifies an explicit timeout. There is essentially no legitimate reason to omit it ā even a generous timeout (30 seconds, for a genuinely slow but expected operation) is strictly better than no bound at all, since it converts an unbounded hang into a bounded, recoverable failure.
import requests
# DANGEROUS: no timeout -- can hang indefinitely if the server never responds
response = requests.get("https://api.example.com/data")
# CORRECT: always set an explicit timeout
response = requests.get("https://api.example.com/data", timeout=5)Converts an indefinite hang into a catchable Timeout exception
2raise_for_status(): Making HTTP Errors Actually Errors
A crucial, easy-to-miss detail about requests' design: requests.get(url) returning a 404 or 500 response is not, by itself, treated as a Python exception ā the call 'succeeds' from Python's perspective exactly as it would for a genuine 200 OK, and response.json() or response.text still work, returning whatever error body the server happened to send back. Code that doesn't explicitly check the status can silently proceed as though a failed request had actually succeeded, processing an error response's JSON body as if it were legitimate data.
response.raise_for_status() closes this gap: it inspects response.status_code, and if it falls in the 4xx (client error) or 5xx (server error) range, raises requests.exceptions.HTTPError ā converting an HTTP-level failure into an actual Python exception your code's try/except can catch, following exactly the precise-exception-handling discipline established throughout the Advanced Error Handling section.
The practical pattern this establishes as a near-universal default: response = requests.get(url, timeout=5); response.raise_for_status(); data = response.json() ā call raise_for_status() immediately after every request, before doing anything with the response body, so that a failed request fails loudly and immediately at the point of the actual failure, rather than propagating a misleading 'success' further into your code where the eventual failure (a KeyError on an unexpected error-response JSON shape, for instance) would be much harder to trace back to its real cause.
response = requests.get(url, timeout=5)
response.raise_for_status() # raises HTTPError if status is 4xx or 5xx
data = response.json() # only reached if the request genuinely succeededTurns a 404/500 into a catchable HTTPError, immediately
3Session: Reusing Connections and Shared Configuration
Each standalone requests.get(url) call, by default, establishes a fresh TCP (and, for HTTPS, TLS) connection from scratch ā meaningful overhead, especially for HTTPS's additional handshake cost, that's entirely wasted when making multiple requests to the *same* host in succession. requests.Session(), used as a context manager, maintains an underlying connection pool that's reused across every request made through that session object to the same host ā a real, measurable performance improvement for any code making more than one request to the same API.
Beyond connection reuse, a Session also lets you set configuration *once* that applies automatically to every subsequent request made through it ā session.headers.update({"Authorization": f"Bearer {token}"}) sets an authorization header that every following session.get()/session.post() call automatically includes, eliminating the need to repeat headers={...} on every individual call and the risk of forgetting it on one of them.
The practical rule this establishes: any code making more than one request to the same API or host should use a Session rather than repeated standalone requests.get()/requests.post() calls ā it's both a performance improvement (connection reuse) and a correctness improvement (shared configuration applied consistently, impossible to accidentally omit on one specific call).
import requests
with requests.Session() as session:
session.headers.update({"Authorization": f"Bearer {token}"})
response1 = session.get("https://api.example.com/users")
response2 = session.get("https://api.example.com/orders")
# BOTH requests reuse the same underlying TCP connection -- faster, and auth header set onceReused connections, shared headers ā set once, applied everywhere
4Step-by-Step Breakdown
requests.get(url) works in a demo. In production, without a timeout, that same line can hang your entire program indefinitely ā let's cover what actually needs to be there.
requests.get() without a timeout can hang FOREVER if the server never responds -- always set one explicitly.
Checkpoint: What happens if requests.get(url) is called with no timeout, and the server never responds?
- āThe call can block indefinitely, potentially freezing the entire program until the connection is somehow interrupted
- ārequests automatically applies a reasonable default timeout after a few seconds
response.raise_for_status() converts a bad HTTP status (4xx/5xx) into a Python exception -- otherwise a failed request looks identical to a successful one to your code.
Checkpoint: Without calling raise_for_status(), how would code notice that a request returned a 404 Not Found?
- āIt wouldn't notice automatically ā response.json() or response.text would still 'succeed' and return whatever error body the server sent, silently
- ārequests.get() automatically raises an exception itself whenever the status code is 4xx or 5xx
A Session reuses the underlying TCP connection across multiple requests to the SAME host -- meaningfully faster than a new connection per call.
requests remains the sync HTTP standard; httpx is the modern alternative worth knowing, especially for its async support.
Raise a Real HTTP Error. Finish FakeResponse.raise_for_status(): turn a 4xx/5xx response into a real exception.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Always set an explicit timeout on every requests call, with no exceptions
requests has no default timeout ā omitting it risks an indefinite hang if a server or network device fails to respond, a genuinely common production failure mode.
Always call raise_for_status() immediately after a request, before processing the response body
A 4xx/5xx response is not automatically an exception in requests ā raise_for_status() converts it into one, catching the failure at its actual source instead of letting it propagate misleadingly into your data-processing logic.
Frequent Bugs
Calling requests.get(url) with no timeout in production code, causing the calling thread or process to hang indefinitely if the server or an intermediate network device fails to respond.
Always pass an explicit timeout argument to every requests call, converting a potential indefinite hang into a bounded, catchable Timeout exception.
Real-World Examples
A Resilient API Client Combining Session, Timeout, and Status Checking
A service needs to make multiple authenticated requests to an internal API, correctly bounded by timeouts and properly surfacing any HTTP-level failures.
import requests
class InternalApiClient:
def __init__(self, base_url: str, token: str):
self.base_url = base_url
self.session = requests.Session()
self.session.headers.update({"Authorization": f"Bearer {token}"})
def get_user(self, user_id: int) -> dict:
response = self.session.get(f"{self.base_url}/users/{user_id}", timeout=5)
response.raise_for_status()
return response.json()