Skip to content
This repository was archived by the owner on Apr 15, 2025. It is now read-only.

Commit d179a8d

Browse files
committed
feat(client): improve error message for http timeouts
1 parent b690482 commit d179a8d

File tree

6 files changed

+99
-3
lines changed

6 files changed

+99
-3
lines changed

src/prisma/_async_http.py

+11
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,23 @@
44

55
import httpx
66

7+
from .utils import convert_exc
78
from ._types import Method
9+
from .errors import HTTPClientTimeoutError
810
from .http_abstract import AbstractHTTP, AbstractResponse
911

1012
__all__ = ('HTTP', 'AsyncHTTP', 'Response', 'client')
1113

1214

15+
_ASYNC_HTTP_EXC_MAPPING = {
16+
httpx.TimeoutException: HTTPClientTimeoutError,
17+
}
18+
19+
1320
class AsyncHTTP(AbstractHTTP[httpx.AsyncClient, httpx.Response]):
1421
session: httpx.AsyncClient
1522

23+
@convert_exc(_ASYNC_HTTP_EXC_MAPPING) # type: ignore[arg-type]
1624
@override
1725
async def download(self, url: str, dest: str) -> None:
1826
async with self.session.stream('GET', url, timeout=None) as resp:
@@ -21,14 +29,17 @@ async def download(self, url: str, dest: str) -> None:
2129
async for chunk in resp.aiter_bytes():
2230
fd.write(chunk)
2331

32+
@convert_exc(_ASYNC_HTTP_EXC_MAPPING) # type: ignore[arg-type]
2433
@override
2534
async def request(self, method: Method, url: str, **kwargs: Any) -> 'Response':
2635
return Response(await self.session.request(method, url, **kwargs))
2736

37+
@convert_exc(_ASYNC_HTTP_EXC_MAPPING) # type: ignore[arg-type]
2838
@override
2939
def open(self) -> None:
3040
self.session = httpx.AsyncClient(**self.session_kwargs)
3141

42+
@convert_exc(_ASYNC_HTTP_EXC_MAPPING) # type: ignore[arg-type]
3243
@override
3344
async def close(self) -> None:
3445
if self.should_close():

src/prisma/_sync_http.py

+11
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,23 @@
33

44
import httpx
55

6+
from .utils import convert_exc
67
from ._types import Method
8+
from .errors import HTTPClientTimeoutError
79
from .http_abstract import AbstractHTTP, AbstractResponse
810

911
__all__ = ('HTTP', 'SyncHTTP', 'Response', 'client')
1012

1113

14+
_SYNC_HTTP_EXC_MAPPING = {
15+
httpx.TimeoutException: HTTPClientTimeoutError,
16+
}
17+
18+
1219
class SyncHTTP(AbstractHTTP[httpx.Client, httpx.Response]):
1320
session: httpx.Client
1421

22+
@convert_exc(_SYNC_HTTP_EXC_MAPPING) # type: ignore[arg-type]
1523
@override
1624
def download(self, url: str, dest: str) -> None:
1725
with self.session.stream('GET', url, timeout=None) as resp:
@@ -20,14 +28,17 @@ def download(self, url: str, dest: str) -> None:
2028
for chunk in resp.iter_bytes():
2129
fd.write(chunk)
2230

31+
@convert_exc(_SYNC_HTTP_EXC_MAPPING) # type: ignore[arg-type]
2332
@override
2433
def request(self, method: Method, url: str, **kwargs: Any) -> 'Response':
2534
return Response(self.session.request(method, url, **kwargs))
2635

36+
@convert_exc(_SYNC_HTTP_EXC_MAPPING) # type: ignore[arg-type]
2737
@override
2838
def open(self) -> None:
2939
self.session = httpx.Client(**self.session_kwargs)
3040

41+
@convert_exc(_SYNC_HTTP_EXC_MAPPING) # type: ignore[arg-type]
3142
@override
3243
def close(self) -> None:
3344
if self.should_close():

src/prisma/_types.py

+3
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
FuncType = Callable[..., object]
2424
CoroType = Callable[..., Coroutine[Any, Any, object]]
2525

26+
ExcT = TypeVar('ExcT', bound=BaseException)
27+
ExcMapping = Mapping[Type[BaseException], Type[BaseException]]
28+
2629

2730
@runtime_checkable
2831
class InheritsGeneric(Protocol):

src/prisma/errors.py

+6
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
'TableNotFoundError',
1313
'RecordNotFoundError',
1414
'HTTPClientClosedError',
15+
'HTTPClientTimeoutError',
1516
'ClientNotConnectedError',
1617
'PrismaWarning',
1718
'UnsupportedSubclassWarning',
@@ -44,6 +45,11 @@ def __init__(self) -> None:
4445
super().__init__('Cannot make a request from a closed client.')
4546

4647

48+
class HTTPClientTimeoutError(PrismaError):
49+
def __init__(self) -> None:
50+
super().__init__('HTTP operation has timed out.')
51+
52+
4753
class UnsupportedDatabaseError(PrismaError):
4854
context: str
4955
database: str

src/prisma/utils.py

+57-2
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
import inspect
77
import logging
88
import warnings
9+
import functools
910
import contextlib
10-
from typing import TYPE_CHECKING, Any, Dict, Union, TypeVar, Iterator, NoReturn, Coroutine
11+
from types import TracebackType
12+
from typing import TYPE_CHECKING, Any, Dict, Type, Union, TypeVar, Callable, Iterator, NoReturn, Optional, Coroutine
1113
from importlib.util import find_spec
1214

13-
from ._types import CoroType, FuncType, TypeGuard
15+
from ._types import CoroType, FuncType, TypeGuard, ExcMapping
1416

1517
if TYPE_CHECKING:
1618
from typing_extensions import TypeGuard
@@ -139,3 +141,56 @@ def make_optional(value: _T) -> _T | None:
139141

140142
def is_dict(obj: object) -> TypeGuard[dict[object, object]]:
141143
return isinstance(obj, dict)
144+
145+
146+
# TODO: improve typing
147+
class SyncAsyncContextDecorator(contextlib.ContextDecorator):
148+
"""`ContextDecorator` compatible with sync/async functions."""
149+
150+
def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]: # type:ignore
151+
@functools.wraps(func)
152+
async def async_inner(*args: Any, **kwargs: Any) -> object:
153+
async with self._recreate_cm(): # type: ignore
154+
return await func(*args, **kwargs)
155+
156+
@functools.wraps(func)
157+
def sync_inner(*args: Any, **kwargs: Any) -> object:
158+
with self._recreate_cm(): # type: ignore
159+
return func(*args, **kwargs)
160+
161+
if is_coroutine(func):
162+
return async_inner
163+
else:
164+
return sync_inner
165+
166+
167+
class convert_exc(SyncAsyncContextDecorator):
168+
"""`SyncAsyncContextDecorator` to convert exceptions."""
169+
170+
def __init__(self, exc_mapping: ExcMapping) -> None:
171+
self._exc_mapping = exc_mapping
172+
173+
def __enter__(self) -> 'convert_exc':
174+
return self
175+
176+
def __exit__(
177+
self,
178+
exc_type: Optional[Type[BaseException]],
179+
exc: Optional[BaseException],
180+
exc_tb: Optional[TracebackType],
181+
) -> None:
182+
if exc is not None and exc_type is not None:
183+
for source_exc_type, target_exc_type in self._exc_mapping.items():
184+
if issubclass(exc_type, source_exc_type):
185+
raise target_exc_type(exc) from exc
186+
187+
async def __aenter__(self) -> 'convert_exc':
188+
return self.__enter__()
189+
190+
async def __aexit__(
191+
self,
192+
exc_type: Optional[Type[BaseException]],
193+
exc: Optional[BaseException],
194+
exc_tb: Optional[TracebackType],
195+
) -> None:
196+
self.__exit__(exc_type, exc, exc_tb)

tests/test_http.py

+11-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from prisma.http import HTTP
77
from prisma.utils import _NoneType
88
from prisma._types import Literal
9-
from prisma.errors import HTTPClientClosedError
9+
from prisma.errors import HTTPClientClosedError, HTTPClientTimeoutError
1010

1111
from .utils import patch_method
1212

@@ -81,3 +81,13 @@ async def test_httpx_default_config(monkeypatch: 'MonkeyPatch') -> None:
8181
'timeout': httpx.Timeout(30),
8282
},
8383
)
84+
85+
86+
@pytest.mark.asyncio
87+
async def test_http_timeout_error() -> None:
88+
"""Ensure that `httpx.TimeoutException` is converted to `prisma.errors.HTTPClientTimeoutError`."""
89+
http = HTTP(timeout=httpx.Timeout(1e-6))
90+
http.open()
91+
with pytest.raises(HTTPClientTimeoutError) as exc_info:
92+
await http.request('GET', 'https://google.com')
93+
assert type(exc_info.value.__cause__) is httpx.TimeoutException

0 commit comments

Comments
 (0)