httpx follow redirects: why you get a 302 and how to fix it (tested)

httpx returns the 301/302 response instead of following it. Pass follow_redirects=True to the request or to httpx.Client(...) / AsyncClient(...); the redirect hops are then in r.history.

Unlike requests, httpx does not follow redirects by default: you get the 301/302 response itself. Measured with httpx 0.28.1 (and requests 2.34.2 for comparison) on Python 3.13.5 against a local http.server (srv.start() starts it in a thread; /r302 redirects to /final, which echoes the method).

1. httpx returns the redirect instead of following it

import httpx, requests
from srv import start
base = start()

r = requests.get(base + "/r302")
print("requests:", r.status_code, r.url)

r = httpx.get(base + "/r302")
print("httpx:   ", r.status_code, r.url)
print("is_redirect:", r.is_redirect)
print("location:", r.headers["location"])
print("next_request:", r.next_request)

Output:

requests: 200 http://127.0.0.1:8963/final
httpx:    302 http://127.0.0.1:8963/r302
is_redirect: True
location: /final
next_request: <Request('GET', 'http://127.0.0.1:8963/final')>

requests ends on /final with 200; httpx stops at the 302. The response tells you where it would go: r.is_redirect, the Location header, and r.next_request (a ready-made request you can send with client.send(r.next_request)).

2. Fix: follow_redirects=True (per request or on the Client)

import httpx
from srv import start
base = start()

# per request
r = httpx.get(base + "/r302", follow_redirects=True)
print(r.status_code, r.url)
print("history:", r.history)

# or once, on the client
with httpx.Client(follow_redirects=True) as client:
    r = client.get(base + "/r301")
    print(r.status_code, r.json()["path"], [h.status_code for h in r.history])

Output:

200 http://127.0.0.1:8963/final
history: [<Response [302 Found]>]
200 /final [301]

The redirect responses are kept in r.history, and r.url is the final URL. The old allow_redirects= argument (requests, and httpx before 0.20) is not accepted by httpx 0.28; use follow_redirects.

3. raise_for_status() raises on a redirect

import httpx
from srv import start
base = start()

r = httpx.get(base + "/r302")
try:
    r.raise_for_status()
except httpx.HTTPStatusError as e:
    print(type(e).__name__)
    print(e)

Output:

HTTPStatusError
Redirect response '302 Found' for url 'http://127.0.0.1:8963/r302'
Redirect location: '/final'
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/302

If you call raise_for_status() without following redirects, a 3xx is an error too (only 2xx passes). The message names the redirect location, which is usually the clue that follow_redirects is missing.

4. Redirect loops and POST becoming GET

import httpx
from srv import start
base = start()

with httpx.Client(follow_redirects=True, max_redirects=5) as client:
    try:
        client.get(base + "/loop")
    except httpx.TooManyRedirects as e:
        print(type(e).__name__, "-", e)

    for code in (301, 302, 303, 307, 308):
        r = client.post(base + f"/r{code}", json={"a": 1})
        print(code, "->", r.json()["method"])

Output:

TooManyRedirects - Exceeded maximum allowed redirects.
301 -> GET
302 -> GET
303 -> GET
307 -> POST
308 -> POST

A loop raises httpx.TooManyRedirects after max_redirects (default 20). When following, a POST answered with 301/302/303 is re-sent as GET (body dropped), while 307 and 308 keep the POST. If your POST "loses" its data after a redirect, the server is sending 301/302 (often a missing trailing slash or http→https); post to the final URL instead.

5. AsyncClient works the same way

import asyncio, httpx
from srv import start
base = start()

async def main():
    async with httpx.AsyncClient(follow_redirects=True) as client:
        r = await client.get(base + "/r302")
        print(r.status_code, r.url)

asyncio.run(main())

Output:

200 http://127.0.0.1:8963/final

Related: 301 vs 302 vs 307 vs 308, check a redirect chain, ERR_TOO_MANY_REDIRECTS, requests HTTPError response message.

Frequently asked questions

Does httpx follow redirects by default?

No. httpx 0.28.1 returned the 302 response itself, while requests followed it to the final 200. Pass follow_redirects=True.

Why does httpx raise_for_status fail with 'Redirect response 302 Found'?

raise_for_status treats anything outside 2xx as an error, including redirects. Follow redirects with follow_redirects=True, or handle the Location header yourself.

Does httpx keep the POST method after a redirect?

For 301, 302 and 303 it re-sends as GET; for 307 and 308 it keeps POST.

Written with AI assistance. Measured on 2026-10-23 with httpx 0.28.1 and requests 2.34.2 on Python 3.13.5 against a local http.server. Each block was run verbatim and the output pasted unedited. PlainHeaders is our extension; httpx is free.