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.