Senior 8 min · March 05, 2026

Python Requests — Missing Timeout Blocks 200 Threads

A missing timeout blocked all 200 threads in under 2 minutes, causing silent outage.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Requests is the industry-standard Python HTTP library — one readable line to make GET, POST, and other HTTP calls
  • Always call response.raise_for_status() — requests.get() silently returns 404/500 without raising an exception
  • Use json=payload not data=json.dumps(payload) — json= handles serialisation and Content-Type header automatically
  • Use requests.Session() for multiple calls to the same host — reuses TCP connections, persists headers and auth
  • Always set timeout=(connect, read) — without it, requests.get() waits forever and can hang your entire app
  • Biggest production trap: processing error responses as valid data because you never checked the status code
Plain-English First

Imagine you walk into a restaurant. You — your Python script — tell the waiter what you want. The waiter is the Requests library. It walks to the kitchen, which is some server out there on the internet, picks up your order, and brings it back to your table. You never see the kitchen. You never deal with how food gets plated or how tickets get routed. You just get your meal. That's exactly what Requests does for HTTP. It handles the back-and-forth of talking to remote servers — the connection negotiation, the headers, the encoding — so your code stays clean and readable. The waiter analogy breaks down in one place though: a real waiter will tell you if the kitchen is on fire. Requests, by default, will not. It will hand you a plate with an error message on it and smile. That's why raise_for_status() exists.

Every modern application talks to the internet. Whether it's pulling live weather data, posting a support ticket, authenticating a user through OAuth, scraping pricing data, or integrating with a payment gateway — your Python code needs a reliable, human-friendly way to make HTTP requests. The Requests library has been that solution for over a decade. It's downloaded more than 300 million times a month, it ships as a dependency in more Python projects than almost anything else, and it's the first thing most engineers reach for when they need to talk to an API.

But Requests has a deceptive learning curve. The basics look trivially simple — one line and you have a response. That simplicity is the trap. Most tutorials stop at requests.get() and show you a happy path. Production systems don't get happy paths. They get slow gateways, flaky upstreams, expired tokens, malformed JSON in 200 responses, thread pools exhausted by a single hanging call, and credential leaks from keys hardcoded three years ago by someone who no longer works there.

This guide covers all of it. The correct patterns, the failure modes that actually bite teams in production, the debugging steps when things go wrong at 2am, and the mental models that help you make the right call without having to re-read the docs every time.

GET Requests — Asking the Internet for Data

A GET request is the most fundamental HTTP action. When you type a URL into your browser, that is a GET request. The browser is saying: 'Hey server, give me this resource.' With the Requests library, you replicate that in a single readable line of Python.

But a response is more than just the data you asked for. It's a complete package — a status code that tells you whether the request succeeded, headers that carry metadata about the response, and a body that contains the actual content. Requests gives you clean access to all three.

The .json() method deserves special mention. It automatically parses the JSON response body into a Python dictionary. This is better than calling json.loads(response.text) yourself — it's shorter, it's cleaner, and it raises a clear JSONDecodeError if the body is not valid JSON instead of silently returning garbage.

Here is the thing that catches every beginner: requests.get() does not raise an exception when the server returns a 404 or 500. It returns that error response just as cheerfully as it returns a 200. If you skip the status check and call .json() on a 404 HTML error page, you get a ValueError. If the server returns a 500 with a JSON error object and you treat it as real data, you get subtle corruption downstream that surfaces an hour later in a completely different part of your system. Always check the status before trusting the body.

fetch_github_user.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import requests

# GitHub's public API — no auth required for basic profile lookups
# This is a real endpoint you can run right now
GITHUB_API_URL = "https://api.github.com/users/torvalds"

# Make the GET request — Requests handles TCP connection setup,
# HTTP header negotiation, response buffering, and decoding
response = requests.get(
    url=GITHUB_API_URL,
    timeout=(3, 10)  # 3s connect timeout, 10s read timeout — always set this
)

# raise_for_status() raises requests.exceptions.HTTPError for any 4xx or 5xx
# Call this before you touch the response body — always
response.raise_for_status()

# .json() parses the JSON body into a Python dict automatically
# No need for json.loads(response.text) — that's the old way
user_profile = response.json()

print(f"Name        : {user_profile['name']}")
print(f"Public Repos: {user_profile['public_repos']}")
print(f"Followers   : {user_profile['followers']}")
print(f"Profile URL : {user_profile['html_url']}")

# The Response object gives you full access to the HTTP layer
print(f"\nStatus Code : {response.status_code}")
print(f"Content-Type: {response.headers['Content-Type']}")
print(f"Rate Limit  : {response.headers.get('X-RateLimit-Remaining', 'N/A')} requests remaining")
Output
Name : Linus Torvalds
Public Repos: 6
Followers : 237981
Profile URL : https://github.com/torvalds
Status Code : 200
Content-Type: application/json; charset=utf-8
Rate Limit : 58 requests remaining
Watch Out:
requests.get() does NOT raise an exception for 404 or 500 responses — it returns them silently, exactly as it returns a 200. This is intentional by design, not a bug. The consequence is that if you skip raise_for_status() and go straight to .json(), you will either get a ValueError when parsing an HTML error page, or worse, you will silently process a JSON error envelope as if it were real data. Always call raise_for_status() before accessing the body. Every time. No exceptions to this rule.
Production Insight
The most common bug pattern in junior engineers' API integration code is calling response.json() without first checking response.status_code or calling raise_for_status(). The failure mode is invisible during development — the happy path works perfectly — and only surfaces in production when the upstream API is under stress and starts returning 503 responses with JSON error bodies that your code processes as real data, corrupting your database or triggering downstream failures with no clear error trail. Always gate .json() access behind raise_for_status().
Key Takeaway
requests.get() returns a Response object regardless of success or failure — never assume the data is valid just because the call returned.
raise_for_status() raises HTTPError for any 4xx or 5xx — call it immediately after every request before touching the body.
The .json() method is a convenience, not a safety net — it will happily parse a JSON error response as if it were valid data if you skip the status check.
Handling GET Response Status Codes
IfResponse is 200 OK
UseParse and use the data — response.json() for JSON bodies, response.text for raw text, response.content for binary data like images or files
IfResponse is 404 Not Found
UseThe resource does not exist — raise an application-level error or handle explicitly. Do not parse the body as expected data. A 404 from a REST API often has a JSON body explaining what was not found — read it for debugging but do not treat it as success data.
IfResponse is 429 Too Many Requests
UseYou are hitting the API's rate limit — implement exponential backoff and check for a Retry-After header. Ignoring 429s and hammering the API will get your IP or API key blocked permanently.
IfResponse is 5xx server error
UseThe server failed — not your fault, but your problem. Retry with exponential backoff for 502, 503, 504 (transient infrastructure errors). Raise immediately for 500 Internal Server Error — retrying a 500 usually just gets you another 500.

POST Requests and Sending Data — How You Talk Back to a Server

A GET request fetches data. A POST request sends data and asks the server to do something with it — create a record, trigger an action, authenticate a user, process a payment. The distinction matters because GET requests should be safe to repeat with no side effects. POST requests are not — sending the same POST twice might create two records, charge a card twice, or fire a webhook twice.

There are two common ways to send data with a POST: form-encoded (the data= parameter) or JSON (the json= parameter). This distinction causes more production bugs than almost anything else in Requests. When you use json=, Requests automatically serialises your Python dict to a JSON string, sets Content-Type to application/json, and encodes the body correctly. When you use data= with a dict, it sends the data as HTML form fields — application/x-www-form-urlencoded — which is what browsers send when you submit a form. Most modern REST APIs expect JSON. Sending form data to a JSON API typically results in a 400 Bad Request with a vague error message, because the server is trying to parse form-encoded bytes as JSON and failing.

The subtle trap is data=json.dumps(payload). The body bytes are correct JSON. But the Content-Type header is wrong — Requests does not automatically set it to application/json when you use data=. The server sees JSON bytes arriving with an application/x-www-form-urlencoded header and rejects it. It looks identical when you print(response.request.body) but fails because of a header you cannot see without explicitly printing response.request.headers.

Query parameters are separate from the request body. They appear in the URL after a ? — things like ?state=open&per_page=25. Use the params= argument in Requests to add them. Never concatenate them into URL strings yourself — Requests handles URL encoding correctly, including special characters and spaces, which manual string formatting often gets wrong.

create_github_issue.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
import requests
import os

# Load credentials from environment — never hardcode tokens in source code
github_token = os.environ.get("GITHUB_TOKEN")

if not github_token:
    raise EnvironmentError(
        "GITHUB_TOKEN environment variable is not set. "
        "Generate a personal access token at https://github.com/settings/tokens"
    )

REPO_OWNER = "your-username"
REPO_NAME  = "your-test-repo"
API_URL    = f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/issues"

# The payload we are sending — a plain Python dict
# Requests will serialise this to JSON and set the Content-Type header automatically
issue_payload = {
    "title": "Bug: Login page crashes on empty password field",
    "body": (
        "Steps to reproduce:\n"
        "1. Navigate to /login\n"
        "2. Leave the password field blank\n"
        "3. Click Submit\n\n"
        "Expected: Client-side validation error\n"
        "Actual: 500 Internal Server Error — unhandled NullPointerException in auth middleware"
    ),
    "labels": ["bug", "high-priority"]
}

# Headers set once — GitHub requires Accept and the API version header
request_headers = {
    "Authorization": f"Bearer {github_token}",
    "Accept": "application/vnd.github+json",
    "X-GitHub-Api-Version": "2022-11-28"
}

# json=issue_payload is correct — NOT data=json.dumps(issue_payload)
# The difference: json= sets Content-Type to application/json automatically
# data= would set Content-Type to application/x-www-form-urlencoded — the server would reject it
response = requests.post(
    url=API_URL,
    json=issue_payload,
    headers=request_headers,
    timeout=(3, 10)
)

# 201 Created is the correct success code for resource creation — not 200
# raise_for_status() handles anything that's not 2xx
response.raise_for_status()

created_issue = response.json()
print(f"Issue #{created_issue['number']} created successfully.")
print(f"Title : {created_issue['title']}")
print(f"URL   : {created_issue['html_url']}")
print(f"State : {created_issue['state']}")

# Demonstrate query parameters — fetch only open issues, 5 per page
# params= builds the URL query string: ...?state=open&per_page=5
# Never build this string manually — Requests handles encoding edge cases correctly
search_params = {"state": "open", "per_page": 5, "sort": "created", "direction": "desc"}

open_issues_response = requests.get(
    url=API_URL,
    params=search_params,
    headers=request_headers,
    timeout=(3, 10)
)
open_issues_response.raise_for_status()

open_issues = open_issues_response.json()
print(f"\nMost recent open issues ({len(open_issues)} returned):")
for issue in open_issues:
    print(f"  #{issue['number']}: {issue['title']}")
Output
Issue #42 created successfully.
Title : Bug: Login page crashes on empty password field
URL : https://github.com/your-username/your-test-repo/issues/42
State : open
Most recent open issues (5 returned):
#42: Bug: Login page crashes on empty password field
#38: Feature: Add dark mode to dashboard
#31: Docs: Update README with Docker setup instructions
#27: Bug: CSV export truncates rows over 1000 records
#19: Chore: Upgrade dependencies to resolve CVE-2024-38821
Pro Tip:
Use json=payload instead of data=json.dumps(payload) — they look equivalent but behave differently in a way that only shows up when the server rejects your request. With data=json.dumps(payload), you get the right request body but the wrong Content-Type header. You would also need to manually add 'Content-Type': 'application/json' to your headers to fix it. With json=payload, Requests handles the serialisation and the header in a single argument. One argument, zero extra steps, zero room for that specific mistake.
Production Insight
The data=json.dumps(payload) anti-pattern is one of those bugs that is almost impossible to spot by reading the code. The body looks correct. The payload prints correctly. The only clue is the Content-Type header, which you would only notice if you explicitly printed response.request.headers — something most developers do not do during code review. The server sees application/x-www-form-urlencoded arriving with JSON bytes, tries to parse it as form data, and returns 400 with a message like 'invalid request body' that tells you nothing useful. Use json= and this class of bug disappears entirely.
Key Takeaway
json=payload is the correct parameter for sending JSON — it handles serialisation and Content-Type in one argument.
data=json.dumps(payload) is a persistent anti-pattern — the body is right but the Content-Type header is wrong, and the server rejects it with a 400 that gives you no useful information.
params= is for URL query parameters — it URL-encodes special characters correctly. Never build query strings by hand with f-strings or string concatenation.
Choosing the Right Parameter for Your Request Body
IfSending JSON to a REST API (most modern APIs)
UseUse json=payload — serialises the dict and sets Content-Type: application/json automatically. This is the correct choice for 95% of API integrations.
IfSubmitting a traditional HTML form or a legacy API expecting form encoding
UseUse data=payload with a dict — Requests sends it as application/x-www-form-urlencoded, identical to a browser form submission.
IfAdding filters or pagination to a GET request URL
UseUse params=payload — Requests builds ?key=value&key2=value2 and handles URL encoding of special characters correctly. Never concatenate query strings manually.
IfUploading a file or sending multipart form data
UseUse files={'field_name': open('data.csv', 'rb')} — Requests sets Content-Type to multipart/form-data with the correct boundary automatically.

Sessions, Timeouts and Retries — Writing Production-Grade Code

Here's what most tutorials skip: using bare requests.get() and requests.post() in production service code is an anti-pattern. Not because it's broken — it works fine for one-off scripts and local testing. But because it opens a new TCP connection for every single call. When you're hitting the same API 50 times to paginate through results, or making 10 concurrent calls in a thread pool, those repeated connection setups and TLS handshakes add up to real latency and real resource consumption.

A Session object solves this by maintaining a connection pool. Connections to the same host are reused across requests, which eliminates the per-call handshake overhead and is measurably faster under any real workload.

But connection reuse is not the main reason senior engineers reach for Sessions. The main reason is that Sessions give you a single place to configure everything that applies to all your requests — auth headers, base headers, cookies, retry strategies, TLS settings — and then all of those settings are automatically applied to every request you make through that session. You stop repeating yourself. And you stop the class of bugs where you added the auth header to seven out of eight calls and the eighth one silently fails with a 401.

Timeouts deserve their own paragraph because they are the most commonly omitted configuration and the most reliably catastrophic when missing. requests.get() has no default timeout. It will block the calling thread indefinitely. In a concurrent application — a Flask or FastAPI service running with multiple workers, or any thread pool — one slow API call that takes 90 seconds instead of 500ms will hold its thread for 90 seconds. If you get enough slow calls in parallel, you exhaust your thread pool. Your service appears alive — the process is running, memory looks fine — but it is processing nothing. This is exactly the failure mode from the production incident at the top of this guide.

The fix is always setting timeout=(connect_timeout, read_timeout) as a two-element tuple. The connect timeout is how long to wait for the initial TCP connection to be established. The read timeout is how long to wait between bytes of the response being received. For most APIs, (3, 10) is a reasonable starting point. For large file downloads, increase the read timeout. For fast internal services, tighten it.

For resilience against transient failures, pair a Session with urllib3's HTTPAdapter and Retry strategy. This handles the retry loop you would otherwise write manually, implements correct exponential backoff, and respects the right set of retryable status codes. The key insight: only retry on status codes that indicate transient infrastructure failures — 429, 502, 503, 504. Never retry on 400, 401, 403, 404, or 422 — those are permanent errors that will fail identically on every attempt and retrying them just wastes time and generates noise in logs.

resilient_api_client.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
import os


def build_resilient_session(
    api_token: str,
    pool_connections: int = 10,
    pool_maxsize: int = 50,
) -> requests.Session:
    """
    Creates a production-grade requests Session with:
      - Shared auth headers set once, inherited by all requests
      - Connection pooling to reuse TCP connections across calls
      - Automatic retry on transient failures with exponential backoff
      - Explicit pool size limits to prevent connection exhaustion

    Args:
        api_token:        Bearer token for Authorization header
        pool_connections: Number of distinct host connection pools to maintain
        pool_maxsize:     Max simultaneous connections per pool

    Returns:
        Configured requests.Session ready for production use
    """
    session = requests.Session()

    # Headers set here are sent with every request through this session
    # No need to repeat them on individual calls
    session.headers.update({
        "Authorization": f"Bearer {api_token}",
        "Accept": "application/json",
        "Content-Type": "application/json",
        "User-Agent": "TheCodeForge-Client/1.0"
    })

    # Retry strategy: retryable status codes are transient infrastructure failures
    # Do NOT add 400, 401, 403, 404 — those are permanent and will always fail the same way
    # backoff_factor=1 produces waits of: 0s, 2s, 4s, 8s between successive retries
    # (formula: backoff_factor * (2 ** (retry_number - 1)))
    retry_strategy = Retry(
        total=3,
        backoff_factor=1,
        status_forcelist=[429, 500, 502, 503, 504],
        allowed_methods=["GET", "HEAD", "OPTIONS"],  # Only retry idempotent methods by default
        raise_on_status=False   # Let raise_for_status() handle this — do not raise inside retry
    )

    # HTTPAdapter wraps the retry strategy and sets connection pool limits
    # pool_maxsize prevents connection exhaustion under concurrent load
    http_adapter = HTTPAdapter(
        max_retries=retry_strategy,
        pool_connections=pool_connections,
        pool_maxsize=pool_maxsize
    )

    # Mount the adapter for both HTTPS and HTTP — always cover both prefixes
    session.mount("https://", http_adapter)
    session.mount("http://",  http_adapter)

    return session


# --- Usage ---
api_token = os.environ.get("WEATHER_API_KEY", "demo_key")
BASE_URL  = "https://api.open-meteo.com/v1"

session = build_resilient_session(api_token=api_token)

try:
    response = session.get(
        url=f"{BASE_URL}/forecast",
        params={
            "latitude": 51.5074,        # London
            "longitude": -0.1278,
            "current_weather": True,
            "wind_speed_unit": "kmh"
        },
        timeout=(3, 10)                 # Always set timeout even on sessions
        # Note: timeout is NOT persisted on the session itself — set it per-call
    )
    response.raise_for_status()

    weather_data = response.json()
    current      = weather_data["current_weather"]

    print(f"Location    : London, UK")
    print(f"Temperature : {current['temperature']} °C")
    print(f"Wind Speed  : {current['windspeed']} km/h")
    print(f"Wind Dir    : {current['winddirection']}°")
    print(f"Weather Code: {current['weathercode']}")
    print(f"\nResponse time: {response.elapsed.total_seconds():.3f}s")

except requests.exceptions.Timeout:
    # Raised when connect_timeout or read_timeout is exceeded
    # Do not retry here — the retry strategy handles that inside the session
    print("ERROR: The weather API did not respond in time. Try again in a few seconds.")

except requests.exceptions.ConnectionError:
    # DNS failure, refused connection, or network unreachable
    print("ERROR: Could not reach the weather API. Check network connectivity.")

except requests.exceptions.HTTPError as http_err:
    # Raised by raise_for_status() for 4xx and 5xx responses that exhausted retries
    print(f"ERROR: HTTP {http_err.response.status_code} — {http_err}")

finally:
    # Always close the session to release the underlying connection pool
    # Use as a context manager (with build_resilient_session(...) as session:) to handle this automatically
    session.close()
Output
Location : London, UK
Temperature : 14.2 °C
Wind Speed : 18.5 km/h
Wind Dir : 247°
Weather Code: 3
Response time: 0.312s
The Production HTTP Client Mental Model
  • Session reuses TCP connections via connection pooling — one TLS handshake per host, not one per request
  • Session persists headers and auth across all requests — set once, inherited everywhere, no repetition
  • HTTPAdapter + Retry handles transient failures automatically — no manual retry loops, correct exponential backoff
  • Timeout must still be set per-call — it is not a session-level setting. Every requests.get() and requests.post() call needs its own timeout argument
  • Bare requests.get() is for one-off scripts — anything running in a service under concurrent load should use Session with explicit pool limits
Production Insight
The timeout gap is the most common latent production bug in Python services that consume external APIs. Engineers set up the integration, test it, it works, it goes to production. Six months later a dependency has a bad deployment, response times spike from 300ms to 90 seconds, and the Python service falls over in a way that looks nothing like a timeout — it looks like a complete service failure with no error logs. Always set timeout. Always handle requests.exceptions.Timeout explicitly. Add alerting on timeout exception rates — a spike in timeouts is often your first warning that an upstream is degrading before it fully fails.
Key Takeaway
Session is the right foundation for any production HTTP client — connection pooling, shared headers, and retry logic all flow from it.
timeout=(connect, read) is not optional — requests.get() waits forever by default and a missing timeout is a production outage waiting to happen.
HTTPAdapter + Retry handles transient failures automatically — configure status_forcelist carefully and only include status codes that are actually safe to retry.
Session Configuration Decisions
IfMaking a single one-off request in a script
UseBare requests.get() with timeout is fine — no Session overhead needed for a single call
IfMaking multiple sequential requests to the same host
UseUse requests.Session() — reuses TCP connections and eliminates repeated header configuration
IfUpstream API is rate-limited or has occasional 5xx blips
UseMount HTTPAdapter with Retry strategy — automatic backoff on 429, 502, 503, 504 without writing retry loops
IfRunning in a concurrent environment with a thread pool or async workers
UseConfigure explicit pool_connections and pool_maxsize on HTTPAdapter — prevents file descriptor exhaustion under load. One session per thread or use thread-safe session handling.

Authentication Patterns — Basic Auth, Tokens, and OAuth in the Real World

Almost every API that does anything useful requires authentication. Understanding which auth pattern to use, why it exists, and how to implement it correctly is what separates an engineer who can follow an API quickstart from one who can build a production integration that stays secure and maintainable.

Basic Auth is the oldest pattern. Your username and password are combined as 'username:password', base64-encoded, and sent in every request as the Authorization header value. Requests handles this encoding for you when you pass auth=HTTPBasicAuth(username, password) or the equivalent tuple shorthand auth=(username, password). Basic Auth is simple and widely supported. Its weakness is that the password is transmitted with every request — if you're ever on HTTP instead of HTTPS, those credentials are readable by anyone on the network. Only use Basic Auth over HTTPS, and treat it as a legacy pattern for internal tools or simple APIs. Most serious APIs have moved away from it.

Bearer token authentication is the modern standard. You obtain a token once — usually at login or from an API key dashboard — and attach it to every subsequent request as 'Authorization: Bearer your_token_here'. The server validates the token without ever seeing your password again. The Session pattern makes this elegant: set the Authorization header once on the session and every request inherits it automatically. This is the pattern used by GitHub, Stripe, OpenAI, and most REST APIs built in the last several years.

OAuth 2.0 is the framework for apps acting on behalf of users. Instead of handling user passwords, your application redirects the user to the provider's login page, the user authenticates there, and the provider sends your app a token with specific scopes. Your app never sees the password. The requests-oauthlib library extends Requests with full OAuth 2.0 flow support. This is what you need for 'Sign in with Google', 'Connect with GitHub', Spotify API access, or any integration where users authorise your app to act on their accounts.

API key authentication varies more than the others. Some APIs want the key as a query parameter (?api_key=...). Others want it as a custom header (X-API-Key: ...). A few use the standard Authorization header with a custom scheme (ApiKey your_key_here). Always check the API documentation. As a general principle, prefer header-based API keys over query parameter keys — query parameters appear in server access logs, CDN logs, and browser history, which makes them a persistent credential exposure risk that is hard to detect and remediate.

auth_patterns_demo.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import requests
from requests.auth import HTTPBasicAuth
import os


# ─── PATTERN 1: Basic Auth ────────────────────────────────────────────────────
# Requests base64-encodes "username:password" and sets the Authorization header
# Use HTTPBasicAuth explicitly for clarity — the tuple shorthand also works:
# auth=("demo_user", "secret123") is identical to HTTPBasicAuth(...)

print("=== Pattern 1: Basic Auth ===")
basic_response = requests.get(
    url="https://httpbin.org/basic-auth/demo_user/secret123",
    auth=HTTPBasicAuth("demo_user", "secret123"),
    timeout=(3, 10)
)
basic_response.raise_for_status()
result = basic_response.json()
print(f"Authenticated : {result['authenticated']}")
print(f"User          : {result['user']}")
print(f"Auth header   : {basic_response.request.headers.get('Authorization')}")
# Output will show: Basic ZGVtb191c2VyOnNlY3JldDEyMw== (base64 of demo_user:secret123)


# ─── PATTERN 2: Bearer Token Auth via Session ─────────────────────────────────
# Set the token once on the session — every subsequent call inherits it
# No need to pass headers= on every individual request

print("\n=== Pattern 2: Bearer Token via Session ===")
api_token = os.environ.get("MY_API_TOKEN", "mock_token_for_demo_abc123")

token_session = requests.Session()
token_session.headers.update({
    "Authorization": f"Bearer {api_token}",
    "Accept": "application/json"
})

try:
    token_response = token_session.get(
        url="https://httpbin.org/bearer",
        timeout=(3, 10)
    )
    token_response.raise_for_status()
    result = token_response.json()
    print(f"Authenticated : {result['authenticated']}")
    print(f"Token received: {result['token']}")
finally:
    token_session.close()  # Release the connection pool


# ─── PATTERN 3: API Key as Custom Header ──────────────────────────────────────
# Some APIs (OpenAI uses Authorization: Bearer, Stripe uses Authorization: Bearer,
# but others use custom header names — always check the docs)
# Header-based keys are safer than query param keys — they stay out of server logs

print("\n=== Pattern 3: API Key as Custom Header ===")
api_key = os.environ.get("SERVICE_API_KEY", "sk-demo-key-for-testing-9999")

header_key_response = requests.get(
    url="https://httpbin.org/headers",
    headers={
        "X-API-Key": api_key,
        "Accept": "application/json"
    },
    timeout=(3, 10)
)
header_key_response.raise_for_status()

returned_headers = header_key_response.json()["headers"]
print(f"API Key received: {returned_headers.get('X-Api-Key', 'not found')}")
print("Key delivered via header — does not appear in URL or server access logs")


# ─── PATTERN 4: API Key as Query Parameter (avoid if header-based is available) ─
# Some older APIs only support query param auth — use it only if you have no choice
# Never log the full URL when using query-param API keys

print("\n=== Pattern 4: API Key as Query Param (legacy — avoid when possible) ===")
query_key_response = requests.get(
    url="https://httpbin.org/get",
    params={"api_key": api_key, "other_param": "value"},
    timeout=(3, 10)
)
query_key_response.raise_for_status()
print(f"Full URL (key visible): {query_key_response.url}")
print("WARNING: This URL — and the key in it — will appear in every server access log")
Output
=== Pattern 1: Basic Auth ===
Authenticated : True
User : demo_user
Auth header : Basic ZGVtb191c2VyOnNlY3JldDEyMw==
=== Pattern 2: Bearer Token via Session ===
Authenticated : True
Token received: mock_token_for_demo_abc123
=== Pattern 3: API Key as Custom Header ===
API Key received: sk-demo-key-for-testing-9999
Key delivered via header — does not appear in URL or server access logs
=== Pattern 4: API Key as Query Param (legacy — avoid when possible) ===
Full URL (key visible): https://httpbin.org/get?api_key=sk-demo-key-for-testing-9999&other_param=value
WARNING: This URL — and the key in it — will appear in every server access log
Watch Out:
Never hardcode API tokens, passwords, or any credentials directly in your source code. It does not matter if the repository is private — private repositories get cloned to laptops, laptops get stolen, access gets revoked inconsistently, and permissions change. Load secrets from environment variables using os.environ.get() or from a .env file using python-dotenv. Add .env to your .gitignore before your first commit, not after. If a token was ever hardcoded and committed — even for one commit, even in a 'private' repo — rotate it immediately. git history is permanent and full-history searches for credentials in leaked repos are automated.
Production Insight
Two credential exposure patterns appear repeatedly in security incident reports. The first is API keys in query parameters appearing in AWS CloudFront or nginx access logs, discovered months later during a log audit. The second is tokens hardcoded in Python scripts pushed to GitHub — automated scanners operated by threat actors find these within minutes of the push and begin making API calls. Both are preventable. Use environment variables for all secrets. Use header-based auth over query param auth whenever the API supports it. Audit your logs and your git history periodically — treat any credential in either place as compromised.
Key Takeaway
Bearer token auth via Session is the modern standard — set the Authorization header once on the session and every request inherits it automatically.
Never hardcode secrets in source code — use os.environ.get() or python-dotenv, and add .env to .gitignore before your first commit.
Header-based API keys are safer than query parameter keys — query params appear in server access logs, browser history, and CDN logs, creating a persistent credential exposure risk that is difficult to detect.
● Production incidentPOST-MORTEMseverity: high

Missing Timeout Cascades Into Full Service Outage

Symptom
Payment service stopped processing all transactions shortly after 11pm on a Tuesday. Health checks started failing. Kubernetes restarted the pods, but new pods became unresponsive within seconds of coming up. No error logs anywhere — the process was alive, memory usage was normal, CPU was flat. It looked fine from the outside and was doing absolutely nothing.
Assumption
The engineer who wrote the integration assumed the third-party payment gateway would always respond within a few seconds. It always had during development and load testing. No timeout was set on the requests.post() call because it had never been needed before.
Root cause
The payment gateway experienced a latency spike that pushed response times to around 90 seconds — caused by a misconfigured database connection pool on their end. Without a timeout set, every requests.post() call in the service blocked its thread for the full 90 seconds waiting for a response that was crawling back. The service ran with 200 worker threads. Within less than two minutes, all 200 were blocked mid-request. No threads remained to process incoming payments, respond to health check endpoints, or handle Kubernetes liveness probes. The pods were restarted but immediately consumed their thread pools on the backlog of queued requests. The service appeared alive — process running, no crashes, no exceptions — and was completely dead.
Fix
Added timeout=(3, 10) to every requests call in the payment service — three seconds to establish a connection, ten seconds to receive the full response. Added a connection pool cap of 50 concurrent outbound connections with a queue that returns HTTP 503 to callers instead of blocking indefinitely when the pool is full. Added a circuit breaker that stops calling the payment gateway entirely after five consecutive timeouts, returning a clear degraded-mode error to clients, and automatically retries the gateway every 30 seconds to check if it has recovered.
Key lesson
  • requests.get() and requests.post() wait forever by default — a missing timeout is a production time bomb, not a minor oversight
  • One slow upstream can consume your entire thread pool in seconds — connection pool limits are a required safeguard, not a premature optimisation
  • A process that is running but doing nothing is significantly harder to diagnose than a process that has crashed — add thread-level health metrics and alert on thread pool saturation, not just process liveness
  • Circuit breakers prevent cascade failures — once you know an upstream is failing, stop calling it immediately and fail fast rather than queuing more blocked threads
  • Load test with latency injection, not just load — a dependency that responds in 200ms under normal conditions and 90 seconds under stress will not reveal this class of failure in a happy-path load test
Production debug guideFrom silent failures to connection exhaustion — what to check first and why6 entries
Symptom · 01
Script hangs indefinitely on a requests call
Fix
Add timeout=(3, 10) to the request immediately. requests.get() and requests.post() wait forever without a timeout — there is no default. If this is happening in production, also check whether your thread pool or worker count is saturated, because one hanging call usually means many.
Symptom · 02
Server returns 400 Bad Request on POST but the payload looks correct when you print it
Fix
Print response.request.headers and check the Content-Type value. If it says application/x-www-form-urlencoded, you used data=json.dumps() instead of json=. The body bytes are identical — the Content-Type is what breaks it. Switch to json=payload and the 400 will disappear.
Symptom · 03
response.json() raises ValueError or returns data that does not match what the API docs describe
Fix
Print response.status_code and response.text before calling .json(). You are almost certainly parsing an error page — a 404 HTML document or a 500 JSON error envelope — as if it were a successful response body. Add raise_for_status() before every .json() call.
Symptom · 04
SSL certificate verification fails with SSLError or CERTIFICATE_VERIFY_FAILED
Fix
Do not set verify=False in production — that disables all TLS validation and opens you to man-in-the-middle attacks. Check whether the server uses a self-signed or private CA certificate. If so, use verify='/path/to/ca-bundle.pem' to point Requests at the correct certificate authority. verify=False is only acceptable on a local dev environment with no sensitive data in transit.
Symptom · 05
Too many open files error, OSError, or connection exhaustion under concurrent load
Fix
You are almost certainly using bare requests.get() in a loop or thread pool. Each call opens a new TCP connection and new file descriptor. Switch to requests.Session() and configure HTTPAdapter with explicit pool_connections and pool_maxsize values. Check current open file descriptors with lsof -p <pid> | grep TCP | wc -l.
Symptom · 06
Intermittent 429 Too Many Requests responses from an upstream API
Fix
Mount an HTTPAdapter with Retry(total=3, backoff_factor=1, status_forcelist=[429]) on your session. This creates automatic exponential backoff — waits 1s, then 2s, then 4s between retries. Also check the Retry-After header in the 429 response — some APIs tell you the exact wait time. Honour it. Ignoring Retry-After will get your IP blocked.
★ Requests Quick Debug Cheat SheetFast diagnostics for the most common HTTP request failures. Run these in your terminal — they give you a clear answer in under 30 seconds.
Script hangs on HTTP call with no output and no error
Immediate action
Add a timeout parameter to the request — you almost certainly do not have one
Commands
python -c "import requests; r=requests.get('https://httpbin.org/delay/30', timeout=3); print(r.status_code)"
timeout 5 python -c "import requests; requests.get('https://httpbin.org/delay/30')"
Fix now
Add timeout=(3, 10) to every requests call in your codebase — search for requests.get and requests.post with no timeout argument and treat each one as a bug
POST returns 400 with a payload that looks correct when printed+
Immediate action
Inspect the Content-Type header that Requests actually sent — not the one you think it sent
Commands
python -c "import requests; r=requests.post('https://httpbin.org/post', json={'key':'val'}); print(r.request.headers.get('Content-Type'))"
python -c "import requests, json; r=requests.post('https://httpbin.org/post', data=json.dumps({'key':'val'})); print(r.request.headers.get('Content-Type'))"
Fix now
The first command prints application/json. The second prints application/x-www-form-urlencoded. If you see the second in production, switch to json=payload immediately.
Connection pool exhaustion — too many open files or socket errors under concurrent load+
Immediate action
Check whether you are using bare requests.get() in a thread pool or loop — each call opens its own TCP connection
Commands
python -c "import requests; s=requests.Session(); print(s.adapters)"
lsof -p $(pgrep -f your_script.py) | grep TCP | wc -l
Fix now
Switch to requests.Session() and mount an HTTPAdapter with pool_connections=10 and pool_maxsize=50. The second command shows you the current open TCP connection count — if it's climbing linearly with your request count, you have a pooling problem.
Intermittent 502 or 503 errors from upstream API — not consistent, just occasional+
Immediate action
Add a retry strategy to your session — these are transient errors that are explicitly designed to be retried
Commands
python -c "from urllib3.util.retry import Retry; print(Retry.DEFAULT_BACKOFF_MAX)"
python -c "import requests; from requests.adapters import HTTPAdapter; from urllib3.util.retry import Retry; s=requests.Session(); s.mount('https://', HTTPAdapter(max_retries=Retry(3, backoff_factor=1))); print('Retry configured on session')"
Fix now
Mount HTTPAdapter with Retry(total=3, backoff_factor=1, status_forcelist=[502, 503, 504]) on both https:// and http:// prefixes. The backoff_factor=1 creates waits of 1s, 2s, 4s between retries — enough for most transient failures to clear.
Bare requests.get/post vs requests.Session
Feature / Aspectrequests.get/post (bare calls)requests.Session
TCP connection per requestNew connection opened and closed every time — one TLS handshake per callReuses existing pooled connections — one handshake per host, amortised across many requests
Shared headers and authMust be passed explicitly on every individual call — easy to miss oneSet once on the session, inherited automatically by every request
Cookie persistenceCookies are not carried between calls — each is a stateless transactionCookies are automatically stored and sent on subsequent calls to the same host
Retry logicMust write your own retry loop — usually done incorrectly or not at allMount HTTPAdapter with Retry strategy — handles backoff, status codes, and method filtering automatically
Connection pool limitsNo pool — each call gets its own file descriptor and portpool_connections and pool_maxsize on HTTPAdapter cap resource usage explicitly
Best use caseOne-off scripts, local testing, single API calls with no follow-up requestsProduction services, multi-step workflows, any code making 3+ requests to the same host
Performance under loadDegrades linearly — more requests means more handshakes means more latencyScales cleanly — connection pooling absorbs repeated calls to the same host

Key takeaways

1
Always call response.raise_for_status() before accessing response data
Requests does not raise exceptions on 404 or 500 by default. Skipping this call means your code will silently process error responses as valid data, which causes downstream bugs that surface far from the original API call and are hard to trace.
2
Use json=payload, not data=json.dumps(payload)
the json= parameter handles serialisation and sets the Content-Type header in a single argument. The data= version sends the right body bytes but the wrong Content-Type, which causes most REST APIs to return a 400 with no useful diagnostic information.
3
Use requests.Session() for any production code making multiple requests to the same host
it reuses TCP connections via connection pooling, persists auth headers and cookies across all calls, and is the correct foundation for mounting retry strategies. Bare requests.get() is for one-off scripts.
4
Timeouts are non-negotiable in production
requests.get() waits forever by default. Always set timeout=(3, 10) or similar values appropriate to your upstream, and always handle requests.exceptions.Timeout explicitly. A single missing timeout in a concurrent service can cause a complete outage when an upstream degrades.

Common mistakes to avoid

5 patterns
×

Not setting a timeout on HTTP requests

Symptom
Script or service hangs indefinitely when a server is slow, overloaded, or silently dropping connections. In concurrent services, one hanging call ties up a worker thread. Enough hanging calls exhaust the thread pool and the entire service stops processing requests — with no error logs, because the threads are alive and waiting, not crashed.
Fix
Add timeout=(connect_seconds, read_seconds) to every request call — for example, timeout=(3, 10). The first value caps the connection establishment wait. The second caps the wait between bytes received. Treat a missing timeout as a bug in code review, not a style preference. In production services, also monitor for Timeout exceptions — a spike in timeout rates is often your first signal that an upstream is degrading.
×

Using data=json.dumps(payload) instead of json=payload for JSON APIs

Symptom
Server returns 400 Bad Request or silently ignores the request body. No helpful error message — the server sees JSON bytes arriving with Content-Type: application/x-www-form-urlencoded and rejects the parse. Printing the body shows correct JSON. The bug is invisible until you print response.request.headers.
Fix
Use the json= parameter exclusively for JSON APIs. It serialises your dict to a JSON string AND sets Content-Type: application/json in a single argument. If you find yourself writing data=json.dumps(), stop and switch to json=. Search your codebase for this pattern and treat every instance as a bug.
×

Trusting response data without checking the HTTP status code

Symptom
Code processes a 404 HTML page or a 500 JSON error envelope as valid data. This causes a ValueError when .json() encounters HTML, or — worse — silently inserts malformed data into a database because the error response had a valid JSON structure with fields that sort-of matched what the code expected. These bugs surface far from the original API call and are painful to trace.
Fix
Call response.raise_for_status() immediately after every request and before accessing the body. This raises requests.exceptions.HTTPError with the status code and URL for any 4xx or 5xx response. Catch it explicitly where you can handle it meaningfully. Do not suppress it silently with a bare except.
×

Hardcoding API tokens, passwords, or secrets directly in source code

Symptom
Credentials appear in git history — permanently, even after deletion. Automated scanners on GitHub detect exposed tokens within minutes of a push and begin making API calls or selling the credentials. Private repositories are not safe — they get cloned, shared, and accessed by people who should not have the credentials.
Fix
Load all secrets from environment variables using os.environ.get() or from a .env file using python-dotenv. Add .env to .gitignore before the first commit. If any credential was ever hardcoded in a commit — even once, even in a 'private' repository — rotate it immediately. Treat it as compromised regardless of whether you see evidence of misuse.
×

Using bare requests.get() in production code that makes multiple requests

Symptom
Under load, performance degrades faster than expected. File descriptor errors or 'too many open files' errors appear under concurrent usage. Each bare requests.get() call opens a new socket and performs a full TLS handshake. In thread pools, this creates a new connection per thread per call, which exhausts ephemeral ports and file descriptor limits on systems under sustained load.
Fix
Use requests.Session() for any production code making more than one request to the same host. Configure HTTPAdapter with explicit pool_connections and pool_maxsize values to cap resource usage. As a rule: if the code runs in a service rather than a script, it should use a Session.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What's the difference between using requests.get() directly and using a ...
Q02SENIOR
How would you implement retry logic for a flaky third-party API in Pytho...
Q03SENIOR
If a POST request to a REST API returns a 200 OK but the body contains a...
Q01 of 03SENIOR

What's the difference between using requests.get() directly and using a requests.Session(), and when would you choose one over the other in a production system?

ANSWER
Bare requests.get() opens a fresh TCP connection for every call, does not persist headers, cookies, or auth between calls, and has no built-in retry support. It is the right tool for a one-off script making a single request. requests.Session() maintains a connection pool — it reuses TCP connections to the same host, which eliminates repeated TLS handshakes and is measurably faster for any code making multiple requests. Sessions also persist headers, cookies, and auth across all calls, so you configure them once and stop repeating yourself. And Sessions support mounting HTTPAdapter with a Retry strategy, which handles transient failures automatically without you writing retry loops. In a production service — anything running as a long-lived process under load — I would always use a Session. The performance improvement is real, the code is cleaner because auth is configured in one place, and the retry support is the difference between a service that handles transient upstream failures gracefully and one that needs a support ticket every time an upstream has a bad minute.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
How do I send JSON data in a POST request using Python Requests?
02
What is the difference between requests.get() and requests.Session().get()?
03
Why does my Python Requests code hang and never finish?
04
How do I handle rate limiting (HTTP 429) gracefully with Python Requests?
05
Is it safe to use verify=False to disable SSL certificate verification?
🔥

That's Python Libraries. Mark it forged?

8 min read · try the examples if you haven't

Previous
Seaborn for Data Visualisation
7 / 51 · Python Libraries
Next
Flask Web Framework Basics