From 9eb5103471dd43640c9a440a6d457b8d1c0187ff Mon Sep 17 00:00:00 2001 From: Stainless Bot Date: Fri, 22 Sep 2023 22:28:08 +0000 Subject: [PATCH] =?UTF-8?q?chore(internal):=20move=20error=20classes=20fro?= =?UTF-8?q?m=20=5Fbase=5Fexceptions=20to=20=5Fexceptions=20(=E2=9A=A0?= =?UTF-8?q?=EF=B8=8F=20breaking)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Migration Guide If you were instantiating our error classes directly, you may no longer pass a `request` kwarg (it is now pulled from the `response`). ```diff # before: - BadRequestError("Test", response=response, request=request) # after: + BadRequestError("Test", response=response) ``` --- src/finch/__init__.py | 16 ++--- src/finch/_base_client.py | 56 +++++----------- src/finch/_base_exceptions.py | 119 ---------------------------------- src/finch/_client.py | 67 ++++++++++++++++++- src/finch/_exceptions.py | 118 ++++++++++++++++++++++++++------- tests/test_client.py | 27 ++++++++ 6 files changed, 212 insertions(+), 191 deletions(-) delete mode 100644 src/finch/_base_exceptions.py diff --git a/src/finch/__init__.py b/src/finch/__init__.py index f5214f9a..aa714bb4 100644 --- a/src/finch/__init__.py +++ b/src/finch/__init__.py @@ -39,18 +39,18 @@ "Transport", "ProxiesTypes", "APIError", - "APIConnectionError", - "APIResponseValidationError", "APIStatusError", "APITimeoutError", - "AuthenticationError", + "APIConnectionError", + "APIResponseValidationError", "BadRequestError", - "ConflictError", - "InternalServerError", - "NotFoundError", + "AuthenticationError", "PermissionDeniedError", - "RateLimitError", + "NotFoundError", + "ConflictError", "UnprocessableEntityError", + "RateLimitError", + "InternalServerError", "Timeout", "RequestOptions", "Client", @@ -65,7 +65,7 @@ # Update the __module__ attribute for exported symbols so that # error messages point to this module instead of the module # it was originally defined in, e.g. -# finch._base_exceptions.NotFoundError -> finch.NotFoundError +# finch._exceptions.NotFoundError -> finch.NotFoundError __locals = locals() for __name in __all__: if not __name.startswith("__"): diff --git a/src/finch/_base_client.py b/src/finch/_base_client.py index 629e3483..31a98237 100644 --- a/src/finch/_base_client.py +++ b/src/finch/_base_client.py @@ -33,7 +33,7 @@ from httpx import URL, Limits from pydantic import PrivateAttr -from . import _base_exceptions as exceptions +from . import _exceptions from ._qs import Querystring from ._types import ( NOT_GIVEN, @@ -64,7 +64,7 @@ construct_type, ) from ._streaming import Stream, AsyncStream -from ._base_exceptions import ( +from ._exceptions import ( APIStatusError, APITimeoutError, APIConnectionError, @@ -335,7 +335,6 @@ def __init__( def _make_status_error_from_response( self, - request: httpx.Request, response: httpx.Response, ) -> APIStatusError: err_text = response.text.strip() @@ -347,33 +346,16 @@ def _make_status_error_from_response( except Exception: err_msg = err_text or f"Error code: {response.status_code}" - return self._make_status_error(err_msg, body=body, request=request, response=response) + return self._make_status_error(err_msg, body=body, response=response) def _make_status_error( self, err_msg: str, *, body: object, - request: httpx.Request, response: httpx.Response, - ) -> APIStatusError: - if response.status_code == 400: - return exceptions.BadRequestError(err_msg, request=request, response=response, body=body) - if response.status_code == 401: - return exceptions.AuthenticationError(err_msg, request=request, response=response, body=body) - if response.status_code == 403: - return exceptions.PermissionDeniedError(err_msg, request=request, response=response, body=body) - if response.status_code == 404: - return exceptions.NotFoundError(err_msg, request=request, response=response, body=body) - if response.status_code == 409: - return exceptions.ConflictError(err_msg, request=request, response=response, body=body) - if response.status_code == 422: - return exceptions.UnprocessableEntityError(err_msg, request=request, response=response, body=body) - if response.status_code == 429: - return exceptions.RateLimitError(err_msg, request=request, response=response, body=body) - if response.status_code >= 500: - return exceptions.InternalServerError(err_msg, request=request, response=response, body=body) - return APIStatusError(err_msg, request=request, response=response, body=body) + ) -> _exceptions.APIStatusError: + raise NotImplementedError() def _remaining_retries( self, @@ -532,10 +514,10 @@ def _process_response( content_type, *_ = response.headers.get("content-type").split(";") if content_type != "application/json": if self._strict_response_validation: - raise exceptions.APIResponseValidationError( + raise APIResponseValidationError( response=response, - request=response.request, message=f"Expected Content-Type response header to be `application/json` but received `{content_type}` instead.", + body=response.text, ) # If the API responds with content that isn't JSON then we just return @@ -544,7 +526,11 @@ def _process_response( return response.text # type: ignore data = response.json() - return self._process_response_data(data=data, cast_to=cast_to, response=response) + + try: + return self._process_response_data(data=data, cast_to=cast_to, response=response) + except pydantic.ValidationError as err: + raise APIResponseValidationError(response=response, body=data) from err def _process_response_data( self, @@ -826,7 +812,7 @@ def _request( # If the response is streamed then we need to explicitly read the response # to completion before attempting to access the response text. err.response.read() - raise self._make_status_error_from_response(request, err.response) from None + raise self._make_status_error_from_response(err.response) from None except httpx.TimeoutException as err: if retries > 0: return self._retry_request(options, cast_to, retries, stream=stream, stream_cls=stream_cls) @@ -845,12 +831,7 @@ def _request( raise MissingStreamClassError() return stream_cls(cast_to=cast_to, response=response, client=self) - try: - rsp = self._process_response(cast_to=cast_to, options=options, response=response) - except pydantic.ValidationError as err: - raise APIResponseValidationError(request=request, response=response) from err - - return rsp + return self._process_response(cast_to=cast_to, options=options, response=response) def _retry_request( self, @@ -1184,7 +1165,7 @@ async def _request( # If the response is streamed then we need to explicitly read the response # to completion before attempting to access the response text. await err.response.aread() - raise self._make_status_error_from_response(request, err.response) from None + raise self._make_status_error_from_response(err.response) from None except httpx.ConnectTimeout as err: if retries > 0: return await self._retry_request(options, cast_to, retries, stream=stream, stream_cls=stream_cls) @@ -1213,12 +1194,7 @@ async def _request( raise MissingStreamClassError() return stream_cls(cast_to=cast_to, response=response, client=self) - try: - rsp = self._process_response(cast_to=cast_to, options=options, response=response) - except pydantic.ValidationError as err: - raise APIResponseValidationError(request=request, response=response) from err - - return rsp + return self._process_response(cast_to=cast_to, options=options, response=response) async def _retry_request( self, diff --git a/src/finch/_base_exceptions.py b/src/finch/_base_exceptions.py deleted file mode 100644 index e2ba6aa4..00000000 --- a/src/finch/_base_exceptions.py +++ /dev/null @@ -1,119 +0,0 @@ -from __future__ import annotations - -from typing_extensions import Literal - -from httpx import Request, Response - - -class APIError(Exception): - message: str - request: Request - - def __init__(self, message: str, request: Request) -> None: - super().__init__(message) - self.request = request - self.message = message - - -class APIResponseValidationError(APIError): - response: Response - status_code: int - - def __init__(self, request: Request, response: Response, *, message: str | None = None) -> None: - super().__init__(message or "Data returned by API invalid for expected schema.", request) - self.response = response - self.status_code = response.status_code - - -class APIStatusError(APIError): - """Raised when an API response has a status code of 4xx or 5xx.""" - - response: Response - status_code: int - - body: object - """The API response body. - - If the API responded with a valid JSON structure then this property will be the decoded result. - If it isn't a valid JSON structure then this will be the raw response. - """ - - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request) - self.response = response - self.status_code = response.status_code - self.body = body - - -class BadRequestError(APIStatusError): - status_code: Literal[400] - - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request=request, response=response, body=body) - self.status_code = 400 - - -class AuthenticationError(APIStatusError): - status_code: Literal[401] - - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request=request, response=response, body=body) - self.status_code = 401 - - -class PermissionDeniedError(APIStatusError): - status_code: Literal[403] - - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request=request, response=response, body=body) - self.status_code = 403 - - -class NotFoundError(APIStatusError): - status_code: Literal[404] - - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request=request, response=response, body=body) - self.status_code = 404 - - -class ConflictError(APIStatusError): - status_code: Literal[409] - - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request=request, response=response, body=body) - self.status_code = 409 - - -class UnprocessableEntityError(APIStatusError): - status_code: Literal[422] - - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request=request, response=response, body=body) - self.status_code = 422 - - -class RateLimitError(APIStatusError): - status_code: Literal[429] - - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request=request, response=response, body=body) - self.status_code = 429 - - -class InternalServerError(APIStatusError): - status_code: int - - def __init__(self, message: str, *, request: Request, response: Response, body: object) -> None: - super().__init__(message, request=request, response=response, body=body) - self.status_code = response.status_code - - -class APIConnectionError(APIError): - def __init__(self, request: Request, message: str = "Connection error.") -> None: - super().__init__(message, request) - - -class APITimeoutError(APIConnectionError): - def __init__(self, request: Request) -> None: - super().__init__(request, "Request timed out.") diff --git a/src/finch/_client.py b/src/finch/_client.py index 95dcba16..17d3e583 100644 --- a/src/finch/_client.py +++ b/src/finch/_client.py @@ -8,7 +8,7 @@ import httpx -from . import resources +from . import resources, _exceptions from ._qs import Querystring from ._types import ( NOT_GIVEN, @@ -23,6 +23,7 @@ from ._version import __version__ from ._streaming import Stream as Stream from ._streaming import AsyncStream as AsyncStream +from ._exceptions import APIStatusError from ._base_client import ( DEFAULT_LIMITS, DEFAULT_TIMEOUT, @@ -274,6 +275,38 @@ def get_auth_url( ) ) + def _make_status_error( + self, + err_msg: str, + *, + body: object, + response: httpx.Response, + ) -> APIStatusError: + if response.status_code == 400: + return _exceptions.BadRequestError(err_msg, response=response, body=body) + + if response.status_code == 401: + return _exceptions.AuthenticationError(err_msg, response=response, body=body) + + if response.status_code == 403: + return _exceptions.PermissionDeniedError(err_msg, response=response, body=body) + + if response.status_code == 404: + return _exceptions.NotFoundError(err_msg, response=response, body=body) + + if response.status_code == 409: + return _exceptions.ConflictError(err_msg, response=response, body=body) + + if response.status_code == 422: + return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body) + + if response.status_code == 429: + return _exceptions.RateLimitError(err_msg, response=response, body=body) + + if response.status_code >= 500: + return _exceptions.InternalServerError(err_msg, response=response, body=body) + return APIStatusError(err_msg, response=response, body=body) + class AsyncFinch(AsyncAPIClient): hris: resources.AsyncHRIS @@ -508,6 +541,38 @@ def get_auth_url( ) ) + def _make_status_error( + self, + err_msg: str, + *, + body: object, + response: httpx.Response, + ) -> APIStatusError: + if response.status_code == 400: + return _exceptions.BadRequestError(err_msg, response=response, body=body) + + if response.status_code == 401: + return _exceptions.AuthenticationError(err_msg, response=response, body=body) + + if response.status_code == 403: + return _exceptions.PermissionDeniedError(err_msg, response=response, body=body) + + if response.status_code == 404: + return _exceptions.NotFoundError(err_msg, response=response, body=body) + + if response.status_code == 409: + return _exceptions.ConflictError(err_msg, response=response, body=body) + + if response.status_code == 422: + return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body) + + if response.status_code == 429: + return _exceptions.RateLimitError(err_msg, response=response, body=body) + + if response.status_code >= 500: + return _exceptions.InternalServerError(err_msg, response=response, body=body) + return APIStatusError(err_msg, response=response, body=body) + Client = Finch diff --git a/src/finch/_exceptions.py b/src/finch/_exceptions.py index ffdf6a23..cfcd58af 100644 --- a/src/finch/_exceptions.py +++ b/src/finch/_exceptions.py @@ -1,31 +1,103 @@ # File generated from our OpenAPI spec by Stainless. -from ._base_exceptions import APIError as APIError -from ._base_exceptions import ConflictError as ConflictError -from ._base_exceptions import NotFoundError as NotFoundError -from ._base_exceptions import APIStatusError as APIStatusError -from ._base_exceptions import RateLimitError as RateLimitError -from ._base_exceptions import APITimeoutError as APITimeoutError -from ._base_exceptions import BadRequestError as BadRequestError -from ._base_exceptions import APIConnectionError as APIConnectionError -from ._base_exceptions import AuthenticationError as AuthenticationError -from ._base_exceptions import InternalServerError as InternalServerError -from ._base_exceptions import PermissionDeniedError as PermissionDeniedError -from ._base_exceptions import UnprocessableEntityError as UnprocessableEntityError -from ._base_exceptions import APIResponseValidationError as APIResponseValidationError +from __future__ import annotations + +from typing_extensions import Literal + +import httpx __all__ = [ - "APIError", - "APIConnectionError", - "APIResponseValidationError", - "APIStatusError", - "APITimeoutError", - "AuthenticationError", "BadRequestError", - "ConflictError", - "InternalServerError", - "NotFoundError", + "AuthenticationError", "PermissionDeniedError", - "RateLimitError", + "NotFoundError", + "ConflictError", "UnprocessableEntityError", + "RateLimitError", + "InternalServerError", ] + + +class APIError(Exception): + message: str + request: httpx.Request + + body: object | None + """The API response body. + + If the API responded with a valid JSON structure then this property will be the + decoded result. + + If it isn't a valid JSON structure then this will be the raw response. + + If there was no response associated with this error then it will be `None`. + """ + + def __init__(self, message: str, request: httpx.Request, *, body: object | None) -> None: + super().__init__(message) + self.request = request + self.message = message + + +class APIResponseValidationError(APIError): + response: httpx.Response + status_code: int + + def __init__(self, response: httpx.Response, body: object | None, *, message: str | None = None) -> None: + super().__init__(message or "Data returned by API invalid for expected schema.", response.request, body=body) + self.response = response + self.status_code = response.status_code + + +class APIStatusError(APIError): + """Raised when an API response has a status code of 4xx or 5xx.""" + + response: httpx.Response + status_code: int + + def __init__(self, message: str, *, response: httpx.Response, body: object | None) -> None: + super().__init__(message, response.request, body=body) + self.response = response + self.status_code = response.status_code + + +class APIConnectionError(APIError): + def __init__(self, *, message: str = "Connection error.", request: httpx.Request) -> None: + super().__init__(message, request, body=None) + + +class APITimeoutError(APIConnectionError): + def __init__(self, request: httpx.Request) -> None: + super().__init__(message="Request timed out.", request=request) + + +class BadRequestError(APIStatusError): + status_code: Literal[400] = 400 + + +class AuthenticationError(APIStatusError): + status_code: Literal[401] = 401 + + +class PermissionDeniedError(APIStatusError): + status_code: Literal[403] = 403 + + +class NotFoundError(APIStatusError): + status_code: Literal[404] = 404 + + +class ConflictError(APIStatusError): + status_code: Literal[409] = 409 + + +class UnprocessableEntityError(APIStatusError): + status_code: Literal[422] = 422 + + +class RateLimitError(APIStatusError): + status_code: Literal[429] = 429 + + +class InternalServerError(APIStatusError): + pass diff --git a/tests/test_client.py b/tests/test_client.py index 15ebdb4e..95b22d49 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -11,10 +11,12 @@ import httpx import pytest from respx import MockRouter +from pydantic import ValidationError from finch import Finch, AsyncFinch, APIResponseValidationError from finch._types import Omit from finch._models import BaseModel, FinalRequestOptions +from finch._exceptions import APIResponseValidationError from finch._base_client import BaseClient, make_request_options base_url = os.environ.get("API_BASE_URL", "http://127.0.0.1:4010") @@ -385,6 +387,18 @@ def test_client_context_manager(self) -> None: assert not client.is_closed() assert client.is_closed() + @pytest.mark.respx(base_url=base_url) + def test_client_response_validation_error(self, respx_mock: MockRouter) -> None: + class Model(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) + + with pytest.raises(APIResponseValidationError) as exc: + self.client.get("/foo", cast_to=Model) + + assert isinstance(exc.value.__cause__, ValidationError) + @pytest.mark.respx(base_url=base_url) def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None: class Model(BaseModel): @@ -762,6 +776,19 @@ async def test_client_context_manager(self) -> None: assert not client.is_closed() assert client.is_closed() + @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + async def test_client_response_validation_error(self, respx_mock: MockRouter) -> None: + class Model(BaseModel): + foo: str + + respx_mock.get("/foo").mock(return_value=httpx.Response(200, json={"foo": {"invalid": True}})) + + with pytest.raises(APIResponseValidationError) as exc: + await self.client.get("/foo", cast_to=Model) + + assert isinstance(exc.value.__cause__, ValidationError) + @pytest.mark.respx(base_url=base_url) @pytest.mark.asyncio async def test_received_text_for_expected_json(self, respx_mock: MockRouter) -> None: