Skip to content
Home Python Python Requests Library Explained — HTTP, APIs and Real-World Patterns

Python Requests Library Explained — HTTP, APIs and Real-World Patterns

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Python Libraries → Topic 7 of 51
Master the Python Requests library: GET, POST, headers, auth, sessions, error handling and real API patterns — with runnable code and common mistakes explained.
⚙️ Intermediate — basic Python knowledge assumed
In this tutorial, you'll learn
Master the Python Requests library: GET, POST, headers, auth, sessions, error handling and real API patterns — with runnable code and common mistakes explained.
  • 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.
  • 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.
  • 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.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
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
🚨 START HERE
Requests Quick Debug Cheat Sheet
Fast 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 ActionAdd 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 NowAdd 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 ActionInspect 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 NowThe 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 ActionCheck 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 NowSwitch 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 ActionAdd 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 NowMount 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.
Production IncidentMissing Timeout Cascades Into Full Service OutageA payment processing service hung indefinitely on a single slow API call, blocking all 200 worker threads and causing a complete outage that lasted 47 minutes.
SymptomPayment 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.
AssumptionThe 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 causeThe 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.
FixAdded 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 oversightOne slow upstream can consume your entire thread pool in seconds — connection pool limits are a required safeguard, not a premature optimisationA 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 livenessCircuit breakers prevent cascade failures — once you know an upstream is failing, stop calling it immediately and fail fast rather than queuing more blocked threadsLoad 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 why
Script hangs indefinitely on a requests callAdd 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.
Server returns 400 Bad Request on POST but the payload looks correct when you print itPrint 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.
response.json() raises ValueError or returns data that does not match what the API docs describePrint 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.
SSL certificate verification fails with SSLError or CERTIFICATE_VERIFY_FAILEDDo 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.
Too many open files error, OSError, or connection exhaustion under concurrent loadYou 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.
Intermittent 429 Too Many Requests responses from an upstream APIMount 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.

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.py · PYTHON
123456789101112131415161718192021222324252627282930
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.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
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.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
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
Mental Model
The Production HTTP Client Mental Model
Session rule: configure once, call many — headers, auth, retries, and connection pooling all flow from a single setup.
  • 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.py · PYTHON
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
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.
🗂 Bare requests.get/post vs requests.Session
When to use each approach — and why the difference matters in production
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

  • 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.
  • 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.
  • 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.
  • 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

    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 Questions on This Topic

  • QWhat'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?Mid-levelReveal
    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.
  • QHow would you implement retry logic for a flaky third-party API in Python, and what HTTP status codes should trigger a retry versus an immediate failure?Mid-levelReveal
    I'd use requests.Session() with an HTTPAdapter mounted with a urllib3 Retry strategy. The configuration that matters: total=3 for retry count, backoff_factor=1 for exponential backoff (producing waits of 2s, 4s, 8s between retries), and status_forcelist restricted to [429, 502, 503, 504]. Those four codes represent transient infrastructure failures — rate limiting, bad gateways, unavailable services, and gateway timeouts — that may legitimately resolve on retry. I would explicitly exclude 400, 401, 403, 404, and 422. Those are permanent errors. A 401 will not become a 200 on retry. Retrying them wastes time, generates log noise, and delays surfacing the real problem. I'd also restrict retries to idempotent methods — GET, HEAD, OPTIONS — unless I'm certain a POST is safe to retry. A POST to create a record is not safe to retry without idempotency guarantees, because you may create duplicates. The whole approach means the retry logic lives in the session configuration, not scattered across individual call sites.
  • QIf a POST request to a REST API returns a 200 OK but the body contains an error message like {'status': 'error', 'message': 'Invalid token'} — does raise_for_status() catch that? How would you handle it?SeniorReveal
    No, raise_for_status() does not catch that. It only inspects the HTTP status code. A 200 with an error body is a successful HTTP transaction — the server responded correctly and told you something went wrong in the application layer. raise_for_status() has no visibility into that distinction. To handle it, you parse the body and validate it at the application level. The pattern I use: after raise_for_status() clears the HTTP layer, parse the JSON, then check whatever field the API uses to signal success or failure — often 'status', 'error', 'success', or 'code'. If it indicates failure, raise an application-level exception with a meaningful message that includes the error field from the body. The deeper issue here is API design — an API that returns 200 for application errors is conflating HTTP transport with application logic, which forces every client to implement two layers of error checking. When I own the API, I use proper 4xx and 5xx status codes for errors. When integrating with a third-party API that does this, I wrap the calls in a function that handles both layers and presents a clean interface to the rest of my code.

Frequently Asked Questions

How do I send JSON data in a POST request using Python Requests?

Pass your Python dictionary to the json= parameter: requests.post(url, json=your_dict). Requests serialises the dictionary to a JSON string and sets Content-Type: application/json automatically. Do not use data=json.dumps(your_dict) — that sends the correct JSON bytes but sets Content-Type to application/x-www-form-urlencoded, which causes most REST APIs to reject the request with a 400. The json= parameter is one argument that eliminates an entire class of Content-Type mismatch bugs.

What is the difference between requests.get() and requests.Session().get()?

Both make a GET request, but Session().get() reuses the underlying TCP connection across multiple calls through connection pooling — no repeated TLS handshakes, which is meaningfully faster under real workloads. Sessions also automatically persist headers, cookies, and authentication settings across all requests, so you configure auth once and stop repeating it on every call. For a single one-off request in a script, bare requests.get() is perfectly fine. For production code making multiple requests, or any code running in a service under concurrent load, always use a Session.

Why does my Python Requests code hang and never finish?

You almost certainly have not set a timeout. requests.get() and requests.post() wait indefinitely by default — there is no built-in limit. If the server is slow, overloaded, or silently dropping connections, your script blocks forever. Fix it by adding timeout=(connect_timeout, read_timeout) to every request — for example, timeout=(3, 10). This tells Requests to raise requests.exceptions.Timeout if the connection takes more than 3 seconds to establish or the response body takes more than 10 seconds to arrive. Catch that exception explicitly and handle it — do not let it propagate silently.

How do I handle rate limiting (HTTP 429) gracefully with Python Requests?

Mount an HTTPAdapter with a Retry strategy on your Session, configured with status_forcelist=[429] and backoff_factor=1. This creates automatic exponential backoff — 2 seconds before the first retry, 4 before the second, 8 before the third — without you writing a retry loop. Additionally, check the Retry-After header in the 429 response body. Many APIs include the exact number of seconds to wait before retrying. If present, honour it — a well-behaved client respects Retry-After rather than hammering with exponential backoff, which shows up in the API provider's abuse detection systems and can get your key rate-limited more aggressively.

Is it safe to use verify=False to disable SSL certificate verification?

Never in production. Setting verify=False disables all TLS certificate validation — your code will accept any certificate from any server, including certificates presented by a man-in-the-middle attacker on the same network. It also suppresses the urllib3 InsecureRequestWarning, which means the vulnerability becomes invisible in logs. For local development with a self-signed certificate, use verify='/path/to/ca-cert.pem' to point Requests at the specific CA certificate. For production, leave verify=True (the default) and ensure your system's CA bundle is current. If an internal service uses a private CA, distribute the CA certificate and reference it explicitly.

🔥
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousSeaborn for Data VisualisationNext →Flask Web Framework Basics
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged