diff --git a/docs/compatibility.md b/docs/compatibility.md index 89235b1262..643844d8d4 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -46,7 +46,32 @@ The HTTP `GET`, `DELETE`, `HEAD`, and `OPTIONS` methods are specified as not sup If you really do need to send request data using these http methods you should use the generic `.request` function instead. -## Checking for 4xx/5xx responses +## Checking for 4xx/5xx error responses + +Unlike Requests, HTTPX raises an `HTTPError` when a client error response (4xx) or a server error response (5xx) is received. This is done by automatically calling `response.raise_for_status()`. + +```python +>>> import httpx +>>> httpx.get('https://httpbin.org/status/400') +Traceback (most recent call last): +[...] +httpx.exceptions.HTTPError: 400 Client Error: Bad Request [...] +``` + +You can disable this behavior by passing `raise_for_status=False` when issuing the request. You can then perform custom error handling, or raise an `HTTPError` by calling `.raise_for_status()` manually. + +```python +>>> import httpx +>>> response = httpx.get('https://httpbin.org/status/400', raise_for_status=False) +>>> response + +>>> response.is_error +True +>>> response.raise_for_status() +Traceback (most recent call last): +[...] +httpx.exceptions.HTTPError: 400 Client Error: Bad Request +``` We don't support `response.is_ok` since the naming is ambiguous there, and might incorrectly imply an equivalence to `response.status_code == codes.OK`. Instead we provide the `response.is_error` property. Use `if not response.is_error:` instead of `if response.is_ok:`. diff --git a/httpx/api.py b/httpx/api.py index 7fbbd30811..2df5f8469b 100644 --- a/httpx/api.py +++ b/httpx/api.py @@ -28,6 +28,7 @@ def request( auth: AuthTypes = None, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, allow_redirects: bool = True, + raise_for_status: bool = True, verify: VerifyTypes = True, cert: CertTypes = None, trust_env: bool = True, @@ -57,6 +58,8 @@ def request( * **timeout** - *(optional)* The timeout configuration to use when sending the request. * **allow_redirects** - *(optional)* Enables or disables HTTP redirects. + * **raise_for_status** - *(optional)* Whether receiving an error response should + result in raising an `HTTPError`. * **verify** - *(optional)* SSL certificates (a.k.a CA bundle) used to verify the identity of requested hosts. Either `True` (default CA bundle), a path to an SSL certificate file, or `False` (disable verification). @@ -94,6 +97,7 @@ def request( cookies=cookies, auth=auth, allow_redirects=allow_redirects, + raise_for_status=raise_for_status, ) @@ -110,6 +114,7 @@ def stream( auth: AuthTypes = None, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, allow_redirects: bool = True, + raise_for_status: bool = True, verify: VerifyTypes = True, cert: CertTypes = None, trust_env: bool = True, @@ -131,6 +136,7 @@ def stream( auth=auth, timeout=timeout, allow_redirects=allow_redirects, + raise_for_status=raise_for_status, close_client=True, ) @@ -143,6 +149,7 @@ def get( cookies: CookieTypes = None, auth: AuthTypes = None, allow_redirects: bool = True, + raise_for_status: bool = True, cert: CertTypes = None, verify: VerifyTypes = True, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, @@ -164,6 +171,7 @@ def get( cookies=cookies, auth=auth, allow_redirects=allow_redirects, + raise_for_status=raise_for_status, cert=cert, verify=verify, timeout=timeout, @@ -179,6 +187,7 @@ def options( cookies: CookieTypes = None, auth: AuthTypes = None, allow_redirects: bool = True, + raise_for_status: bool = True, cert: CertTypes = None, verify: VerifyTypes = True, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, @@ -200,6 +209,7 @@ def options( cookies=cookies, auth=auth, allow_redirects=allow_redirects, + raise_for_status=raise_for_status, cert=cert, verify=verify, timeout=timeout, @@ -215,6 +225,7 @@ def head( cookies: CookieTypes = None, auth: AuthTypes = None, allow_redirects: bool = False, # Note: Differs to usual default. + raise_for_status: bool = True, cert: CertTypes = None, verify: VerifyTypes = True, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, @@ -238,6 +249,7 @@ def head( cookies=cookies, auth=auth, allow_redirects=allow_redirects, + raise_for_status=raise_for_status, cert=cert, verify=verify, timeout=timeout, @@ -256,6 +268,7 @@ def post( cookies: CookieTypes = None, auth: AuthTypes = None, allow_redirects: bool = True, + raise_for_status: bool = True, cert: CertTypes = None, verify: VerifyTypes = True, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, @@ -277,6 +290,7 @@ def post( cookies=cookies, auth=auth, allow_redirects=allow_redirects, + raise_for_status=raise_for_status, cert=cert, verify=verify, timeout=timeout, @@ -295,6 +309,7 @@ def put( cookies: CookieTypes = None, auth: AuthTypes = None, allow_redirects: bool = True, + raise_for_status: bool = True, cert: CertTypes = None, verify: VerifyTypes = True, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, @@ -316,6 +331,7 @@ def put( cookies=cookies, auth=auth, allow_redirects=allow_redirects, + raise_for_status=raise_for_status, cert=cert, verify=verify, timeout=timeout, @@ -334,6 +350,7 @@ def patch( cookies: CookieTypes = None, auth: AuthTypes = None, allow_redirects: bool = True, + raise_for_status: bool = True, cert: CertTypes = None, verify: VerifyTypes = True, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, @@ -355,6 +372,7 @@ def patch( cookies=cookies, auth=auth, allow_redirects=allow_redirects, + raise_for_status=raise_for_status, cert=cert, verify=verify, timeout=timeout, @@ -370,6 +388,7 @@ def delete( cookies: CookieTypes = None, auth: AuthTypes = None, allow_redirects: bool = True, + raise_for_status: bool = True, cert: CertTypes = None, verify: VerifyTypes = True, timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, @@ -391,6 +410,7 @@ def delete( cookies=cookies, auth=auth, allow_redirects=allow_redirects, + raise_for_status=raise_for_status, cert=cert, verify=verify, timeout=timeout, diff --git a/httpx/client.py b/httpx/client.py index 9eec6641bb..c3fe78e96f 100644 --- a/httpx/client.py +++ b/httpx/client.py @@ -162,6 +162,7 @@ def stream( cookies: CookieTypes = None, auth: AuthTypes = None, allow_redirects: bool = True, + raise_for_status: bool = True, timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET, ) -> "StreamContextManager": request = self.build_request( @@ -179,6 +180,7 @@ def stream( request=request, auth=auth, allow_redirects=allow_redirects, + raise_for_status=raise_for_status, timeout=timeout, ) @@ -552,6 +554,7 @@ def request( cookies: CookieTypes = None, auth: AuthTypes = None, allow_redirects: bool = True, + raise_for_status: bool = True, timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET, ) -> Response: request = self.build_request( @@ -565,7 +568,11 @@ def request( cookies=cookies, ) return self.send( - request, auth=auth, allow_redirects=allow_redirects, timeout=timeout, + request, + auth=auth, + allow_redirects=allow_redirects, + raise_for_status=raise_for_status, + timeout=timeout, ) def send( @@ -575,6 +582,7 @@ def send( stream: bool = False, auth: AuthTypes = None, allow_redirects: bool = True, + raise_for_status: bool = True, timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET, ) -> Response: if request.url.scheme not in ("http", "https"): @@ -588,6 +596,9 @@ def send( request, auth=auth, timeout=timeout, allow_redirects=allow_redirects, ) + if raise_for_status: + response.raise_for_status() + if not stream: try: response.read() @@ -1075,6 +1086,7 @@ async def request( cookies: CookieTypes = None, auth: AuthTypes = None, allow_redirects: bool = True, + raise_for_status: bool = True, timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET, ) -> Response: request = self.build_request( @@ -1088,7 +1100,11 @@ async def request( cookies=cookies, ) response = await self.send( - request, auth=auth, allow_redirects=allow_redirects, timeout=timeout, + request, + auth=auth, + allow_redirects=allow_redirects, + raise_for_status=raise_for_status, + timeout=timeout, ) return response @@ -1099,6 +1115,7 @@ async def send( stream: bool = False, auth: AuthTypes = None, allow_redirects: bool = True, + raise_for_status: bool = False, timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET, ) -> Response: if request.url.scheme not in ("http", "https"): @@ -1112,6 +1129,9 @@ async def send( request, auth=auth, timeout=timeout, allow_redirects=allow_redirects, ) + if raise_for_status: + response.raise_for_status() + if not stream: try: await response.aread() @@ -1410,6 +1430,7 @@ def __init__( *, auth: AuthTypes = None, allow_redirects: bool = True, + raise_for_status: bool = True, timeout: typing.Union[TimeoutTypes, UnsetType] = UNSET, close_client: bool = False, ) -> None: @@ -1417,6 +1438,7 @@ def __init__( self.request = request self.auth = auth self.allow_redirects = allow_redirects + self.raise_for_status = raise_for_status self.timeout = timeout self.close_client = close_client @@ -1426,6 +1448,7 @@ def __enter__(self) -> "Response": request=self.request, auth=self.auth, allow_redirects=self.allow_redirects, + raise_for_status=self.raise_for_status, timeout=self.timeout, stream=True, ) @@ -1448,6 +1471,7 @@ async def __aenter__(self) -> "Response": request=self.request, auth=self.auth, allow_redirects=self.allow_redirects, + raise_for_status=self.raise_for_status, timeout=self.timeout, stream=True, ) diff --git a/tests/client/test_async_client.py b/tests/client/test_async_client.py index 5943839966..30a6bc90a5 100644 --- a/tests/client/test_async_client.py +++ b/tests/client/test_async_client.py @@ -3,6 +3,7 @@ import pytest import httpx +from tests.compat import nullcontext @pytest.mark.usefixtures("async_environment") @@ -83,19 +84,43 @@ async def hello_world(): @pytest.mark.usefixtures("async_environment") -async def test_raise_for_status(server): +@pytest.mark.parametrize( + "status_code, is_error_response", + [(200, False), (400, True), (404, True), (500, True), (505, True)], +) +async def test_raise_for_status_manual(server, status_code, is_error_response): async with httpx.AsyncClient() as client: - for status_code in (200, 400, 404, 500, 505): - response = await client.request( - "GET", server.url.copy_with(path=f"/status/{status_code}") + response = await client.request( + "GET", + server.url.copy_with(path=f"/status/{status_code}"), + raise_for_status=False, + ) + + if is_error_response: + with pytest.raises(httpx.HTTPError) as exc_info: + response.raise_for_status() + assert exc_info.value.response == response + else: + assert response.raise_for_status() is None + + +@pytest.mark.usefixtures("async_environment") +@pytest.mark.parametrize( + "status_code, is_error_response", + [(200, False), (400, True), (404, True), (500, True), (505, True)], +) +async def test_raise_for_status_auto(server, status_code, is_error_response): + async with httpx.AsyncClient() as client: + with ( + pytest.raises(httpx.HTTPError) if is_error_response else nullcontext() + ) as exc_info: + await client.request( + "GET", server.url.copy_with(path=f"/status/{status_code}"), ) - if 400 <= status_code < 600: - with pytest.raises(httpx.HTTPError) as exc_info: - response.raise_for_status() - assert exc_info.value.response == response - else: - assert response.raise_for_status() is None + if is_error_response: + assert exc_info.value.response is not None + assert exc_info.value.response.status_code == status_code @pytest.mark.usefixtures("async_environment") diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 8449b941e0..df6e42d33c 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -10,6 +10,7 @@ AsyncClient, Auth, DigestAuth, + HTTPError, ProtocolError, Request, RequestBodyUnavailable, @@ -252,8 +253,10 @@ async def test_digest_auth_401_response_without_digest_auth_header() -> None: auth = DigestAuth(username="tomchristie", password="password123") client = AsyncClient(dispatch=MockDispatch(auth_header="", status_code=401)) - response = await client.get(url, auth=auth) + with pytest.raises(HTTPError) as exc_info: + await client.get(url, auth=auth) + response = exc_info.value.response assert response.status_code == 401 assert response.json() == {"auth": None} assert len(response.history) == 0 @@ -373,8 +376,10 @@ async def test_digest_auth_incorrect_credentials() -> None: auth = DigestAuth(username="tomchristie", password="password123") client = AsyncClient(dispatch=MockDigestAuthDispatch(send_response_after_attempt=2)) - response = await client.get(url, auth=auth) + with pytest.raises(HTTPError) as exc_info: + await client.get(url, auth=auth) + response = exc_info.value.response assert response.status_code == 401 assert len(response.history) == 1 diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 71687e64b6..05099973b7 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -3,6 +3,7 @@ import pytest import httpx +from tests.compat import nullcontext def test_get(server): @@ -83,18 +84,39 @@ def test_raw_iterator(server): assert body == b"Hello, world!" -def test_raise_for_status(server): +@pytest.mark.parametrize( + "status_code, is_error_response", + [(200, False), (400, True), (404, True), (500, True), (505, True)], +) +def test_raise_for_status_manual(server, status_code, is_error_response): with httpx.Client() as client: - for status_code in (200, 400, 404, 500, 505): - response = client.request( - "GET", server.url.copy_with(path="/status/{}".format(status_code)) - ) - if 400 <= status_code < 600: - with pytest.raises(httpx.HTTPError) as exc_info: - response.raise_for_status() - assert exc_info.value.response == response - else: - assert response.raise_for_status() is None + response = client.request( + "GET", + server.url.copy_with(path="/status/{}".format(status_code)), + raise_for_status=False, + ) + if 400 <= status_code < 600: + with pytest.raises(httpx.HTTPError) as exc_info: + response.raise_for_status() + assert exc_info.value.response == response + else: + assert response.raise_for_status() is None + + +@pytest.mark.parametrize( + "status_code, is_error_response", + [(200, False), (400, True), (404, True), (500, True), (505, True)], +) +def test_raise_for_status_auto(server, status_code, is_error_response): + with httpx.Client() as client: + with ( + pytest.raises(httpx.HTTPError) if is_error_response else nullcontext() + ) as exc_info: + client.request("GET", server.url.copy_with(path=f"/status/{status_code}")) + + if is_error_response: + assert exc_info.value.response is not None + assert exc_info.value.response.status_code == status_code def test_options(server): diff --git a/tests/compat.py b/tests/compat.py new file mode 100644 index 0000000000..603741c075 --- /dev/null +++ b/tests/compat.py @@ -0,0 +1,9 @@ +try: + from contextlib import nullcontext +except ImportError: # pragma: no cover + # Python 3.6 + from contextlib import contextmanager + + @contextmanager + def nullcontext(): # type: ignore + yield None