Filter concepts by levelShowing all levels.

Python · Networking and HTTP Clients

Networking and HTTP Clients

Choosing between requests, httpx, and aiohttp, and configuring one for pooling, timeouts, retries, auth, streaming, and concurrency — verified against live endpoints

  • requests vs. httpx vs. aiohttpsync-only, sync+async, or async-only — pick by need
  • Timeouts + retriesa client with no timeout can hang forever on a slow server
  • gather() concurrencytotal time ≈ the slowest call, not the sum of all of them
  • verify= over verify=Falsetrust a specific CA bundle instead of disabling TLS checks

1.requests, httpx, and aiohttp

requests is the classic synchronous HTTP client — one blocking call at a time, the simplest API. httpx is a newer client with the same requests-like API but adds native async support (AsyncClient) and HTTP/2. aiohttp is async-only, built around its own event loop, and is also usable as a server framework.

Key features

  • requests has no built-in async client — every call blocks until the response arrives
  • httpx.Client is synchronous like requests; httpx.AsyncClient is its async twin, same method names
  • aiohttp.ClientSession is async-only — there is no synchronous aiohttp client
  • requests is the most widely installed of the three and has the largest plugin/adapter ecosystem
  • httpx supports HTTP/2 (http2=True); requests and aiohttp are HTTP/1.1 only

Choosing a client

Choosing a client
LibrarySyncAsyncHTTP/2Best fit
requestsYesNoNoScripts, sync codebases, maximum ecosystem/plugin support
httpxYesYes (AsyncClient)YesNew projects wanting one API for both sync and async
aiohttpNoYes (ClientSession)NoExisting asyncio codebases, especially if also serving HTTP
Sync-only vs. sync-and-async vs. async-only

requests

  • Synchronous only — every call blocks
  • requests.get(url), response.json()
  • Largest ecosystem of adapters/plugins

httpx / aiohttp

  • httpx: one API, both httpx.Client (sync) and httpx.AsyncClient (async)
  • aiohttp: async-only, built on asyncio, also usable as a server
  • Both needed for concurrent HTTP calls without threads
  • requests
    • Synchronous only — every call blocks
    • requests.get(url), response.json()
    • Largest ecosystem of adapters/plugins
  • httpx / aiohttp
    • httpx: one API, both httpx.Client (sync) and httpx.AsyncClient (async)
    • aiohttp: async-only, built on asyncio, also usable as a server
    • Both needed for concurrent HTTP calls without threads

Command

python
import requests
r = requests.get(url)              # sync, blocks

import httpx
r = httpx.get(url)                 # sync, requests-like API
async with httpx.AsyncClient() as c:
    r = await c.get(url)           # async, same method names

import aiohttp
async with aiohttp.ClientSession() as s:
    async with s.get(url) as r:    # async, response is a context manager
        data = await r.json()

requests is sync-only with the biggest ecosystem; httpx gives one API for both sync and async plus HTTP/2; aiohttp is async-only and often doubles as a server.

2.Building a resilient client: pooling, timeouts, retries

A resilient HTTP client reuses one Session/Client object across calls (connection pooling), sets an explicit timeout so a hung server cannot block forever, and retries a failed request automatically instead of giving up after one attempt.

Key features

  • Creating a new requests.Session()/httpx.Client() per call defeats pooling — construct one and reuse it across every request
  • timeout=N in requests/httpx applies to the connect AND read phases separately unless split as timeout=(connect, read)
  • A request with no timeout= can hang indefinitely — requests defaults to no timeout at all if you do not pass one
  • requests' retry logic lives on the transport adapter (urllib3.util.retry.Retry), mounted via HTTPAdapter, not a client kwarg
  • httpx's built-in retries= (on HTTPTransport) only retries connection-level failures, never a 4xx/5xx status — status-code retries need custom code

Configuring pooling, timeouts, and retries per client

Configuring pooling, timeouts, and retries per client
Concernrequestshttpxaiohttp
Poolingrequests.Session() reused across callshttpx.Client() reused; httpx.Limits(max_connections=..., max_keepalive_connections=...)aiohttp.TCPConnector(limit=..., limit_per_host=...)
Timeoutrequests.get(url, timeout=5) or (connect, read) tuplehttpx.Client(timeout=httpx.Timeout(5.0))aiohttp.ClientTimeout(total=5, connect=..., sock_read=...)
RetriesHTTPAdapter(max_retries=Retry(total=3, status_forcelist=[502,503,504]))httpx.HTTPTransport(retries=N) — connection errors onlyno built-in retry — wrap calls yourself or use a third-party helper
A pooled client under a slow, flaky server
yes, noretry leftno (or 5xx)yes — backoff,retryno2xx

One reused Session

built once, not per call

Request attempt

Past timeout?

Retries left?

Raise (Timeout / RetryError)

Response returned

  • One reused Session — built once, not per call
    • leads to Request attempt
  • Request attempt
    • leads to Past timeout?
    • leads to Response returned (2xx)
  • Past timeout?
    • on error, leads to Raise (Timeout / RetryError) (yes, no retry left)
    • leads to Retries left? (no (or 5xx))
  • Retries left?
    • leads to Request attempt (yes — backoff, retry)
    • on error, leads to Raise (Timeout / RetryError) (no)
  • Raise (Timeout / RetryError)
  • Response returned

Command

python
# requests — pool via Session, bound with timeout, retry via adapter
with requests.Session() as session:
    session.mount('https://', HTTPAdapter(max_retries=Retry(total=3, status_forcelist=[502, 503, 504])))
    r = session.get(url, timeout=(3.05, 10))   # (connect timeout, read timeout)

# httpx — pool via Client + Limits, timeout via Timeout(), retries via transport
limits = httpx.Limits(max_connections=100, max_keepalive_connections=20)
with httpx.Client(limits=limits, timeout=httpx.Timeout(10.0, connect=3.05)) as client:
    r = client.get(url)

# aiohttp — pool via TCPConnector, timeout via ClientTimeout
timeout = aiohttp.ClientTimeout(total=10, connect=3.05)
connector = aiohttp.TCPConnector(limit=100, limit_per_host=10)
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
    async with session.get(url) as r:
        ...

Build one Session/Client and reuse it for pooling; always pass an explicit timeout; retries are a transport-level adapter, not automatic, and httpx's built-in retries= skips status codes entirely.

3.Authentication and headers

Authentication in an HTTP client is almost always a header: Authorization: Bearer <token> for token auth, or Authorization: Basic <base64> for username/password — sent via headers={...} directly, or via a library auth= helper that builds the same header for you.

Key features

  • Bearer token auth is a plain header: headers={'Authorization': f'Bearer {token}'} — no special client support required
  • requests' auth=(username, password) tuple sends HTTP Basic auth automatically; httpx uses auth=httpx.BasicAuth(username, password)
  • A custom auth scheme can subclass requests.auth.AuthBase (or pass a callable to httpx's auth=) to inject headers on every request via one client
  • Setting default headers once on a Session/Client (session.headers.update({...})) applies them to every request made through it
  • Never put a token in the URL query string — it lands in access logs, browser history, and proxy logs; the Authorization header does not

Sending auth and default headers per client

Sending auth and default headers per client
Goalrequestshttpx
Bearer token, one requestheaders={'Authorization': f'Bearer {token}'}headers={'Authorization': f'Bearer {token}'}
Basic auth, one requestauth=(username, password)auth=httpx.BasicAuth(username, password)
Default header for every requestsession.headers.update({...})client = httpx.Client(headers={...})
Custom auth scheme reused everywheresubclass requests.auth.AuthBase, pass instance to auth=pass a callable(request) to auth=, or subclass httpx.Auth

Command

python
# Bearer token -- a plain header, works identically on any client
headers = {'Authorization': f'Bearer {token}'}

# Basic auth -- requests
requests.get(url, auth=(username, password))

# Basic auth -- httpx
httpx.get(url, auth=httpx.BasicAuth(username, password))

# apply to every request through one reused client
client = httpx.Client(headers={'Authorization': f'Bearer {token}'})

Authentication is a header: build it with headers={'Authorization': ...} directly, or auth=(...) / auth=httpx.BasicAuth(...) for Basic — set it once via session.headers.update()/httpx.Client(headers=...) to apply to every call.

4.Streaming responses

Streaming reads a response body in chunks as it arrives, instead of loading the whole thing into memory at once — required for a large file, and enabled with stream=True (requests) or a client.stream() context manager (httpx/aiohttp).

Key features

  • requests: pass stream=True to get(), then iterate response.iter_content(chunk_size=N) — the body is NOT downloaded until you iterate
  • httpx: use the httpx.stream(method, url) or client.stream(...) context manager, then response.iter_bytes(N) or .iter_text(N)
  • aiohttp: iterate response.content.iter_chunked(N) inside async for — .content is a StreamReader, read incrementally by default
  • Forgetting to close a streamed response (or not using it as a context manager) leaves the underlying connection open, blocking pool reuse
  • response.json() or .text on a streamed response force-reads the WHOLE body first — they defeat the point of streaming

Streaming a response body per client

Streaming a response body per client
LibraryEnable streamingRead chunks
requestsrequests.get(url, stream=True)for chunk in response.iter_content(chunk_size=8192): ...
httpxwith httpx.stream("GET", url) as response:for chunk in response.iter_bytes(8192): ...
aiohttpasync with session.get(url) as response:async for chunk in response.content.iter_chunked(8192): ...
Whole-body read vs. streamed read

Server sends body

arrives over the wire in pieces either way

stream=True / .stream()

chunks handed to your code as they arrive

Constant memory

never holds more than one chunk at a time

  1. Server sends body — arrives over the wire in pieces either way
  2. stream=True / .stream() — chunks handed to your code as they arrive
  3. Constant memory — never holds more than one chunk at a time

Command

python
with requests.get(url, stream=True) as r:
    for chunk in r.iter_content(chunk_size=8192):
        f.write(chunk)

with httpx.stream('GET', url) as r:
    for chunk in r.iter_bytes(8192):
        f.write(chunk)

async with session.get(url) as r:
    async for chunk in r.content.iter_chunked(8192):
        f.write(chunk)

stream=True (requests) or client.stream()/httpx.stream() (httpx) or the default .content.iter_chunked() (aiohttp) reads a body in bounded chunks — always use a with block so the connection is released even on an early break.

5.Async HTTP clients

httpx.AsyncClient (or aiohttp.ClientSession) lets several HTTP requests run concurrently instead of one after another — asyncio.gather() starts them all, then waits for whichever finishes, in whatever order that happens.

Key features

  • asyncio.gather(*(client.get(u) for u in urls)) starts every request before waiting on any of them
  • Reuse one httpx.AsyncClient (or aiohttp.ClientSession) across all the concurrent calls — a fresh client per call adds a new connection handshake each time
  • Total time for N concurrent requests is close to the slowest single request, not the sum of all N — the real payoff over a sync loop
  • A single failing request inside gather() raises and cancels the whole group by default — pass return_exceptions=True to collect failures instead of aborting
  • Concurrency has a real ceiling — hundreds of simultaneous connections to one host risks hitting server-side rate limits or connection limits

Sequential vs. concurrent requests, same three URLs

Sequential vs. concurrent requests, same three URLs
ApproachCode shapeTotal time for 3 slow calls
Sync, one at a timefor u in urls: httpx.get(u)sum of all 3 response times
Async, concurrentawait asyncio.gather(*(client.get(u) for u in urls))close to the SLOWEST single response time
One client per call vs. one shared client, awaited concurrently

Sequential (or a fresh client each time)

  • for u in urls: httpx.get(u) — each call waits for the last to finish
  • A new httpx.Client()/AsyncClient() per call re-does the connection handshake
  • Total time ≈ sum of every response time

One AsyncClient, gathered concurrently

  • async with httpx.AsyncClient() as client: — built once, reused
  • await asyncio.gather(*(client.get(u) for u in urls)) starts all of them
  • Total time ≈ the SLOWEST single response, not the sum
  • Sequential (or a fresh client each time)
    • for u in urls: httpx.get(u) — each call waits for the last to finish
    • A new httpx.Client()/AsyncClient() per call re-does the connection handshake
    • Total time ≈ sum of every response time
  • One AsyncClient, gathered concurrently
    • async with httpx.AsyncClient() as client: — built once, reused
    • await asyncio.gather(*(client.get(u) for u in urls)) starts all of them
    • Total time ≈ the SLOWEST single response, not the sum

Command

python
import asyncio
import httpx

async def fetch_all(urls):
    async with httpx.AsyncClient() as client:      # one client, reused
        return await asyncio.gather(
            *(client.get(u) for u in urls),
            return_exceptions=True,                 # don't abort on one failure
        )

results = asyncio.run(fetch_all(urls))

await asyncio.gather(*(client.get(u) for u in urls)) on ONE shared httpx.AsyncClient runs requests concurrently — total time is close to the slowest single call, not the sum of all of them.

6.HTTP status handling in a client

requests and httpx never raise an exception for a 4xx/5xx status by default — response.status_code just holds the number. Calling response.raise_for_status() is what turns a bad status into a raised exception, so a caller can use try/except instead of checking the number by hand.

Key features

  • response.status_code (requests/httpx) never raises anything by itself — a 500 response is returned normally, just like a 200
  • response.raise_for_status() raises only for 4xx/5xx — it does nothing (returns None) for a successful response
  • requests raises requests.exceptions.HTTPError; httpx raises httpx.HTTPStatusError — both carry .response with the original response object
  • A network-level failure (DNS, refused connection, timeout) raises a DIFFERENT exception than a bad status code — those never even reach raise_for_status()
  • httpx.HTTPStatusError distinguishes response.is_client_error (4xx) from response.is_server_error (5xx) as boolean properties

Turning a status code into control flow

Turning a status code into control flow
Situationrequestshttpx
Check without raisingif response.status_code == 404:if response.status_code == 404:
Raise on 4xx/5xxresponse.raise_for_status()response.raise_for_status()
Exception type raisedrequests.exceptions.HTTPErrorhttpx.HTTPStatusError
Reach the original response after catchingexcept HTTPError as e: e.responseexcept HTTPStatusError as e: e.response
Network failure (no response at all)requests.exceptions.ConnectionErrorhttpx.ConnectError / httpx.TimeoutException

Command

python
response = client.get(url)
try:
    response.raise_for_status()   # no-op on 2xx, raises on 4xx/5xx
except requests.exceptions.HTTPError as e:   # httpx.HTTPStatusError for httpx
    handle_failure(e.response.status_code)
else:
    data = response.json()

status_code alone never raises — call raise_for_status() to turn a 4xx/5xx into requests.exceptions.HTTPError / httpx.HTTPStatusError, which still carries the original response.

7.Response validation

A 200 status only means the server considers the request successful — it does not guarantee the body is valid JSON, or that it has the fields your code expects. response.json() raises on malformed JSON; checking specific keys (or a schema library like Pydantic) is what catches a well-formed but wrong-shaped body.

Key features

  • response.json() raises requests.exceptions.JSONDecodeError (httpx: json.JSONDecodeError) if the body is not valid JSON at all — an HTML error page from a proxy is a common real cause
  • A 200 response with valid JSON can still be missing an expected key — .json()['missing_key'] raises KeyError, not a validation error
  • response.json().get('key') returns None silently on a missing key instead of raising — often the WRONG choice when the field is required
  • A Pydantic model (or dataclass + manual checks) turns "does this dict have the right shape" into one explicit validation step instead of scattered .get() calls
  • Validate the body even on a successful status — a misconfigured API gateway can return 200 with an HTML login page instead of the expected JSON

Where an invalid response can fail, and what to check

Where an invalid response can fail, and what to check
FailureSymptom if uncheckedGuard
Malformed JSON bodyJSONDecodeError deep inside unrelated codewrap response.json() in try/except
Missing expected keyKeyError far from the actual causeresponse.json()['field'] with an explicit except, or a schema
Wrong type for a fieldTypeError/ValueError later when the value is usedvalidate with a Pydantic model or manual isinstance checks
Unexpectedly empty bodyJSONDecodeError (empty string is not valid JSON)check response.text or response.content before parsing

Command

python
response = client.get(url)
response.raise_for_status()
try:
    data = response.json()
except requests.exceptions.JSONDecodeError as e:
    raise RuntimeError(f'response was not valid JSON: {e}') from e

required = data['id']   # explicit KeyError if the API changed shape

A 2xx status says nothing about the body's shape — parse with a guarded response.json(), then check (or schema-validate) the specific fields the code actually needs.

8.Proxy configuration and TLS verification

A proxy sits between your client and the real server — set one with the proxies= (requests) or proxy= (httpx) argument. TLS verification checks the server's certificate is signed by a trusted authority; turning it off (verify=False) removes protection against a fake server impersonating the real one.

Key features

  • requests.get(url, proxies={"https": "http://proxy.example.com:8080"}) routes the request through that proxy
  • httpx.Client(proxy="http://proxy.example.com:8080") does the same for httpx — one proxy= argument, not a scheme-keyed dict
  • verify=False on requests or httpx.Client(verify=False) disables certificate verification — the connection is still encrypted, but no longer confirms who is on the other end
  • A real self-signed or expired certificate raises SSLCertVerificationError/ConnectError by default — that failure is the protection working, not a bug to silence
  • Prefer trusting a specific internal CA bundle (verify="/path/to/ca-bundle.pem") over verify=False when a private/internal service uses a self-signed cert

Proxy and TLS-verification configuration, requests vs. httpx

Proxy and TLS-verification configuration, requests vs. httpx
Settingrequestshttpx
Set a proxyrequests.get(url, proxies={"https": "http://p:8080"})httpx.Client(proxy="http://p:8080")
Disable TLS verificationrequests.get(url, verify=False)httpx.Client(verify=False)
Trust a custom CA bundlerequests.get(url, verify="/path/ca.pem")httpx.Client(verify="/path/ca.pem")

Command

python
import httpx

# Proxy
client = httpx.Client(proxy="http://proxy.example.com:8080")

# NEVER do this against a real host -- disables certificate verification entirely
client = httpx.Client(verify=False)

# Prefer this instead, if the target uses a private/internal CA
client = httpx.Client(verify="/path/to/internal-ca-bundle.pem")

proxy= (httpx) / proxies= (requests) route a request through an intermediary. verify=False disables certificate checking for every request on that client — trust a specific CA bundle (verify="path/to/ca.pem") for a private/internal service instead of disabling verification outright.

Remember

  • requests is sync-only with the biggest ecosystem; httpx gives one API for both sync and async plus HTTP/2; aiohttp is async-only.
  • Reuse one Client/Session across calls — a fresh one per call re-does the connection handshake and loses pooling.
  • A 200 status only means the SERVER considered the request successful — validate the body shape separately, it says nothing about that.
  • verify=False disables certificate checking for every request on that client — trust a specific CA bundle instead for a private/internal service.

Pro tip

  • asyncio.gather() on one shared AsyncClient is close to the slowest single response, not the sum — the real payoff over a sync loop.
  • requests.get() called inside async code blocks the whole event loop — it has no way to yield control back, unlike httpx.AsyncClient.
  • Iterate streaming responses (iter_content/aiter_bytes) instead of .content for a large body — the whole thing does not need to fit in memory at once.

Next up

  • Senior-Level Engineering Skills

Sources

  • requests — requests.readthedocs.io · httpx — python-httpx.org · aiohttp — docs.aiohttp.org
  • Every call run for real against httpbin.org · checked 2026-08-22

Spotted a mistake on this page? Tell us.