httpx raise_for_status and HTTPStatusError status code (tested)

httpx returns 4xx/5xx responses without raising; r.raise_for_status() raises httpx.HTTPStatusError. Read e.response.status_code and e.response.json() in the handler. A 302 also raises unless follow_redirects=True.

httpx never raises for a 4xx or 5xx status by itself: call response.raise_for_status(), which raises httpx.HTTPStatusError. Measured with httpx 0.28.1 on Python 3.13.5 against a local http.server (srv.py) that answers /<code> with that status and a small JSON body.

1. httpx response raise_for_status

import httpx
from srv import start
base = start()

r = httpx.get(base + "/404")
print("no exception yet:", r.status_code, r.is_error)
r.raise_for_status()

Output:

no exception yet: 404 True
Traceback (most recent call last):
  File "/tmp/hr394/b1.py", line 7, in <module>
    r.raise_for_status()
    ~~~~~~~~~~~~~~~~~~^^
  File "/tmp/fx384/ve/lib/python3.13/site-packages/httpx/_models.py", line 829, in raise_for_status
    raise HTTPStatusError(message, request=request, response=self)
httpx.HTTPStatusError: Client error '404 Not Found' for url 'http://127.0.0.1:8963/404'
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404

2. httpx HTTPStatusError: get the status code and body

import httpx
from srv import start
base = start()

try:
    httpx.get(base + "/404").raise_for_status()
except httpx.HTTPStatusError as e:
    print("status code:", e.response.status_code)
    print("reason:     ", e.response.reason_phrase)
    print("body:       ", e.response.json())
    print("url:        ", e.request.url)

Output:

status code: 404
reason:      Not Found
body:        {'error': 'item not found'}
url:         http://127.0.0.1:8963/404

The exception carries the full response (e.response.status_code, .json(), .text, .headers) and the request (e.request.url). Log the body: APIs usually explain the error there.

3. It returns the response, and 3xx raises too

import httpx
from srv import start
base = start()

# raise_for_status() returns the response, so it chains
data = httpx.get(base + "/200").raise_for_status().json()
print(data)

# 3xx also raises when redirects are not followed (the httpx default)
try:
    httpx.get(base + "/302").raise_for_status()
except httpx.HTTPStatusError as e:
    print("302:", e)
print(httpx.get(base + "/302", follow_redirects=True).raise_for_status().status_code)

Output:

{'ok': True}
302: Redirect response '302 Found' for url 'http://127.0.0.1:8963/302'
Redirect location: '/200'
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/302
200

In httpx 0.28 raise_for_status() returns the response, so .raise_for_status().json() works. Because httpx does not follow redirects by default, a 302 also raises (requests would have followed it); pass follow_redirects=True.

4. HTTPStatusError vs RequestError

import httpx
from srv import start
base = start()

for url in [base + "/500", "http://127.0.0.1:1/"]:
    try:
        httpx.get(url).raise_for_status()
    except httpx.HTTPStatusError as e:
        print("HTTPStatusError:", e.response.status_code)
    except httpx.RequestError as e:
        print("RequestError:", type(e).__name__, e)

print(issubclass(httpx.HTTPStatusError, httpx.HTTPError), issubclass(httpx.RequestError, httpx.HTTPError))

Output:

HTTPStatusError: 500
RequestError: ConnectError [Errno 111] Connection refused
True True

HTTPStatusError means the server answered with an error status; RequestError (ConnectError, timeouts, ...) means there was no usable response, so there is no e.response. Both subclass httpx.HTTPError, so except httpx.HTTPError catches either.

5. httpx asyncclient raise_for_status for every request

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

async def main():
    async with httpx.AsyncClient(base_url=base, event_hooks={"response": [raise_on_4xx_5xx]}) as client:
        try:
            await client.get("/503")
        except httpx.HTTPStatusError as e:
            print("hook raised:", e.response.status_code)

async def raise_on_4xx_5xx(response):
    if response.is_error:
        await response.aread()
        response.raise_for_status()

asyncio.run(main())

Output:

hook raised: 503

A response event hook raises on every error response without repeating raise_for_status() at each call. In an AsyncClient the hook must be async, and await response.aread() first if you want e.response.json() to work in the handler.

Related: httpx follow redirects, httpx retries, httpx vs requests differences, requests HTTPError response message.

Frequently asked questions

How do I get the status code from httpx HTTPStatusError?

e.response.status_code; the body is in e.response.text or e.response.json() and the URL in e.request.url.

Does httpx raise an exception on 404?

No. It returns the response; call r.raise_for_status() or check r.is_error.

Why does httpx raise_for_status fail on a 302?

httpx does not follow redirects by default, and raise_for_status() raises for 3xx. Pass follow_redirects=True.

What is the difference between HTTPStatusError and RequestError?

HTTPStatusError: the server returned an error status. RequestError: no response (connection refused, timeout). Both subclass httpx.HTTPError.

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