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

Commit 21ee98b

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

File tree

10 files changed

+206
-13
lines changed

10 files changed

+206
-13
lines changed

src/prisma/_async_http.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,28 @@
44

55
import httpx
66

7+
from .utils import ExcConverter
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+
convert_exc = ExcConverter(
16+
{
17+
httpx.ConnectTimeout: HTTPClientTimeoutError,
18+
httpx.ReadTimeout: HTTPClientTimeoutError,
19+
httpx.WriteTimeout: HTTPClientTimeoutError,
20+
httpx.PoolTimeout: HTTPClientTimeoutError,
21+
}
22+
)
23+
24+
1325
class AsyncHTTP(AbstractHTTP[httpx.AsyncClient, httpx.Response]):
1426
session: httpx.AsyncClient
1527

28+
@convert_exc
1629
@override
1730
async def download(self, url: str, dest: str) -> None:
1831
async with self.session.stream('GET', url, timeout=None) as resp:
@@ -21,14 +34,17 @@ async def download(self, url: str, dest: str) -> None:
2134
async for chunk in resp.aiter_bytes():
2235
fd.write(chunk)
2336

37+
@convert_exc
2438
@override
2539
async def request(self, method: Method, url: str, **kwargs: Any) -> 'Response':
2640
return Response(await self.session.request(method, url, **kwargs))
2741

42+
@convert_exc
2843
@override
2944
def open(self) -> None:
3045
self.session = httpx.AsyncClient(**self.session_kwargs)
3146

47+
@convert_exc
3248
@override
3349
async def close(self) -> None:
3450
if self.should_close():

src/prisma/_constants.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
1-
from typing import Dict
1+
from typing import Any, Dict
22
from datetime import timedelta
33

4+
import httpx
5+
46
DEFAULT_CONNECT_TIMEOUT: timedelta = timedelta(seconds=10)
57
DEFAULT_TX_MAX_WAIT: timedelta = timedelta(milliseconds=2000)
68
DEFAULT_TX_TIMEOUT: timedelta = timedelta(milliseconds=5000)
79

10+
DEFAULT_HTTP_LIMITS: httpx.Limits = httpx.Limits(max_connections=1000)
11+
DEFAULT_HTTP_TIMEOUT: httpx.Timeout = httpx.Timeout(30)
12+
DEFAULT_HTTP_CONFIG: Dict[str, Any] = {
13+
'limits': DEFAULT_HTTP_LIMITS,
14+
'timeout': DEFAULT_HTTP_TIMEOUT,
15+
}
16+
817
# key aliases to transform query arguments to make them more pythonic
918
QUERY_BUILDER_ALIASES: Dict[str, str] = {
1019
'startswith': 'startsWith',

src/prisma/_sync_http.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,28 @@
33

44
import httpx
55

6+
from .utils import ExcConverter
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+
convert_exc = ExcConverter(
15+
{
16+
httpx.ConnectTimeout: HTTPClientTimeoutError,
17+
httpx.ReadTimeout: HTTPClientTimeoutError,
18+
httpx.WriteTimeout: HTTPClientTimeoutError,
19+
httpx.PoolTimeout: HTTPClientTimeoutError,
20+
}
21+
)
22+
23+
1224
class SyncHTTP(AbstractHTTP[httpx.Client, httpx.Response]):
1325
session: httpx.Client
1426

27+
@convert_exc
1528
@override
1629
def download(self, url: str, dest: str) -> None:
1730
with self.session.stream('GET', url, timeout=None) as resp:
@@ -20,14 +33,17 @@ def download(self, url: str, dest: str) -> None:
2033
for chunk in resp.iter_bytes():
2134
fd.write(chunk)
2235

36+
@convert_exc
2337
@override
2438
def request(self, method: Method, url: str, **kwargs: Any) -> 'Response':
2539
return Response(self.session.request(method, url, **kwargs))
2640

41+
@convert_exc
2742
@override
2843
def open(self) -> None:
2944
self.session = httpx.Client(**self.session_kwargs)
3045

46+
@convert_exc
3147
@override
3248
def close(self) -> None:
3349
if self.should_close():

src/prisma/_types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
FuncType = Callable[..., object]
2424
CoroType = Callable[..., Coroutine[Any, Any, object]]
2525

26+
ExcMapping = Mapping[Type[BaseException], Type[BaseException]]
27+
2628

2729
@runtime_checkable
2830
class InheritsGeneric(Protocol):

src/prisma/errors.py

Lines changed: 9 additions & 0 deletions
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,14 @@ 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__(
51+
'HTTP operation has timed out.\n'
52+
'The default timeout is 30 seconds. Maybe you should increase it: prisma.Prisma(http_config={"timeout": httpx.Timeout(30)})'
53+
)
54+
55+
4756
class UnsupportedDatabaseError(PrismaError):
4857
context: str
4958
database: str

src/prisma/http_abstract.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,18 @@
1212
)
1313
from typing_extensions import override
1414

15-
from httpx import Limits, Headers, Timeout
15+
from httpx import Headers
1616

1717
from .utils import _NoneType
1818
from ._types import Method
1919
from .errors import HTTPClientClosedError
20+
from ._constants import DEFAULT_HTTP_CONFIG
2021

2122
Session = TypeVar('Session')
2223
Response = TypeVar('Response')
2324
ReturnType = TypeVar('ReturnType')
2425
MaybeCoroutine = Union[Coroutine[Any, Any, ReturnType], ReturnType]
2526

26-
DEFAULT_CONFIG: Dict[str, Any] = {
27-
'limits': Limits(max_connections=1000),
28-
'timeout': Timeout(30),
29-
}
30-
3127

3228
class AbstractHTTP(ABC, Generic[Session, Response]):
3329
session_kwargs: Dict[str, Any]
@@ -45,7 +41,7 @@ def __init__(self, **kwargs: Any) -> None:
4541
# Session = open
4642
self._session: Optional[Union[Session, Type[_NoneType]]] = _NoneType
4743
self.session_kwargs = {
48-
**DEFAULT_CONFIG,
44+
**DEFAULT_HTTP_CONFIG,
4945
**kwargs,
5046
}
5147

src/prisma/utils.py

Lines changed: 57 additions & 2 deletions
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 MaybeAsyncContextDecorator(contextlib.ContextDecorator):
148+
"""`ContextDecorator` compatible with sync/async functions."""
149+
150+
def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]: # type: ignore[override]
151+
@functools.wraps(func)
152+
async def async_inner(*args: Any, **kwargs: Any) -> object:
153+
async with self._recreate_cm(): # type: ignore[attr-defined]
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[attr-defined]
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 ExcConverter(MaybeAsyncContextDecorator):
168+
"""`MaybeAsyncContextDecorator` to convert exceptions."""
169+
170+
def __init__(self, exc_mapping: ExcMapping) -> None:
171+
self._exc_mapping = exc_mapping
172+
173+
def __enter__(self) -> 'ExcConverter':
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+
target_exc_type = self._exc_mapping.get(exc_type)
184+
if target_exc_type is not None:
185+
raise target_exc_type() from exc
186+
187+
async def __aenter__(self) -> 'ExcConverter':
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_client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@
1010
from prisma import ENGINE_TYPE, SCHEMA_PATH, Prisma, errors, get_client
1111
from prisma.types import HttpConfig
1212
from prisma.testing import reset_client
13+
from prisma._constants import DEFAULT_HTTP_CONFIG
1314
from prisma.cli.prisma import run
1415
from prisma.engine.http import HTTPEngine
1516
from prisma.engine.errors import AlreadyConnectedError
16-
from prisma.http_abstract import DEFAULT_CONFIG
1717

1818
from .utils import Testdir, patch_method
1919

@@ -140,7 +140,7 @@ async def _test(config: HttpConfig) -> None:
140140

141141
captured = getter()
142142
assert captured is not None
143-
assert captured == ((), {**DEFAULT_CONFIG, **config})
143+
assert captured == ((), {**DEFAULT_HTTP_CONFIG, **config})
144144

145145
await _test({'timeout': 1})
146146
await _test({'timeout': httpx.Timeout(5, connect=10, read=30)})

tests/test_http.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22

33
import httpx
44
import pytest
5+
from pytest_mock import MockerFixture
56

67
from prisma.http import HTTP
78
from prisma.utils import _NoneType
89
from prisma._types import Literal
9-
from prisma.errors import HTTPClientClosedError
10+
from prisma.errors import HTTPClientClosedError, HTTPClientTimeoutError
1011

1112
from .utils import patch_method
1213

@@ -81,3 +82,26 @@ async def test_httpx_default_config(monkeypatch: 'MonkeyPatch') -> None:
8182
'timeout': httpx.Timeout(30),
8283
},
8384
)
85+
86+
87+
@pytest.mark.asyncio
88+
@pytest.mark.parametrize(
89+
'httpx_error',
90+
[
91+
httpx.ConnectTimeout(''),
92+
httpx.ReadTimeout(''),
93+
httpx.WriteTimeout(''),
94+
httpx.PoolTimeout(''),
95+
],
96+
)
97+
async def test_http_timeout_error(httpx_error: BaseException, mocker: MockerFixture) -> None:
98+
"""Ensure that `httpx.TimeoutException` is converted to `prisma.errors.HTTPClientTimeoutError`."""
99+
mocker.patch('httpx.AsyncClient.request', side_effect=httpx_error)
100+
101+
http = HTTP()
102+
http.open()
103+
104+
with pytest.raises(HTTPClientTimeoutError) as exc_info:
105+
await http.request('GET', '/')
106+
107+
assert exc_info.value.__cause__ == httpx_error

tests/test_utils.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import asyncio
2+
from typing import Type, NoReturn
3+
4+
import pytest
5+
6+
from prisma.utils import ExcConverter
7+
8+
9+
@pytest.mark.asyncio
10+
@pytest.mark.parametrize(
11+
('convert_exc', 'raised_exc_type', 'expected_exc_type', 'should_be_converted'),
12+
[
13+
pytest.param(ExcConverter({ValueError: ImportError}), ValueError, ImportError, True, id='should convert'),
14+
pytest.param(
15+
ExcConverter({ValueError: ImportError}), RuntimeError, RuntimeError, False, id='should not convert'
16+
),
17+
],
18+
)
19+
async def test_exc_converter(
20+
convert_exc: ExcConverter,
21+
raised_exc_type: Type[BaseException],
22+
expected_exc_type: Type[BaseException],
23+
should_be_converted: bool,
24+
) -> None:
25+
"""Ensure that `prisma.utils.ExcConverter` works as expected."""
26+
27+
# Test sync context manager
28+
with pytest.raises(expected_exc_type) as exc_info_1:
29+
with convert_exc:
30+
raise raised_exc_type()
31+
32+
# Test async context manager
33+
with pytest.raises(expected_exc_type) as exc_info_2:
34+
async with convert_exc:
35+
await asyncio.sleep(0.1)
36+
raise raised_exc_type()
37+
38+
# Test sync decorator
39+
with pytest.raises(expected_exc_type) as exc_info_3:
40+
41+
@convert_exc
42+
def help_func() -> NoReturn:
43+
raise raised_exc_type()
44+
45+
help_func()
46+
47+
# Test async decorator
48+
with pytest.raises(expected_exc_type) as exc_info_4:
49+
50+
@convert_exc
51+
async def help_func() -> NoReturn:
52+
await asyncio.sleep(0.1)
53+
raise raised_exc_type()
54+
55+
await help_func()
56+
57+
# Test exception cause
58+
if should_be_converted:
59+
assert all(
60+
(
61+
type(exc_info_1.value.__cause__) is raised_exc_type,
62+
type(exc_info_2.value.__cause__) is raised_exc_type,
63+
type(exc_info_3.value.__cause__) is raised_exc_type,
64+
type(exc_info_4.value.__cause__) is raised_exc_type,
65+
)
66+
)

0 commit comments

Comments
 (0)