Filter concepts by levelShowing all levels.

Python · Section 48

Networking and HTTP Clients

Level
intermediate
Read
180 min
Concepts
8

Choosing between requests, httpx, and aiohttp, and configuring a client for connection pooling, timeouts, retries, authentication, streaming, concurrent async requests, and TLS/proxy settings — the client-library API surface used to actually make and validate calls, verified against live endpoints rather than described in the abstract.

Python overview

What is true here

  1. requests is sync-only with the biggest ecosystem; httpx gives one API for both sync and async plus HTTP/2; aiohttp is async-only.
  2. Reuse one Client/Session across calls — a fresh one per call re-does the connection handshake and loses pooling.
  3. A 200 status only means the server considered the request successful; the response body still needs its own validation.
  4. asyncio.gather() on one shared AsyncClient runs requests concurrently — total time approaches the slowest single call, not the sum.
  5. verify=False disables certificate checking for every request on that client — trust a specific CA bundle instead for a private/internal service.

What you will be able to do

  • Choose between requests, httpx, and aiohttp for a given workload, and justify the choice
  • Configure a client with connection pooling, a bounded timeout, and automatic retries
  • Add authentication headers correctly, and avoid leaking credentials into logs or URLs
  • Stream a large response instead of loading it fully into memory
  • Run several requests concurrently with one shared AsyncClient and asyncio.gather()
  • Handle a non-2xx status and a malformed response body as two separate failure modes
  • Route a client through a proxy, and trust a specific CA bundle instead of disabling TLS verification

Choosing and configuring a client

requests vs. httpx vs. aiohttp, and the pooling/timeout/retry configuration that makes a client survive a slow or flaky server.

requests, httpx, and aiohttp

coreintermediate

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.

Think of it as

requests is a phone call — you dial, you wait, you hang up, one at a time. httpx is that same phone call, but the same person can also send several messages at once and keep working while waiting for replies (async). aiohttp is a dedicated messaging app built for handling many conversations simultaneously from the start — it was never designed to make just one blocking call and wait.

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()

What we're doing: Make the same GET request with all three libraries and confirm each one actually reaches httpbin.org and gets the same query parameters back.

three_clients.pypython
import asyncio
import requests
import httpx
import aiohttp

r = requests.get('https://httpbin.org/get', params={'q': '1'})
print('requests:', r.status_code, r.json()['args'])

r = httpx.get('https://httpbin.org/get', params={'q': '1'})
print('httpx:', r.status_code, r.json()['args'])

async def aiohttp_get():
    async with aiohttp.ClientSession() as session:
        async with session.get('https://httpbin.org/get', params={'q': '1'}) as resp:
            data = await resp.json()
            return resp.status, data['args']

print('aiohttp:', asyncio.run(aiohttp_get()))
6
requests.get is a module-level function — no client object to construct for a single call.
9
httpx.get mirrors requests' call shape exactly — params=, .status_code, .json() all match.
13
aiohttp requires both an outer ClientSession and an inner response context manager, and every read (.json()) is awaited.
Output
requests: 200 {'q': '1'}
httpx: 200 {'q': '1'}
aiohttp: (200, {'q': '1'})

Why this works: All three libraries reach the same server and get the same echoed query parameters back, confirming the API differences are about call shape and concurrency model — not about what HTTP they can perform. requests and httpx share almost identical sync call shapes; aiohttp's two-level async context manager is the one genuinely different pattern to learn.

Picking aiohttp for a script that never needs concurrency

Wrong

python
import asyncio
import aiohttp

async def main():
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as resp:
            return await resp.json()

data = asyncio.run(main())   # one request, wrapped in an event loop for no reason

Better

python
import requests

data = requests.get(url).json()   # one line, no event loop needed

What you see: No functional bug — the async version works — but every single-request script now needs asyncio.run(), an async function, and a nested context manager to do what requests.get(url).json() does in one line.

Why: aiohttp and httpx.AsyncClient earn their complexity when many requests run concurrently — asyncio.gather() overlapping their wait time. A script making one request, or requests one after another with no concurrency benefit, pays async's ceremony cost for zero speed benefit.

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

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

Together

python
import requests
import httpx
import asyncio
import aiohttp

# requests -- sync only
r = requests.get('https://httpbin.org/get')

# httpx -- sync, same call shape as requests
r = httpx.get('https://httpbin.org/get')

# httpx -- async, same method names as the sync client
async def fetch_httpx():
    async with httpx.AsyncClient() as client:
        return await client.get('https://httpbin.org/get')

# aiohttp -- async only, its own session/response shape
async def fetch_aiohttp():
    async with aiohttp.ClientSession() as session:
        async with session.get('https://httpbin.org/get') as resp:
            return await resp.json()

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 and often doubles as a server.

See also: async http clients · building a resilient client · content negotiation and performance · choosing threading multiprocessing or asyncio

Building a resilient client: pooling, timeouts, retries

coreintermediate

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.

Think of it as

A fresh requests.get(url) every time is calling a new phone number, dialing, and hanging up after every sentence. A reused Session keeps the line open. A timeout is a rule for how long you will let it ring before giving up. A retry policy is trying again automatically if the call drops, instead of the whole task failing on one bad connection.

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

What we're doing: Trigger a real timeout against a genuinely slow endpoint, then trigger a real exhausted-retries error against a genuinely failing endpoint, proving both failure modes actually fire rather than assuming the configuration works.

resilient_client.pypython
import time
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

# 1. Timeout: httpbin.org/delay/3 sleeps 3s server-side before responding
try:
    requests.get('https://httpbin.org/delay/3', timeout=1)
except requests.exceptions.Timeout as e:
    print('Timeout raised:', type(e).__name__)

# 2. Retries: httpbin.org/status/500 always returns 500 -- retries exhaust
retry_strategy = Retry(
    total=3,
    backoff_factor=0.1,
    status_forcelist=[500, 502, 503, 504],
    allowed_methods=['GET'],
)
session = requests.Session()
session.mount('https://', HTTPAdapter(max_retries=retry_strategy))

start = time.time()
try:
    session.get('https://httpbin.org/status/500')
except requests.exceptions.RetryError as e:
    elapsed = time.time() - start
    print('RetryError after exhausting retries:', type(e).__name__, 'elapsed:', round(elapsed, 2))
8
timeout=1 means "give up after 1 second" -- the server deliberately takes 3, so this always times out.
20
total=3 with backoff_factor=0.1 means up to 3 retries with increasing delay between them, only for the listed status codes.
24
Every one of the 4 attempts (1 original + 3 retries) gets a 500, so urllib3 raises RetryError once total is exhausted.
Output
Timeout raised: ReadTimeout
RetryError after exhausting retries: RetryError elapsed: 2.89

Why this works: Both failures are real, not simulated: httpbin.org/delay/3 genuinely sleeps 3 seconds server-side, so a 1-second timeout genuinely expires client-side. httpbin.org/status/500 genuinely returns 500 every time, so all 4 attempts (backed off with increasing delay, hence the 2.89s elapsed) genuinely fail, and urllib3 genuinely raises RetryError once the retry budget hits zero — this is what "exhausted" looks like in practice, not a theoretical ceiling.

Passing a single timeout number and assuming it only bounds connect time

Wrong

python
# a slow-to-RESPOND (not slow-to-connect) server still hangs past this
r = requests.get(url, timeout=1)   # 1 applies to connect AND read separately

Better

python
# be explicit: fast to connect, more patient waiting for the body
r = requests.get(url, timeout=(3.05, 27))   # (connect_timeout, read_timeout)

What you see: No crash, but a misunderstanding of what the number bounds -- a single timeout=N is applied independently to BOTH the connect phase and the read phase, not once total, so the real worst-case wait is closer to 2N, not N.

Why: requests and httpx both apply a single timeout value separately to each network phase (connect, read, and in httpx's case also write and pool-acquire) rather than as one combined budget for the whole request — passing a tuple (or a Timeout() object in httpx) makes each phase's bound explicit instead of assumed.

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

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

Together

python
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

retry_strategy = Retry(
    total=3,
    backoff_factor=0.1,
    status_forcelist=[500, 502, 503, 504],
    allowed_methods=['GET'],
)
session = requests.Session()
session.mount('https://', HTTPAdapter(max_retries=retry_strategy))

response = session.get('https://api.example.com/data', timeout=(3.05, 10))

Remember: 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.

See also: content negotiation and performance · client library landscape · async http clients

Authentication and headers

standardintermediate

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.

Think of it as

Authentication in HTTP is showing ID at a door, every single time you knock — nothing is remembered between requests unless you attach it yourself. headers={'Authorization': ...} is handing over that ID with each request; a library's auth= parameter is just a shortcut for building the same ID card in the standard format instead of typing it by hand.

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}'})

What we're doing: Send a real Bearer token and real Basic auth credentials against httpbin.org's dedicated auth-check endpoints and confirm both are accepted.

auth_headers.pypython
import requests
import httpx

r = requests.get('https://httpbin.org/bearer', headers={'Authorization': 'Bearer test-token-123'})
print('bearer:', r.status_code, r.json())

r = requests.get('https://httpbin.org/basic-auth/user/pass', auth=('user', 'pass'))
print('requests basic:', r.status_code, r.json())

r = httpx.get('https://httpbin.org/basic-auth/user/pass', auth=httpx.BasicAuth('user', 'pass'))
print('httpx basic:', r.status_code, r.json())

r = requests.get('https://httpbin.org/headers', headers={'X-Client-Id': 'svc-42', 'Authorization': 'Bearer abc'})
echoed = r.json()['headers']
print('echoed:', echoed.get('X-Client-Id'), echoed.get('Authorization'))
4
/bearer validates the Authorization header against the Bearer scheme and echoes the token back if accepted.
7
requests' auth=('user', 'pass') tuple builds the Basic auth header (base64 of user:pass) automatically.
10
httpx has no auth= tuple shortcut -- httpx.BasicAuth(...) is the explicit equivalent.
Output
bearer: 200 {'authenticated': True, 'token': 'test-token-123'}
requests basic: 200 {'authenticated': True, 'user': 'user'}
httpx basic: 200 {'authenticated': True, 'user': 'user'}
echoed: svc-42 Bearer abc

Why this works: httpbin.org's /bearer and /basic-auth endpoints actually validate the credentials sent, not just accept anything — a 200 with 'authenticated': True confirms the header was built and sent correctly, not merely that a request was made. The last call confirms custom and auth headers travel together, unmodified, exactly as sent.

Hardcoding a token into request code instead of reading it from configuration

Wrong

python
# committed straight into version control
headers = {'Authorization': 'Bearer sk_live_51H8x2KJ9...'}
response = requests.get(url, headers=headers)

Better

python
import os

token = os.environ['API_TOKEN']   # read from environment/secrets manager, never committed
headers = {'Authorization': f'Bearer {token}'}
response = requests.get(url, headers=headers)

What you see: The token works today, but it is now permanently in the repository's git history — even deleting the line later does not remove it from past commits, and any repository access (including an old fork or a leaked CI cache) leaks a live credential.

Why: A hardcoded secret in source code is committed the moment the file is, and `git log`/`git blame` retain it forever unless the entire repository history is rewritten. Reading it from an environment variable or a secrets manager at runtime keeps the actual value out of version control entirely.

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

Together

python
import requests
import httpx

# one-off Bearer header
r = requests.get(url, headers={'Authorization': f'Bearer {token}'})

# Basic auth via the dedicated parameter
r = requests.get(url, auth=('user', 'pass'))
r = httpx.get(url, auth=httpx.BasicAuth('user', 'pass'))

# a default header applied to every call made through this client
session = requests.Session()
session.headers.update({'Authorization': f'Bearer {token}'})
r = session.get(url)   # Authorization sent automatically

Remember: 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.

See also: headers · building a resilient client · proxy and tls configuration

Advertisement

Reading responses and going concurrent

Streaming a large body, running requests concurrently with an async client, and the two separate things a response needs validated — its status and its body.

Streaming responses

coreintermediate

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

Think of it as

A normal request is waiting for a delivery truck to fully unload before touching anything inside. Streaming is unloading each box as the truck hands it over — you can start working with the first box before the truck has finished arriving, and the whole shipment never has to fit on your desk at once.

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)

What we're doing: Stream a real 2048-byte response from all three libraries in fixed 512-byte chunks and confirm the total bytes and chunk count match what a whole-body read would give, proving nothing is silently dropped or duplicated.

streaming.pypython
import asyncio
import requests
import httpx
import aiohttp

with requests.get('https://httpbin.org/stream-bytes/2048', stream=True) as r:
    total = chunks = 0
    for chunk in r.iter_content(chunk_size=512):
        total += len(chunk)
        chunks += 1
    print('requests:', total, 'bytes in', chunks, 'chunks')

with httpx.stream('GET', 'https://httpbin.org/stream-bytes/2048') as r:
    total = chunks = 0
    for chunk in r.iter_bytes(512):
        total += len(chunk)
        chunks += 1
    print('httpx:', total, 'bytes in', chunks, 'chunks')

async def aiohttp_stream():
    total = chunks = 0
    async with aiohttp.ClientSession() as session:
        async with session.get('https://httpbin.org/stream-bytes/2048') as resp:
            async for chunk in resp.content.iter_chunked(512):
                total += len(chunk)
                chunks += 1
    return total, chunks

print('aiohttp:', asyncio.run(aiohttp_stream()))
5
stream=True is required -- without it, requests downloads the entire body before get() even returns.
13
httpx.stream() is itself the context manager; the response inside it is only partially read until you iterate.
22
aiohttp streams by default -- .content is already a StreamReader; iter_chunked just controls the chunk size.
Output
requests: 2048 bytes in 4 chunks
httpx: 2048 bytes in 4 chunks
aiohttp: (2048, 4)

Why this works: All three report the same 2048 total bytes across 4 chunks of 512 -- the endpoint's real byte count, confirming chunking splits the SAME data rather than losing or duplicating any of it. requests and httpx needed an explicit opt-in (stream=True, or the .stream() context manager); aiohttp streams by default because .content is always a StreamReader.

Calling .json() or .text on a streamed response, defeating the point of streaming

Wrong

python
with requests.get(url, stream=True) as r:
    data = r.json()   # forces the ENTIRE body into memory anyway

Better

python
with requests.get(url, stream=True) as r:
    for chunk in r.iter_content(chunk_size=8192):
        process_incrementally(chunk)   # never holds the whole body at once

What you see: No error -- .json() and .text both work fine on a streamed response -- but memory usage for a large file is identical to not streaming at all, because both force-read and buffer the complete body before returning anything.

Why: stream=True only changes whether the connection is held open for YOU to read incrementally -- it does not change what .json()/.text do internally, which is read everything remaining and decode it in one shot. Streaming only pays off when the code also reads via iter_content/iter_bytes/iter_chunked.

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

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): ...

Together

python
import requests
import httpx

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

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

# aiohttp
async with session.get(url) as r:
    async for chunk in r.content.iter_chunked(8192):
        process(chunk)

Remember: 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.

See also: response validation · building a resilient client

Async HTTP clients

coreintermediate

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.

Think of it as

A synchronous client is one cashier serving one customer fully before starting the next. An async client is one cashier who starts every customer's order, then serves whichever order finishes first while the others are still being prepared — the total wait is close to the SLOWEST single order, not the sum of every order.

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))

What we're doing: Fetch three real URLs concurrently with one shared httpx.AsyncClient and asyncio.gather(), and measure the real elapsed time against what a sequential loop would take.

concurrent_fetch.pypython
import asyncio
import time
import httpx

async def fetch_all(urls):
    async with httpx.AsyncClient() as client:
        start = time.monotonic()
        responses = await asyncio.gather(*(client.get(u) for u in urls))
        elapsed = time.monotonic() - start
    return [r.status_code for r in responses], elapsed

urls = [
    "https://httpbin.org/get?q=1",
    "https://httpbin.org/get?q=2",
    "https://httpbin.org/get?q=3",
]
statuses, elapsed = asyncio.run(fetch_all(urls))
print(statuses)
print(f"elapsed: {elapsed:.2f}s for {len(urls)} concurrent requests")
6
One AsyncClient is built and reused for all three requests inside the same async with block.
8
asyncio.gather() starts all three GET requests before awaiting any of their responses.
Output
[200, 200, 200]
elapsed: 1.18s for 3 concurrent requests

Why this works: httpbin.org/get responds in well under a second per call, so three genuinely sequential requests would take roughly three times as long as one. Because asyncio.gather() starts all three before awaiting any response, the process spends most of its time waiting on the network concurrently rather than one request at a time — the real, measured payoff of an async client over a sync loop for independent I/O-bound calls.

Building a fresh AsyncClient inside the loop instead of reusing one

Wrong

python
async def fetch(url):
    async with httpx.AsyncClient() as client:   # new client, new connection, every call
        return await client.get(url)

results = await asyncio.gather(*(fetch(u) for u in urls))

Better

python
async def fetch_all(urls):
    async with httpx.AsyncClient() as client:   # one client, shared connection pool
        return await asyncio.gather(*(client.get(u) for u in urls))

results = asyncio.run(fetch_all(urls))

What you see: No exception, just consistently slower results than expected — each request pays a fresh TCP/TLS handshake instead of reusing an already-open connection from a shared pool.

Why: An httpx.AsyncClient (or aiohttp.ClientSession) holds a connection pool that is only useful if the SAME client instance serves multiple requests. Constructing a new one per call — even inside a gather() — throws away that pool and pays a full handshake every time, which erases most of the concurrency benefit for requests to the same host.

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

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

Together

python
import asyncio
import httpx

async def fetch_all(urls):
    async with httpx.AsyncClient() as client:
        responses = await asyncio.gather(*(client.get(u) for u in urls))
    return [r.status_code for r in responses]

urls = ["https://httpbin.org/get?q=1", "https://httpbin.org/get?q=2", "https://httpbin.org/get?q=3"]
print(asyncio.run(fetch_all(urls)))

Remember: 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.

See also: client library landscape · building a resilient client · async io clients and connection pooling

HTTP status handling in a client

standardintermediate

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.

Think of it as

A response with a 404 or 500 status is a completed phone call that happens to deliver bad news — the call itself succeeded. raise_for_status() is choosing to treat bad news as a hang-up: an explicit decision to convert "the message was disappointing" into "something went wrong," which is what most calling code actually wants.

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()

What we're doing: Confirm raise_for_status() genuinely raises for a real 404 from both libraries, with the correct status code recoverable from the exception.

status_handling.pypython
import requests
import httpx

r = requests.get('https://httpbin.org/status/404')
try:
    r.raise_for_status()
except requests.exceptions.HTTPError as e:
    print('requests HTTPError:', e.response.status_code, type(e).__name__)

r = httpx.get('https://httpbin.org/status/404')
try:
    r.raise_for_status()
except httpx.HTTPStatusError as e:
    print('httpx HTTPStatusError:', e.response.status_code, type(e).__name__)
4
httpbin.org/status/404 always returns a 404 with an empty body -- built specifically for testing status handling.
6
raise_for_status() inspects status_code itself and raises only because 404 is in the 4xx range.
Output
requests HTTPError: 404 HTTPError
httpx HTTPStatusError: 404 HTTPStatusError

Why this works: Both libraries genuinely raise their respective exception type only after inspecting the real status code returned by the server, and both expose the original response (with its real status_code) on the caught exception — confirming raise_for_status() is a deliberate check, not something that happens automatically on every request.

Assuming a 4xx/5xx response already raised, and never calling raise_for_status()

Wrong

python
response = requests.get(url)
data = response.json()   # runs even on a 500 -- may be an error page, not JSON
process(data['result'])  # KeyError or JSONDecodeError, far from the real cause

Better

python
response = requests.get(url)
response.raise_for_status()   # fails loudly and immediately on 4xx/5xx
data = response.json()
process(data['result'])

What you see: No exception at the actual failure point -- code proceeds past a 500 as if it succeeded, and the real error (KeyError, JSONDecodeError, or wrong business data) surfaces several lines later with a confusing, unrelated traceback.

Why: requests and httpx treat every HTTP response as a "successful" Python call regardless of its status code — a 500 is returned, not raised. Skipping raise_for_status() means the code has to notice the failure itself, and code that assumes success will misinterpret an error response as valid data.

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

Together

python
import requests

response = requests.get(url)
try:
    response.raise_for_status()
except requests.exceptions.HTTPError as e:
    print(f'request failed: {e.response.status_code}')
else:
    data = response.json()

Remember: 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.

See also: status codes · response validation

Response validation

standardintermediate

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.

Think of it as

A 200 status is a delivery confirmation, not a contents inspection — the package arrived, but nobody checked what is inside. response.json() is opening the box and confirming it is not empty debris; checking the actual keys (or validating against a schema) is confirming what is inside matches the packing list.

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

What we're doing: Trigger a real JSONDecodeError by parsing an HTML response as JSON, then confirm a genuine JSON response parses and exposes its real keys.

response_validation.pypython
import requests

# httpbin.org/html returns a real HTML page, not JSON
r = requests.get('https://httpbin.org/html')
try:
    r.json()
except requests.exceptions.JSONDecodeError as e:
    print('JSONDecodeError raised:', type(e).__name__)

# httpbin.org/json returns real, well-formed JSON
r = requests.get('https://httpbin.org/json')
data = r.json()
print('keys:', list(data.keys()))
4
/html deliberately returns text/html -- calling .json() on it must fail, since HTML is not valid JSON.
11
/json returns a real, well-formed JSON document -- .json() succeeds and the keys are inspectable normally.
Output
JSONDecodeError raised: JSONDecodeError
keys: ['slideshow']

Why this works: The HTML response genuinely fails to parse as JSON — this is exactly the failure mode a misconfigured proxy or an API returning an error page produces in production, not a contrived edge case. The successful call confirms that even a well-formed JSON response has a specific shape (here, one top-level "slideshow" key) that calling code still has to know about and check, not just assume.

Assuming a 200 status guarantees the JSON body has every field the code expects

Wrong

python
response = requests.get(url)
response.raise_for_status()             # only confirms the STATUS was 2xx
data = response.json()
user_email = data['user']['email']      # KeyError if the API's shape ever changes

Better

python
response = requests.get(url)
response.raise_for_status()
data = response.json()
try:
    user_email = data['user']['email']
except KeyError as e:
    raise RuntimeError(f'response missing expected field: {e}') from e

What you see: A successful (200) response still crashes the caller with KeyError the moment the API adds a new version, renames a field, or returns a partial object for some edge case — raise_for_status() gave no protection against any of that.

Why: raise_for_status() only inspects the STATUS LINE — it has no knowledge of the body's structure at all. A field genuinely missing from an otherwise-valid, 200-status JSON response is a completely separate failure mode that needs its own explicit check.

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

Together

python
import requests
from pydantic import BaseModel

class UserResponse(BaseModel):
    id: int
    email: str

response = requests.get(url)
response.raise_for_status()
try:
    user = UserResponse.model_validate(response.json())
except (requests.exceptions.JSONDecodeError, ValueError) as e:
    raise RuntimeError(f'unexpected response shape: {e}') from e

Remember: 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.

See also: http status handling · status codes

Advertisement

Proxies and transport security

Routing a client through an intermediary, and what disabling TLS verification actually gives up.

Proxy configuration and TLS verification

standardintermediate

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.

Think of it as

A proxy is a mail forwarder — your request goes to it first, and it relays the request onward (and the response back), which is how a corporate network or a scraping service routes traffic through a controlled exit point. TLS verification is checking a passport at a border — skipping the check lets anyone through, including someone pretending to be who they are not.

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")

What we're doing: Show TLS verification actually rejecting a real self-signed certificate, proving the check is doing real work rather than being a formality.

tls_verification.pypython
import httpx

try:
    r = httpx.get("https://self-signed.badssl.com/", timeout=5)
    print("unexpected success:", r.status_code)
except httpx.ConnectError as e:
    print("rejected, as expected:", type(e).__name__)
4
self-signed.badssl.com deliberately serves a certificate no public CA has signed -- a live test target for verification failures.
6
httpx raises ConnectError (wrapping an SSL certificate verification failure) instead of silently connecting.
Output
rejected, as expected: ConnectError

Why this works: httpx (like requests) verifies the server's certificate chain against a trusted CA bundle by default, and self-signed.badssl.com's certificate is deliberately signed by nobody a browser or HTTP client trusts. The connection fails BEFORE any HTTP request is sent, which is exactly the protection TLS verification exists to provide — proof the check is real, not a formality that could safely be skipped.

Disabling TLS verification to silence a certificate error instead of fixing the real cause

Wrong

python
import httpx

# "it kept failing so I turned this off" -- now ANY server, including an
# attacker impersonating the real one, is accepted without complaint
client = httpx.Client(verify=False)
r = client.get("https://internal-api.company.com/data")

Better

python
import httpx

# Trust the specific internal CA that actually signed this cert, instead
# of disabling verification against every host this client ever calls
client = httpx.Client(verify="/etc/ssl/certs/company-internal-ca.pem")
r = client.get("https://internal-api.company.com/data")

What you see: No exception at request time — the silent risk is that verify=False also accepts a certificate from an attacker on the network path, not just the internal service's legitimate self-signed one.

Why: verify=False does not target "this one internal certificate" — it disables certificate verification for every request that client makes, for the lifetime of that client object. A client configured this way cannot tell the real internal-api.company.com from a machine-in-the-middle presenting any certificate at all. Pointing verify at the actual CA that signed the internal certificate keeps the check working for exactly the case it needs to protect against.

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")

Together

python
import httpx

# Route through a proxy (illustrative -- no live proxy server in this environment)
client = httpx.Client(proxy="http://proxy.example.com:8080")

# Trust a specific CA bundle instead of disabling verification outright
client = httpx.Client(verify="/etc/ssl/certs/internal-ca.pem")

Remember: 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.

See also: client library landscape · proxies and nat · https tls and same origin policy

Advertisement