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.
Files changed (53) hide show
  1. {typact-0.2.0/src/typact.egg-info → typact-0.2.1}/PKG-INFO +24 -1
  2. {typact-0.2.0 → typact-0.2.1}/README.md +23 -0
  3. {typact-0.2.0 → typact-0.2.1}/pyproject.toml +1 -1
  4. {typact-0.2.0 → typact-0.2.1}/src/typact/client/decorator.py +15 -2
  5. {typact-0.2.0 → typact-0.2.1}/src/typact/client/http_client.py +161 -30
  6. {typact-0.2.0 → typact-0.2.1}/src/typact/client/metadata.py +7 -0
  7. {typact-0.2.0 → typact-0.2.1/src/typact.egg-info}/PKG-INFO +24 -1
  8. {typact-0.2.0 → typact-0.2.1}/tests/test_http_client_request.py +91 -0
  9. {typact-0.2.0 → typact-0.2.1}/LICENSE +0 -0
  10. {typact-0.2.0 → typact-0.2.1}/setup.cfg +0 -0
  11. {typact-0.2.0 → typact-0.2.1}/src/typact/__init__.py +0 -0
  12. {typact-0.2.0 → typact-0.2.1}/src/typact/annotations/__init__.py +0 -0
  13. {typact-0.2.0 → typact-0.2.1}/src/typact/annotations/base.py +0 -0
  14. {typact-0.2.0 → typact-0.2.1}/src/typact/annotations/body.py +0 -0
  15. {typact-0.2.0 → typact-0.2.1}/src/typact/annotations/cookie.py +0 -0
  16. {typact-0.2.0 → typact-0.2.1}/src/typact/annotations/file.py +0 -0
  17. {typact-0.2.0 → typact-0.2.1}/src/typact/annotations/form.py +0 -0
  18. {typact-0.2.0 → typact-0.2.1}/src/typact/annotations/header.py +0 -0
  19. {typact-0.2.0 → typact-0.2.1}/src/typact/annotations/path.py +0 -0
  20. {typact-0.2.0 → typact-0.2.1}/src/typact/annotations/query.py +0 -0
  21. {typact-0.2.0 → typact-0.2.1}/src/typact/builder/__init__.py +0 -0
  22. {typact-0.2.0 → typact-0.2.1}/src/typact/builder/multipart_builder.py +0 -0
  23. {typact-0.2.0 → typact-0.2.1}/src/typact/builder/request_builder.py +0 -0
  24. {typact-0.2.0 → typact-0.2.1}/src/typact/builder/url_builder.py +0 -0
  25. {typact-0.2.0 → typact-0.2.1}/src/typact/client/__init__.py +0 -0
  26. {typact-0.2.0 → typact-0.2.1}/src/typact/converter/__init__.py +0 -0
  27. {typact-0.2.0 → typact-0.2.1}/src/typact/converter/file_converter.py +0 -0
  28. {typact-0.2.0 → typact-0.2.1}/src/typact/converter/json_converter.py +0 -0
  29. {typact-0.2.0 → typact-0.2.1}/src/typact/converter/response_converter.py +0 -0
  30. {typact-0.2.0 → typact-0.2.1}/src/typact/converter/sse_converter.py +0 -0
  31. {typact-0.2.0 → typact-0.2.1}/src/typact/converter/stream_converter.py +0 -0
  32. {typact-0.2.0 → typact-0.2.1}/src/typact/core/__init__.py +0 -0
  33. {typact-0.2.0 → typact-0.2.1}/src/typact/core/errors.py +0 -0
  34. {typact-0.2.0 → typact-0.2.1}/src/typact/core/retry.py +0 -0
  35. {typact-0.2.0 → typact-0.2.1}/src/typact/core/types.py +0 -0
  36. {typact-0.2.0 → typact-0.2.1}/src/typact/interceptor/__init__.py +0 -0
  37. {typact-0.2.0 → typact-0.2.1}/src/typact/interceptor/auth.py +0 -0
  38. {typact-0.2.0 → typact-0.2.1}/src/typact/interceptor/base.py +0 -0
  39. {typact-0.2.0 → typact-0.2.1}/src/typact/interceptor/log.py +0 -0
  40. {typact-0.2.0 → typact-0.2.1}/src/typact/interceptor/trace.py +0 -0
  41. {typact-0.2.0 → typact-0.2.1}/src/typact/runtime/__init__.py +0 -0
  42. {typact-0.2.0 → typact-0.2.1}/src/typact/runtime/aiohttp.py +0 -0
  43. {typact-0.2.0 → typact-0.2.1}/src/typact/runtime/base.py +0 -0
  44. {typact-0.2.0 → typact-0.2.1}/src/typact/runtime/httpx.py +0 -0
  45. {typact-0.2.0 → typact-0.2.1}/src/typact/runtime/mock.py +0 -0
  46. {typact-0.2.0 → typact-0.2.1}/src/typact/runtime/urllib.py +0 -0
  47. {typact-0.2.0 → typact-0.2.1}/src/typact/testing/__init__.py +0 -0
  48. {typact-0.2.0 → typact-0.2.1}/src/typact/testing/mock_runtime.py +0 -0
  49. {typact-0.2.0 → typact-0.2.1}/src/typact.egg-info/SOURCES.txt +0 -0
  50. {typact-0.2.0 → typact-0.2.1}/src/typact.egg-info/dependency_links.txt +0 -0
  51. {typact-0.2.0 → typact-0.2.1}/src/typact.egg-info/requires.txt +0 -0
  52. {typact-0.2.0 → typact-0.2.1}/src/typact.egg-info/top_level.txt +0 -0
  53. {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.0
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "typact"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Add your description here"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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(client: Any, method: str, path: str):
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(self, path: str):
53
- return create_route_decorator(self, "GET", path)
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(self, path: str):
56
- return create_route_decorator(self, "POST", path)
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(self, path: str):
59
- return create_route_decorator(self, "PUT", path)
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(self, path: str):
62
- return create_route_decorator(self, "PATCH", path)
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(self, path: str):
65
- return create_route_decorator(self, "DELETE", path)
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(self, route_methods[0], path)
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.request_builder.build(route, args, kwargs)
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
- response = await self._request_with_retry(config)
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
- retry_config = await self.interceptor_chain.refresh_unauthorized(
199
+ refreshed_config = await self.interceptor_chain.refresh_unauthorized(
128
200
  config=config,
129
201
  response=response,
130
202
  )
131
203
 
132
- if retry_config is not None:
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.request_builder.build(route, args, kwargs)
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(self.runtime.stream(config), item_type):
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
- async def _request_with_retry(self, config: RequestConfig) -> Response:
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 self.retry_config.retry_status_codes
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(self.retry_config.delay_for_retry(retry_number))
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 _can_retry(self, config: RequestConfig, retry_number: int) -> bool:
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 < self.retry_config.max_retries
204
- and self.retry_config.allows_method(config.method)
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.0
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