typact 0.2.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {typact-0.2.0/src/typact.egg-info → typact-0.2.1}/PKG-INFO +24 -1
- {typact-0.2.0 → typact-0.2.1}/README.md +23 -0
- {typact-0.2.0 → typact-0.2.1}/pyproject.toml +1 -1
- {typact-0.2.0 → typact-0.2.1}/src/typact/client/decorator.py +15 -2
- {typact-0.2.0 → typact-0.2.1}/src/typact/client/http_client.py +161 -30
- {typact-0.2.0 → typact-0.2.1}/src/typact/client/metadata.py +7 -0
- {typact-0.2.0 → typact-0.2.1/src/typact.egg-info}/PKG-INFO +24 -1
- {typact-0.2.0 → typact-0.2.1}/tests/test_http_client_request.py +91 -0
- {typact-0.2.0 → typact-0.2.1}/LICENSE +0 -0
- {typact-0.2.0 → typact-0.2.1}/setup.cfg +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/__init__.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/annotations/__init__.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/annotations/base.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/annotations/body.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/annotations/cookie.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/annotations/file.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/annotations/form.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/annotations/header.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/annotations/path.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/annotations/query.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/builder/__init__.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/builder/multipart_builder.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/builder/request_builder.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/builder/url_builder.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/client/__init__.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/converter/__init__.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/converter/file_converter.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/converter/json_converter.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/converter/response_converter.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/converter/sse_converter.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/converter/stream_converter.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/core/__init__.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/core/errors.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/core/retry.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/core/types.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/interceptor/__init__.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/interceptor/auth.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/interceptor/base.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/interceptor/log.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/interceptor/trace.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/runtime/__init__.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/runtime/aiohttp.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/runtime/base.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/runtime/httpx.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/runtime/mock.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/runtime/urllib.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/testing/__init__.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact/testing/mock_runtime.py +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact.egg-info/SOURCES.txt +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact.egg-info/dependency_links.txt +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact.egg-info/requires.txt +0 -0
- {typact-0.2.0 → typact-0.2.1}/src/typact.egg-info/top_level.txt +0 -0
- {typact-0.2.0 → typact-0.2.1}/tests/test_runtime_integration.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: typact
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Add your description here
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Python: >=3.13
|
|
@@ -257,6 +257,29 @@ client = HttpClient(
|
|
|
257
257
|
)
|
|
258
258
|
```
|
|
259
259
|
|
|
260
|
+
单个接口可覆盖 Client 默认策略;显式传入 `None` 可关闭该默认值:
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
from collections.abc import AsyncIterator
|
|
264
|
+
|
|
265
|
+
from typact import Path
|
|
266
|
+
|
|
267
|
+
@client.get(
|
|
268
|
+
"/reports/{report_id}",
|
|
269
|
+
timeout=60,
|
|
270
|
+
retry_config=RetryConfig(max_retries=2),
|
|
271
|
+
)
|
|
272
|
+
async def get_report(report_id: int = Path()) -> dict:
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@client.get("/events", timeout=None)
|
|
277
|
+
async def events() -> AsyncIterator[dict]:
|
|
278
|
+
pass
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
流式连接只会在尚未产出任何分片前重试,避免重复交付 SSE 事件或下载数据。
|
|
282
|
+
|
|
260
283
|
所有 Runtime 都可以映射为统一的 `typact.Response`:
|
|
261
284
|
|
|
262
285
|
```python
|
|
@@ -242,6 +242,29 @@ client = HttpClient(
|
|
|
242
242
|
)
|
|
243
243
|
```
|
|
244
244
|
|
|
245
|
+
单个接口可覆盖 Client 默认策略;显式传入 `None` 可关闭该默认值:
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
from collections.abc import AsyncIterator
|
|
249
|
+
|
|
250
|
+
from typact import Path
|
|
251
|
+
|
|
252
|
+
@client.get(
|
|
253
|
+
"/reports/{report_id}",
|
|
254
|
+
timeout=60,
|
|
255
|
+
retry_config=RetryConfig(max_retries=2),
|
|
256
|
+
)
|
|
257
|
+
async def get_report(report_id: int = Path()) -> dict:
|
|
258
|
+
pass
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@client.get("/events", timeout=None)
|
|
262
|
+
async def events() -> AsyncIterator[dict]:
|
|
263
|
+
pass
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
流式连接只会在尚未产出任何分片前重试,避免重复交付 SSE 事件或下载数据。
|
|
267
|
+
|
|
245
268
|
所有 Runtime 都可以映射为统一的 `typact.Response`:
|
|
246
269
|
|
|
247
270
|
```python
|
|
@@ -5,7 +5,8 @@ from collections.abc import AsyncIterator
|
|
|
5
5
|
from functools import wraps
|
|
6
6
|
from typing import Any, AsyncGenerator, Callable, get_origin, get_type_hints
|
|
7
7
|
|
|
8
|
-
from typact.client.metadata import RouteDefinition
|
|
8
|
+
from typact.client.metadata import UNSET, RouteDefinition
|
|
9
|
+
from typact.core.retry import RetryConfig
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
def run_async_from_sync(coro_factory: Callable[[], Any]):
|
|
@@ -33,7 +34,17 @@ def run_async_from_sync(coro_factory: Callable[[], Any]):
|
|
|
33
34
|
return result[0]
|
|
34
35
|
|
|
35
36
|
|
|
36
|
-
def create_route_decorator(
|
|
37
|
+
def create_route_decorator(
|
|
38
|
+
client: Any,
|
|
39
|
+
method: str,
|
|
40
|
+
path: str,
|
|
41
|
+
*,
|
|
42
|
+
timeout: float | None | object = UNSET,
|
|
43
|
+
retry_config: RetryConfig | None | object = UNSET,
|
|
44
|
+
):
|
|
45
|
+
if timeout is not UNSET and timeout is not None and timeout <= 0:
|
|
46
|
+
raise ValueError("timeout must be greater than 0")
|
|
47
|
+
|
|
37
48
|
def decorator(func: Callable):
|
|
38
49
|
route = RouteDefinition(
|
|
39
50
|
method=method,
|
|
@@ -41,6 +52,8 @@ def create_route_decorator(client: Any, method: str, path: str):
|
|
|
41
52
|
signature=inspect.signature(func),
|
|
42
53
|
return_type=get_type_hints(func).get("return", Any),
|
|
43
54
|
is_async=inspect.iscoroutinefunction(func),
|
|
55
|
+
timeout=timeout,
|
|
56
|
+
retry_config=retry_config,
|
|
44
57
|
)
|
|
45
58
|
|
|
46
59
|
is_stream = get_origin(route.return_type) in {AsyncIterator, AsyncGenerator}
|
|
@@ -6,11 +6,11 @@ from typing import Any, ParamSpec, TypeVar, overload
|
|
|
6
6
|
|
|
7
7
|
from typact.builder.request_builder import RequestBuilder
|
|
8
8
|
from typact.client.decorator import create_route_decorator
|
|
9
|
-
from typact.client.metadata import RouteDefinition
|
|
9
|
+
from typact.client.metadata import UNSET, RouteDefinition
|
|
10
10
|
from typact.converter.response_converter import ResponseConverter
|
|
11
11
|
from typact.converter.sse_converter import SseResponseConverter
|
|
12
12
|
from typact.converter.stream_converter import StreamResponseConverter
|
|
13
|
-
from typact.core.errors import TypactNetworkError, TypactTimeoutError
|
|
13
|
+
from typact.core.errors import TypactHttpError, TypactNetworkError, TypactTimeoutError
|
|
14
14
|
from typact.core.retry import RetryConfig
|
|
15
15
|
from typact.core.types import RequestConfig, Response
|
|
16
16
|
from typact.interceptor.base import InterceptorChain
|
|
@@ -49,20 +49,80 @@ class HttpClient:
|
|
|
49
49
|
self.timeout = timeout
|
|
50
50
|
self.retry_config = retry_config or RetryConfig()
|
|
51
51
|
|
|
52
|
-
def get(
|
|
53
|
-
|
|
52
|
+
def get(
|
|
53
|
+
self,
|
|
54
|
+
path: str,
|
|
55
|
+
*,
|
|
56
|
+
timeout: float | None | object = UNSET,
|
|
57
|
+
retry_config: RetryConfig | None | object = UNSET,
|
|
58
|
+
):
|
|
59
|
+
return create_route_decorator(
|
|
60
|
+
self,
|
|
61
|
+
"GET",
|
|
62
|
+
path,
|
|
63
|
+
timeout=timeout,
|
|
64
|
+
retry_config=retry_config,
|
|
65
|
+
)
|
|
54
66
|
|
|
55
|
-
def post(
|
|
56
|
-
|
|
67
|
+
def post(
|
|
68
|
+
self,
|
|
69
|
+
path: str,
|
|
70
|
+
*,
|
|
71
|
+
timeout: float | None | object = UNSET,
|
|
72
|
+
retry_config: RetryConfig | None | object = UNSET,
|
|
73
|
+
):
|
|
74
|
+
return create_route_decorator(
|
|
75
|
+
self,
|
|
76
|
+
"POST",
|
|
77
|
+
path,
|
|
78
|
+
timeout=timeout,
|
|
79
|
+
retry_config=retry_config,
|
|
80
|
+
)
|
|
57
81
|
|
|
58
|
-
def put(
|
|
59
|
-
|
|
82
|
+
def put(
|
|
83
|
+
self,
|
|
84
|
+
path: str,
|
|
85
|
+
*,
|
|
86
|
+
timeout: float | None | object = UNSET,
|
|
87
|
+
retry_config: RetryConfig | None | object = UNSET,
|
|
88
|
+
):
|
|
89
|
+
return create_route_decorator(
|
|
90
|
+
self,
|
|
91
|
+
"PUT",
|
|
92
|
+
path,
|
|
93
|
+
timeout=timeout,
|
|
94
|
+
retry_config=retry_config,
|
|
95
|
+
)
|
|
60
96
|
|
|
61
|
-
def patch(
|
|
62
|
-
|
|
97
|
+
def patch(
|
|
98
|
+
self,
|
|
99
|
+
path: str,
|
|
100
|
+
*,
|
|
101
|
+
timeout: float | None | object = UNSET,
|
|
102
|
+
retry_config: RetryConfig | None | object = UNSET,
|
|
103
|
+
):
|
|
104
|
+
return create_route_decorator(
|
|
105
|
+
self,
|
|
106
|
+
"PATCH",
|
|
107
|
+
path,
|
|
108
|
+
timeout=timeout,
|
|
109
|
+
retry_config=retry_config,
|
|
110
|
+
)
|
|
63
111
|
|
|
64
|
-
def delete(
|
|
65
|
-
|
|
112
|
+
def delete(
|
|
113
|
+
self,
|
|
114
|
+
path: str,
|
|
115
|
+
*,
|
|
116
|
+
timeout: float | None | object = UNSET,
|
|
117
|
+
retry_config: RetryConfig | None | object = UNSET,
|
|
118
|
+
):
|
|
119
|
+
return create_route_decorator(
|
|
120
|
+
self,
|
|
121
|
+
"DELETE",
|
|
122
|
+
path,
|
|
123
|
+
timeout=timeout,
|
|
124
|
+
retry_config=retry_config,
|
|
125
|
+
)
|
|
66
126
|
|
|
67
127
|
@overload
|
|
68
128
|
def request(
|
|
@@ -70,6 +130,8 @@ class HttpClient:
|
|
|
70
130
|
path: str,
|
|
71
131
|
*,
|
|
72
132
|
method: str,
|
|
133
|
+
timeout: float | None | object = UNSET,
|
|
134
|
+
retry_config: RetryConfig | None | object = UNSET,
|
|
73
135
|
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
|
|
74
136
|
|
|
75
137
|
@overload
|
|
@@ -79,6 +141,8 @@ class HttpClient:
|
|
|
79
141
|
func: Callable[P, R],
|
|
80
142
|
*,
|
|
81
143
|
methods: Sequence[str],
|
|
144
|
+
timeout: float | None | object = UNSET,
|
|
145
|
+
retry_config: RetryConfig | None | object = UNSET,
|
|
82
146
|
) -> Callable[P, R]: ...
|
|
83
147
|
|
|
84
148
|
def request(
|
|
@@ -88,6 +152,8 @@ class HttpClient:
|
|
|
88
152
|
*,
|
|
89
153
|
method: str | None = None,
|
|
90
154
|
methods: Sequence[str] | None = None,
|
|
155
|
+
timeout: float | None | object = UNSET,
|
|
156
|
+
retry_config: RetryConfig | None | object = UNSET,
|
|
91
157
|
):
|
|
92
158
|
if method is not None and methods is not None:
|
|
93
159
|
raise TypeError("method and methods cannot be used together")
|
|
@@ -96,7 +162,13 @@ class HttpClient:
|
|
|
96
162
|
if len(route_methods) != 1:
|
|
97
163
|
raise ValueError("request requires exactly one HTTP method")
|
|
98
164
|
|
|
99
|
-
decorator = create_route_decorator(
|
|
165
|
+
decorator = create_route_decorator(
|
|
166
|
+
self,
|
|
167
|
+
route_methods[0],
|
|
168
|
+
path,
|
|
169
|
+
timeout=timeout,
|
|
170
|
+
retry_config=retry_config,
|
|
171
|
+
)
|
|
100
172
|
|
|
101
173
|
def register(route_func: Callable[P, R]) -> Callable[P, R]:
|
|
102
174
|
wrapper = decorator(route_func)
|
|
@@ -117,20 +189,20 @@ class HttpClient:
|
|
|
117
189
|
args: tuple[Any, ...],
|
|
118
190
|
kwargs: dict[str, Any],
|
|
119
191
|
):
|
|
120
|
-
config = self.
|
|
121
|
-
config.timeout = self.timeout
|
|
192
|
+
config = self._build_request_config(route, args, kwargs)
|
|
122
193
|
config = await self.interceptor_chain.apply_request(config)
|
|
123
194
|
|
|
124
|
-
|
|
195
|
+
retry_config = self._get_retry_config(route)
|
|
196
|
+
response = await self._request_with_retry(config, retry_config)
|
|
125
197
|
|
|
126
198
|
if response.status_code == 401:
|
|
127
|
-
|
|
199
|
+
refreshed_config = await self.interceptor_chain.refresh_unauthorized(
|
|
128
200
|
config=config,
|
|
129
201
|
response=response,
|
|
130
202
|
)
|
|
131
203
|
|
|
132
|
-
if
|
|
133
|
-
response = await self._request_with_retry(retry_config)
|
|
204
|
+
if refreshed_config is not None:
|
|
205
|
+
response = await self._request_with_retry(refreshed_config, retry_config)
|
|
134
206
|
|
|
135
207
|
response = await self.interceptor_chain.apply_response(response)
|
|
136
208
|
|
|
@@ -148,10 +220,10 @@ class HttpClient:
|
|
|
148
220
|
args: tuple[Any, ...],
|
|
149
221
|
kwargs: dict[str, Any],
|
|
150
222
|
) -> AsyncIterator[Any]:
|
|
151
|
-
config = self.
|
|
152
|
-
config.timeout = self.timeout
|
|
223
|
+
config = self._build_request_config(route, args, kwargs)
|
|
153
224
|
config = await self.interceptor_chain.apply_request(config)
|
|
154
225
|
item_type = route.return_type.__args__[0]
|
|
226
|
+
retry_config = self._get_retry_config(route)
|
|
155
227
|
|
|
156
228
|
converter = (
|
|
157
229
|
self.stream_converter
|
|
@@ -159,10 +231,32 @@ class HttpClient:
|
|
|
159
231
|
else self.sse_converter
|
|
160
232
|
)
|
|
161
233
|
|
|
162
|
-
async for item in converter.convert(
|
|
234
|
+
async for item in converter.convert(
|
|
235
|
+
self._stream_with_retry(config, retry_config),
|
|
236
|
+
item_type,
|
|
237
|
+
):
|
|
163
238
|
yield item
|
|
164
239
|
|
|
165
|
-
|
|
240
|
+
def _build_request_config(
|
|
241
|
+
self,
|
|
242
|
+
route: RouteDefinition,
|
|
243
|
+
args: tuple[Any, ...],
|
|
244
|
+
kwargs: dict[str, Any],
|
|
245
|
+
) -> RequestConfig:
|
|
246
|
+
config = self.request_builder.build(route, args, kwargs)
|
|
247
|
+
config.timeout = self.timeout if route.timeout is UNSET else route.timeout
|
|
248
|
+
return config
|
|
249
|
+
|
|
250
|
+
def _get_retry_config(self, route: RouteDefinition) -> RetryConfig:
|
|
251
|
+
if route.retry_config is UNSET:
|
|
252
|
+
return self.retry_config
|
|
253
|
+
return route.retry_config or RetryConfig()
|
|
254
|
+
|
|
255
|
+
async def _request_with_retry(
|
|
256
|
+
self,
|
|
257
|
+
config: RequestConfig,
|
|
258
|
+
retry_config: RetryConfig,
|
|
259
|
+
) -> Response:
|
|
166
260
|
retry_number = 0
|
|
167
261
|
|
|
168
262
|
while True:
|
|
@@ -172,7 +266,7 @@ class HttpClient:
|
|
|
172
266
|
if not self._is_network_error(exc):
|
|
173
267
|
raise
|
|
174
268
|
|
|
175
|
-
if not self._can_retry(config, retry_number):
|
|
269
|
+
if not self._can_retry(config, retry_number, retry_config):
|
|
176
270
|
if isinstance(exc, TypactTimeoutError):
|
|
177
271
|
raise exc
|
|
178
272
|
if isinstance(exc, TimeoutError):
|
|
@@ -180,12 +274,12 @@ class HttpClient:
|
|
|
180
274
|
raise TypactNetworkError("Typact request failed due to a network error", cause=exc) from exc
|
|
181
275
|
else:
|
|
182
276
|
if (
|
|
183
|
-
response.status_code not in
|
|
184
|
-
or not self._can_retry(config, retry_number)
|
|
277
|
+
response.status_code not in retry_config.retry_status_codes
|
|
278
|
+
or not self._can_retry(config, retry_number, retry_config)
|
|
185
279
|
):
|
|
186
280
|
return response
|
|
187
281
|
|
|
188
|
-
await asyncio.sleep(
|
|
282
|
+
await asyncio.sleep(retry_config.delay_for_retry(retry_number))
|
|
189
283
|
retry_number += 1
|
|
190
284
|
|
|
191
285
|
async def _request_once(self, config: RequestConfig) -> Response:
|
|
@@ -198,12 +292,49 @@ class HttpClient:
|
|
|
198
292
|
except TimeoutError as exc:
|
|
199
293
|
raise TypactTimeoutError(config.timeout) from exc
|
|
200
294
|
|
|
201
|
-
def
|
|
295
|
+
async def _stream_with_retry(
|
|
296
|
+
self,
|
|
297
|
+
config: RequestConfig,
|
|
298
|
+
retry_config: RetryConfig,
|
|
299
|
+
) -> AsyncIterator[bytes]:
|
|
300
|
+
retry_number = 0
|
|
301
|
+
|
|
302
|
+
while True:
|
|
303
|
+
received_chunk = False
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
async for chunk in self.runtime.stream(config):
|
|
307
|
+
received_chunk = True
|
|
308
|
+
yield chunk
|
|
309
|
+
return
|
|
310
|
+
except Exception as exc:
|
|
311
|
+
can_retry = (
|
|
312
|
+
not received_chunk
|
|
313
|
+
and self._can_retry(config, retry_number, retry_config)
|
|
314
|
+
and self._is_retryable_stream_error(exc, retry_config)
|
|
315
|
+
)
|
|
316
|
+
if not can_retry:
|
|
317
|
+
raise
|
|
318
|
+
|
|
319
|
+
await asyncio.sleep(retry_config.delay_for_retry(retry_number))
|
|
320
|
+
retry_number += 1
|
|
321
|
+
|
|
322
|
+
def _can_retry(
|
|
323
|
+
self,
|
|
324
|
+
config: RequestConfig,
|
|
325
|
+
retry_number: int,
|
|
326
|
+
retry_config: RetryConfig,
|
|
327
|
+
) -> bool:
|
|
202
328
|
return (
|
|
203
|
-
retry_number <
|
|
204
|
-
and
|
|
329
|
+
retry_number < retry_config.max_retries
|
|
330
|
+
and retry_config.allows_method(config.method)
|
|
205
331
|
)
|
|
206
332
|
|
|
333
|
+
def _is_retryable_stream_error(self, exc: Exception, retry_config: RetryConfig) -> bool:
|
|
334
|
+
if isinstance(exc, TypactHttpError):
|
|
335
|
+
return exc.status_code in retry_config.retry_status_codes
|
|
336
|
+
return self._is_network_error(exc)
|
|
337
|
+
|
|
207
338
|
@staticmethod
|
|
208
339
|
def _is_network_error(exc: Exception) -> bool:
|
|
209
340
|
if isinstance(exc, TypactTimeoutError):
|
|
@@ -2,6 +2,11 @@ import inspect
|
|
|
2
2
|
from dataclasses import dataclass
|
|
3
3
|
from typing import Any
|
|
4
4
|
|
|
5
|
+
from typact.core.retry import RetryConfig
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
UNSET = object()
|
|
9
|
+
|
|
5
10
|
|
|
6
11
|
@dataclass(frozen=True)
|
|
7
12
|
class RouteDefinition:
|
|
@@ -10,3 +15,5 @@ class RouteDefinition:
|
|
|
10
15
|
signature: inspect.Signature
|
|
11
16
|
return_type: Any
|
|
12
17
|
is_async: bool
|
|
18
|
+
timeout: float | None | object = UNSET
|
|
19
|
+
retry_config: RetryConfig | None | object = UNSET
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: typact
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Add your description here
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Python: >=3.13
|
|
@@ -257,6 +257,29 @@ client = HttpClient(
|
|
|
257
257
|
)
|
|
258
258
|
```
|
|
259
259
|
|
|
260
|
+
单个接口可覆盖 Client 默认策略;显式传入 `None` 可关闭该默认值:
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
from collections.abc import AsyncIterator
|
|
264
|
+
|
|
265
|
+
from typact import Path
|
|
266
|
+
|
|
267
|
+
@client.get(
|
|
268
|
+
"/reports/{report_id}",
|
|
269
|
+
timeout=60,
|
|
270
|
+
retry_config=RetryConfig(max_retries=2),
|
|
271
|
+
)
|
|
272
|
+
async def get_report(report_id: int = Path()) -> dict:
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@client.get("/events", timeout=None)
|
|
277
|
+
async def events() -> AsyncIterator[dict]:
|
|
278
|
+
pass
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
流式连接只会在尚未产出任何分片前重试,避免重复交付 SSE 事件或下载数据。
|
|
282
|
+
|
|
260
283
|
所有 Runtime 都可以映射为统一的 `typact.Response`:
|
|
261
284
|
|
|
262
285
|
```python
|
|
@@ -39,6 +39,23 @@ class SlowRuntime(ClientRuntime):
|
|
|
39
39
|
return Response(status_code=200, headers={}, content=b"{}", json_data={})
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
class FlakyStreamRuntime(ClientRuntime):
|
|
43
|
+
def __init__(self):
|
|
44
|
+
self.stream_attempts = 0
|
|
45
|
+
|
|
46
|
+
async def request(self, config: RequestConfig) -> Response:
|
|
47
|
+
raise NotImplementedError
|
|
48
|
+
|
|
49
|
+
def stream(self, config: RequestConfig):
|
|
50
|
+
async def iterator():
|
|
51
|
+
self.stream_attempts += 1
|
|
52
|
+
if self.stream_attempts == 1:
|
|
53
|
+
raise TypactHttpError(503, b"temporarily unavailable")
|
|
54
|
+
yield b"ready"
|
|
55
|
+
|
|
56
|
+
return iterator()
|
|
57
|
+
|
|
58
|
+
|
|
42
59
|
class LoginApi:
|
|
43
60
|
def __init__(self, runtime: MockRuntime):
|
|
44
61
|
self.client = HttpClient("https://example.test", client_runtime=runtime)
|
|
@@ -123,6 +140,30 @@ class StreamApi:
|
|
|
123
140
|
raise NotImplementedError
|
|
124
141
|
|
|
125
142
|
|
|
143
|
+
class RoutePolicyApi:
|
|
144
|
+
def __init__(self, client: HttpClient):
|
|
145
|
+
client.request("/short", method="GET", timeout=3)(self.short)
|
|
146
|
+
client.request("/long-stream", method="GET", timeout=None)(self.long_stream)
|
|
147
|
+
client.request("/no-retry", method="GET", retry_config=None)(self.no_retry)
|
|
148
|
+
client.request(
|
|
149
|
+
"/retry-stream",
|
|
150
|
+
method="GET",
|
|
151
|
+
retry_config=RetryConfig(max_retries=1, initial_delay=0),
|
|
152
|
+
)(self.retry_stream)
|
|
153
|
+
|
|
154
|
+
async def short(self) -> dict:
|
|
155
|
+
raise NotImplementedError
|
|
156
|
+
|
|
157
|
+
async def long_stream(self) -> AsyncIterator[bytes]:
|
|
158
|
+
raise NotImplementedError
|
|
159
|
+
|
|
160
|
+
async def no_retry(self) -> dict:
|
|
161
|
+
raise NotImplementedError
|
|
162
|
+
|
|
163
|
+
async def retry_stream(self) -> AsyncIterator[bytes]:
|
|
164
|
+
raise NotImplementedError
|
|
165
|
+
|
|
166
|
+
|
|
126
167
|
class HttpClientRequestTest(unittest.TestCase):
|
|
127
168
|
def test_registers_bound_method_and_preserves_signature(self):
|
|
128
169
|
runtime = MockRuntime()
|
|
@@ -336,6 +377,56 @@ class HttpClientRequestTest(unittest.TestCase):
|
|
|
336
377
|
|
|
337
378
|
self.assertEqual(asyncio.run(collect()), "你好")
|
|
338
379
|
|
|
380
|
+
def test_route_timeout_overrides_client_default(self):
|
|
381
|
+
runtime = MockRuntime()
|
|
382
|
+
runtime.add_response("GET", "https://example.test/short", json_data={"ok": True})
|
|
383
|
+
api = RoutePolicyApi(HttpClient("https://example.test", client_runtime=runtime, timeout=30))
|
|
384
|
+
|
|
385
|
+
self.assertEqual(asyncio.run(api.short()), {"ok": True})
|
|
386
|
+
self.assertEqual(runtime.requests[0].timeout, 3)
|
|
387
|
+
|
|
388
|
+
def test_rejects_non_positive_route_timeout(self):
|
|
389
|
+
client = HttpClient("https://example.test")
|
|
390
|
+
|
|
391
|
+
with self.assertRaises(ValueError):
|
|
392
|
+
client.get("/short", timeout=0)
|
|
393
|
+
|
|
394
|
+
def test_route_timeout_can_disable_client_default_for_streams(self):
|
|
395
|
+
runtime = MockRuntime()
|
|
396
|
+
runtime.add_stream_response("GET", "https://example.test/long-stream", [b"first"])
|
|
397
|
+
api = RoutePolicyApi(HttpClient("https://example.test", client_runtime=runtime, timeout=30))
|
|
398
|
+
|
|
399
|
+
async def collect():
|
|
400
|
+
return [chunk async for chunk in api.long_stream()]
|
|
401
|
+
|
|
402
|
+
self.assertEqual(asyncio.run(collect()), [b"first"])
|
|
403
|
+
self.assertIsNone(runtime.requests[0].timeout)
|
|
404
|
+
|
|
405
|
+
def test_route_retry_config_can_disable_client_default(self):
|
|
406
|
+
runtime = MockRuntime()
|
|
407
|
+
runtime.add_response("GET", "https://example.test/no-retry", status_code=503)
|
|
408
|
+
api = RoutePolicyApi(
|
|
409
|
+
HttpClient(
|
|
410
|
+
"https://example.test",
|
|
411
|
+
client_runtime=runtime,
|
|
412
|
+
retry_config=RetryConfig(max_retries=1, initial_delay=0),
|
|
413
|
+
)
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
with self.assertRaises(TypactHttpError):
|
|
417
|
+
asyncio.run(api.no_retry())
|
|
418
|
+
self.assertEqual(len(runtime.requests), 1)
|
|
419
|
+
|
|
420
|
+
def test_stream_retries_before_delivering_a_chunk(self):
|
|
421
|
+
runtime = FlakyStreamRuntime()
|
|
422
|
+
api = RoutePolicyApi(HttpClient("https://example.test", client_runtime=runtime))
|
|
423
|
+
|
|
424
|
+
async def collect():
|
|
425
|
+
return [chunk async for chunk in api.retry_stream()]
|
|
426
|
+
|
|
427
|
+
self.assertEqual(asyncio.run(collect()), [b"ready"])
|
|
428
|
+
self.assertEqual(runtime.stream_attempts, 2)
|
|
429
|
+
|
|
339
430
|
|
|
340
431
|
if __name__ == "__main__":
|
|
341
432
|
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|