Python requests

PYTHON Published Updated 5 mins read By Leon Wei
Python requests cover image

Quick summary

Summarize this blog with AI

A reliable Requests call defines what success means before parsing the response: the expected HTTP status, media type, maximum body size, and data shape. It also supplies connection and read timeouts. This tutorial builds a small JSON client and exercises it against a local server, so the examples do not depend on a public API being available.

Tested with Python 3.12.14 and Requests 2.34.2. Run the blocks in order. The server serves synthetic responses on loopback only; no credentials or paid services are involved.

Read a bounded JSON response through a Session

A Session can reuse connections and defaults across calls. The function below makes one GET attempt, rejects redirects, validates the status and media type, and limits streamed bytes before decoding JSON. It does not turn network or parsing failures into an empty success result.

import json
import requests
from urllib.parse import urlsplit

def get_json(session, url, params=None, max_bytes=100_000, timeout=(5, 15)):
    parts = urlsplit(url)
    local_http = parts.scheme == 'http' and parts.hostname == '127.0.0.1'
    if parts.scheme != 'https' and not local_http:
        raise ValueError('use HTTPS except for a loopback test fixture')
    if parts.username or parts.password or not parts.hostname:
        raise ValueError('URL must have a host and no embedded credentials')
    if type(max_bytes) is not int or max_bytes <= 0:
        raise ValueError('max_bytes must be a positive integer')
    with session.get(url, params=params, timeout=timeout, stream=True,
                     allow_redirects=False,
                     headers={'Accept': 'application/json', 'Accept-Encoding': 'identity'}) as response:
        response.raise_for_status()
        if response.status_code != 200:
            raise ValueError('expected HTTP 200, not a redirect or empty response')
        media_type = response.headers.get('Content-Type', '').split(';', 1)[0].lower()
        if media_type != 'application/json' and not media_type.endswith('+json'):
            raise ValueError('expected a JSON media type')
        if response.headers.get('Content-Encoding', 'identity').lower() != 'identity':
            raise ValueError('compressed responses are not accepted by this helper')
        declared = response.headers.get('Content-Length')
        if declared is not None and not 0 <= int(declared) <= max_bytes:
            raise ValueError('declared response size exceeds the limit')
        body = bytearray()
        for chunk in response.iter_content(chunk_size=16384):
            if len(body) + len(chunk) > max_bytes:
                raise ValueError('response exceeds the byte limit')
            body.extend(chunk)
    return json.loads(bytes(body))

def item_names(payload):
    if not isinstance(payload, dict) or not isinstance(payload.get('items'), list):
        raise ValueError('expected an object containing an items list')
    names = []
    for item in payload['items']:
        if not isinstance(item, dict) or not isinstance(item.get('name'), str):
            raise ValueError('each item needs a text name')
        names.append(item['name'])
    return names

JSON decoding proves only that the body is parseable JSON. The separate item_names validator checks the part of the application schema this example consumes. It accepts an empty items list but rejects a missing field or non-text name. Real contracts may require identifiers, uniqueness, ranges, or additional fields.

Exercise success, malformed JSON and HTTP failure locally

from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
from urllib.parse import urlsplit

@contextmanager
def local_api():
    hits = {'transient': 0}
    class Handler(BaseHTTPRequestHandler):
        def do_GET(self):
            path = urlsplit(self.path).path
            status, body = 200, b'{"items":[{"name":"Ada"},{"name":"Ben"}]}'
            if path == '/missing':
                status, body = 404, b'{"error":"missing"}'
            elif path == '/bad-json':
                body = b'not JSON'
            elif path == '/transient':
                hits['transient'] += 1
                if hits['transient'] == 1:
                    status, body = 503, b'{"error":"try later"}'
            self.send_response(status)
            self.send_header('Content-Type', 'application/json')
            self.send_header('Content-Length', str(len(body)))
            self.end_headers()
            self.wfile.write(body)
        def log_message(self, *args):
            pass
    server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
    thread = Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        yield f'http://127.0.0.1:{server.server_port}', hits
    finally:
        server.shutdown()
        server.server_close()
        thread.join()

with local_api() as (base_url, hits), requests.Session() as session:
    session.trust_env = False
    payload = get_json(session, base_url + '/items', params={'limit': 2})
    print(item_names(payload))
    for path in ('/missing', '/bad-json'):
        try:
            get_json(session, base_url + path)
        except (requests.HTTPError, json.JSONDecodeError) as exc:
            print(type(exc).__name__)

Expected output:

['Ada', 'Ben']
HTTPError
JSONDecodeError

The fixture's limit parameter is only a demonstration of query encoding; this tiny server always returns two records. Real APIs define pagination and filtering rules themselves. Use params to encode query values, and use HTTPS for remote endpoints. The local server's plain HTTP is confined to 127.0.0.1.

Retry only a deliberately selected GET failure

This wrapper makes at most three total attempts and retries only HTTP 503 responses without Retry-After. It does not retry a malformed body, a 404, a timeout, or any POST. A Retry-After response is passed back to the caller so it can honor the service's delay rather than guessing. This intentionally small policy is easy to test and adapt to a documented API.

import time

def get_json_with_retry(session, url):
    for attempt in range(3):
        try:
            return get_json(session, url)
        except requests.HTTPError as exc:
            response = exc.response
            retryable = (
                response is not None and response.status_code == 503
                and 'Retry-After' not in response.headers
            )
            if not retryable or attempt == 2:
                raise
            time.sleep(0.1 * (2 ** attempt))
    raise RuntimeError('unreachable retry state')

with local_api() as (base_url, hits), requests.Session() as session:
    session.trust_env = False
    print(item_names(get_json_with_retry(session, base_url + '/transient')))
    print(hits['transient'])

Expected output:

['Ada', 'Ben']
2

Retrying even a GET assumes the endpoint behaves as a read operation. For state-changing requests, a timeout can mean the server completed the action but the client missed its response. Do not automatically resubmit a payment, message or job creation. Use the API's documented idempotency mechanism and reconciliation behavior when available.

Prepare JSON explicitly for a state-changing request

The following prepares a request without sending it. Passing json rather than data sets a JSON request body and media type. An actual send must add the endpoint's authentication, timeout, status and reconciliation contract.

prepared = requests.Request(
    'POST', 'https://example.invalid/jobs', json={'job': 'daily-report'}
).prepare()
print(prepared.method)
print(prepared.headers['Content-Type'])
print(json.loads(prepared.body)['job'])

Expected output:

POST
application/json
daily-report

Make failure and resource ownership explicit

The response and Session context managers close their resources on success or failure. The fixture disables ambient proxy and netrc settings; a real deployment should configure any required proxy and credentials intentionally. Keep TLS verification enabled, avoid tokens in URLs, and do not log authorization headers.

A Requests timeout is a connection/read-inactivity rule, not a total elapsed deadline. Streaming read failures can also surface as ConnectionError or ChunkedEncodingError, so preserve the actual exception and job status. The tutorial's validation includes timeout, truncated body, oversized body, unexpected media type, persistent 503 and retry-limit cases.

For atomic file downloads, continue with download a file safely. Primary references: Requests response and error handling, Sessions and streaming, and HTTP idempotent methods.

Interview Prep

Begin Your SQL, Python, and R Journey

Master 230 interview-style coding questions and build the data skills needed for analyst, scientist, and engineering roles.

Related Articles

All Articles
Python zipfile cover image
python Oct 17, 2026

Python zipfile

Learn how to create, read, extract, and modify ZIP archives with practical examples, by using this built-in standard library tool Python's zipfi…

Python formatted output cover image
python Apr 29, 2024

Python formatted output

Explore Python's formatted output techniques to create readable output and present data clearly and professionally, from basic methods to advanc…