reflectapi-runtime 0.17.6__tar.gz → 0.18.0__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.
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/PKG-INFO +7 -4
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/README.md +3 -2
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/pyproject.toml +3 -1
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/__init__.py +1 -1
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/client.py +173 -180
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/response.py +4 -3
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/transport.py +6 -1
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/tests/test_client.py +266 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/.gitignore +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/auth.py +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/batch.py +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/duration.py +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/exceptions.py +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/hypothesis_strategies.py +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/middleware.py +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/partial.py +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/serde.py +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/sse.py +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/streaming.py +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/testing.py +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/types.py +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/tests/__init__.py +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/tests/test_auth.py +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/tests/test_auth_negative_cases.py +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/tests/test_batch.py +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/tests/test_codegen_regressions.py +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/tests/test_edge_cases.py +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/tests/test_enhanced_features.py +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/tests/test_exceptions.py +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/tests/test_middleware.py +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/tests/test_partial.py +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/tests/test_pydantic_serialization.py +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/tests/test_response.py +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/tests/test_sse.py +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/tests/test_streaming.py +0 -0
- {reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/tests/test_testing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: reflectapi-runtime
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.18.0
|
|
4
4
|
Summary: Runtime library for ReflectAPI Python clients
|
|
5
5
|
Project-URL: Homepage, https://github.com/thepartly/reflectapi
|
|
6
6
|
Project-URL: Repository, https://github.com/thepartly/reflectapi
|
|
@@ -14,12 +14,14 @@ Classifier: License :: OSI Approved :: MIT License
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
18
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
18
19
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
20
|
Classifier: Typing :: Typed
|
|
20
21
|
Requires-Python: >=3.12
|
|
21
22
|
Requires-Dist: httpx>=0.25.0
|
|
22
23
|
Requires-Dist: pydantic>=2.0.0
|
|
24
|
+
Requires-Dist: typing-extensions>=4.4.0
|
|
23
25
|
Provides-Extra: dev
|
|
24
26
|
Requires-Dist: hypothesis>=6.0.0; extra == 'dev'
|
|
25
27
|
Requires-Dist: mypy>=1.5.0; extra == 'dev'
|
|
@@ -50,8 +52,9 @@ directly.
|
|
|
50
52
|
- `ApplicationError`, `NetworkError`, `TimeoutError`, `ValidationError` —
|
|
51
53
|
exceptions raised by the generated methods on non-2xx, transport, and
|
|
52
54
|
validation failures respectively.
|
|
53
|
-
- `
|
|
54
|
-
(
|
|
55
|
+
- `ReflectapiPartialModel` — base class for generated models with three-state
|
|
56
|
+
fields (value / explicit-null / absent) so absent and explicit-null can
|
|
57
|
+
round-trip, tracked via Pydantic's `model_fields_set`.
|
|
55
58
|
- Authentication helpers (`BearerTokenAuth`, `APIKeyAuth`, `BasicAuth`,
|
|
56
59
|
`OAuth2ClientCredentialsAuth`, `OAuth2AuthorizationCodeAuth`).
|
|
57
60
|
- Middleware, batching, and testing utilities (`MockClient`,
|
|
@@ -15,8 +15,9 @@ directly.
|
|
|
15
15
|
- `ApplicationError`, `NetworkError`, `TimeoutError`, `ValidationError` —
|
|
16
16
|
exceptions raised by the generated methods on non-2xx, transport, and
|
|
17
17
|
validation failures respectively.
|
|
18
|
-
- `
|
|
19
|
-
(
|
|
18
|
+
- `ReflectapiPartialModel` — base class for generated models with three-state
|
|
19
|
+
fields (value / explicit-null / absent) so absent and explicit-null can
|
|
20
|
+
round-trip, tracked via Pydantic's `model_fields_set`.
|
|
20
21
|
- Authentication helpers (`BearerTokenAuth`, `APIKeyAuth`, `BasicAuth`,
|
|
21
22
|
`OAuth2ClientCredentialsAuth`, `OAuth2AuthorizationCodeAuth`).
|
|
22
23
|
- Middleware, batching, and testing utilities (`MockClient`,
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "reflectapi-runtime"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.18.0"
|
|
8
8
|
description = "Runtime library for ReflectAPI Python clients"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -20,6 +20,7 @@ classifiers = [
|
|
|
20
20
|
"Programming Language :: Python :: 3",
|
|
21
21
|
"Programming Language :: Python :: 3.12",
|
|
22
22
|
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
23
24
|
"Topic :: Internet :: WWW/HTTP",
|
|
24
25
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
26
|
"Typing :: Typed",
|
|
@@ -27,6 +28,7 @@ classifiers = [
|
|
|
27
28
|
dependencies = [
|
|
28
29
|
"pydantic>=2.0.0",
|
|
29
30
|
"httpx>=0.25.0",
|
|
31
|
+
"typing-extensions>=4.4.0",
|
|
30
32
|
]
|
|
31
33
|
|
|
32
34
|
[project.optional-dependencies]
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import contextlib
|
|
5
6
|
import datetime
|
|
6
7
|
import json
|
|
7
8
|
import time
|
|
@@ -50,6 +51,119 @@ def _json_serializer(obj: Any) -> Any:
|
|
|
50
51
|
# Note: AsyncAuthWrapper removed - AuthHandler now inherits from httpx.Auth directly
|
|
51
52
|
|
|
52
53
|
|
|
54
|
+
def _synthesize_raw_response(
|
|
55
|
+
status: int, headers: Any, body: bytes | str | None
|
|
56
|
+
) -> httpx.Response:
|
|
57
|
+
"""Build a stand-in ``httpx.Response`` for ``TransportMetadata.raw_response``.
|
|
58
|
+
|
|
59
|
+
Used only when a custom transport returns a structural :class:`Response`
|
|
60
|
+
without a wire-level ``raw`` object to surface. Response parsing never
|
|
61
|
+
goes through this synthetic object — it exists purely so
|
|
62
|
+
``raw_response`` keeps its ``httpx.Response`` shape for callers.
|
|
63
|
+
|
|
64
|
+
The structural body is already decoded (``httpx.Response.content``
|
|
65
|
+
decompresses on read), but the headers may still advertise
|
|
66
|
+
``Content-Encoding``. Passing both back into
|
|
67
|
+
``httpx.Response(content=...)`` would make httpx decompress the decoded
|
|
68
|
+
bytes a second time and raise ``DecodingError``, so the compression
|
|
69
|
+
headers are stripped. ``Content-Length`` describes the wire body, which
|
|
70
|
+
the structural body (decoded, possibly middleware-rewritten) need not
|
|
71
|
+
match, so it is always dropped.
|
|
72
|
+
"""
|
|
73
|
+
sanitized_headers = httpx.Headers(headers)
|
|
74
|
+
if "content-encoding" in sanitized_headers:
|
|
75
|
+
del sanitized_headers["content-encoding"]
|
|
76
|
+
if "content-length" in sanitized_headers:
|
|
77
|
+
del sanitized_headers["content-length"]
|
|
78
|
+
return httpx.Response(
|
|
79
|
+
status_code=status,
|
|
80
|
+
headers=sanitized_headers,
|
|
81
|
+
content=body,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _parse_json_body(body: bytes | str | None) -> Any:
|
|
86
|
+
"""Parse a response body as JSON, raising ``ValidationError`` on failure."""
|
|
87
|
+
try:
|
|
88
|
+
return json.loads(body if body is not None else b"")
|
|
89
|
+
except Exception as e:
|
|
90
|
+
raise ValidationError(
|
|
91
|
+
f"Failed to parse JSON response: {e}",
|
|
92
|
+
cause=e,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _raise_for_error_status(
|
|
97
|
+
status: int,
|
|
98
|
+
body: bytes | str | None,
|
|
99
|
+
metadata: TransportMetadata,
|
|
100
|
+
error_model: type | None = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Raise ``ApplicationError`` for HTTP error responses (4xx, 5xx).
|
|
103
|
+
|
|
104
|
+
If ``error_model`` is provided, attempts to deserialize the error body
|
|
105
|
+
into a typed Pydantic model before raising.
|
|
106
|
+
"""
|
|
107
|
+
if status < 400:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
error_data = None
|
|
111
|
+
typed_error = None
|
|
112
|
+
with contextlib.suppress(Exception):
|
|
113
|
+
error_data = json.loads(body)
|
|
114
|
+
|
|
115
|
+
# Try typed error deserialization; fall back to raw error_data
|
|
116
|
+
if error_model is not None and error_data is not None:
|
|
117
|
+
with contextlib.suppress(Exception):
|
|
118
|
+
typed_error = TypeAdapter(error_model).validate_python(error_data)
|
|
119
|
+
|
|
120
|
+
message = f"API error {status}: {httpx.codes.get_reason_phrase(status)}"
|
|
121
|
+
if error_data:
|
|
122
|
+
message += f" - {error_data}"
|
|
123
|
+
|
|
124
|
+
raise ApplicationError(
|
|
125
|
+
message,
|
|
126
|
+
metadata=metadata,
|
|
127
|
+
error_data=error_data,
|
|
128
|
+
typed_error=typed_error,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _validate_body(
|
|
133
|
+
body: bytes | str | None,
|
|
134
|
+
response_model: type[T] | type[Any] | str | _NoValidation,
|
|
135
|
+
metadata: TransportMetadata,
|
|
136
|
+
) -> ApiResponse[T] | ApiResponse[dict[str, Any]]:
|
|
137
|
+
"""Validate a response body using Pydantic via TypeAdapter.
|
|
138
|
+
|
|
139
|
+
TypeAdapter handles all types: plain BaseModel, Generic types
|
|
140
|
+
(list[Model], dict[str, Model]), Union types, and primitives.
|
|
141
|
+
Uses validate_json for Pydantic's fast Rust-based JSON parsing when raw
|
|
142
|
+
bytes are available.
|
|
143
|
+
"""
|
|
144
|
+
# Handle special cases where no validation is needed
|
|
145
|
+
if (
|
|
146
|
+
response_model == "Any"
|
|
147
|
+
or response_model is NO_VALIDATION
|
|
148
|
+
or response_model is Any
|
|
149
|
+
):
|
|
150
|
+
return ApiResponse(_parse_json_body(body), metadata)
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
ta = TypeAdapter(response_model)
|
|
154
|
+
if isinstance(body, (bytes, bytearray, str)):
|
|
155
|
+
validated_data = ta.validate_json(body)
|
|
156
|
+
else:
|
|
157
|
+
validated_data = ta.validate_python(_parse_json_body(body))
|
|
158
|
+
return ApiResponse(validated_data, metadata)
|
|
159
|
+
except PydanticValidationError as e:
|
|
160
|
+
raise ValidationError(
|
|
161
|
+
f"Response validation failed: {e}",
|
|
162
|
+
validation_errors=e.errors(),
|
|
163
|
+
cause=e,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
53
167
|
class ClientBase(ABC):
|
|
54
168
|
"""Base class for synchronous ReflectAPI clients."""
|
|
55
169
|
|
|
@@ -173,6 +287,7 @@ class ClientBase(ABC):
|
|
|
173
287
|
json_model: None = None,
|
|
174
288
|
headers_model: BaseModel | None = None,
|
|
175
289
|
response_model: type[T],
|
|
290
|
+
error_model: type[Any] | None = None,
|
|
176
291
|
) -> ApiResponse[T]: ...
|
|
177
292
|
|
|
178
293
|
@overload
|
|
@@ -186,6 +301,7 @@ class ClientBase(ABC):
|
|
|
186
301
|
json_model: None = None,
|
|
187
302
|
headers_model: BaseModel | None = None,
|
|
188
303
|
response_model: None = None,
|
|
304
|
+
error_model: type[Any] | None = None,
|
|
189
305
|
) -> ApiResponse[dict[str, Any]]: ...
|
|
190
306
|
|
|
191
307
|
@overload
|
|
@@ -199,7 +315,8 @@ class ClientBase(ABC):
|
|
|
199
315
|
json_model: None = None,
|
|
200
316
|
headers_model: BaseModel | None = None,
|
|
201
317
|
response_model: type[Any],
|
|
202
|
-
|
|
318
|
+
error_model: type[Any] | None = None,
|
|
319
|
+
) -> ApiResponse[Any]: ...
|
|
203
320
|
|
|
204
321
|
@overload
|
|
205
322
|
def _make_request(
|
|
@@ -210,7 +327,9 @@ class ClientBase(ABC):
|
|
|
210
327
|
params: dict[str, Any] | None = None,
|
|
211
328
|
json_data: dict[str, Any] | None = None,
|
|
212
329
|
json_model: None = None,
|
|
330
|
+
headers_model: BaseModel | None = None,
|
|
213
331
|
response_model: str,
|
|
332
|
+
error_model: type[Any] | None = None,
|
|
214
333
|
) -> ApiResponse[dict[str, Any]]: ...
|
|
215
334
|
|
|
216
335
|
@overload
|
|
@@ -224,6 +343,7 @@ class ClientBase(ABC):
|
|
|
224
343
|
json_model: BaseModel,
|
|
225
344
|
headers_model: BaseModel | None = None,
|
|
226
345
|
response_model: type[T],
|
|
346
|
+
error_model: type[Any] | None = None,
|
|
227
347
|
) -> ApiResponse[T]: ...
|
|
228
348
|
|
|
229
349
|
@overload
|
|
@@ -237,6 +357,7 @@ class ClientBase(ABC):
|
|
|
237
357
|
json_model: BaseModel,
|
|
238
358
|
headers_model: BaseModel | None = None,
|
|
239
359
|
response_model: None = None,
|
|
360
|
+
error_model: type[Any] | None = None,
|
|
240
361
|
) -> ApiResponse[dict[str, Any]]: ...
|
|
241
362
|
|
|
242
363
|
def _validate_request_params(
|
|
@@ -389,85 +510,6 @@ class ClientBase(ABC):
|
|
|
389
510
|
|
|
390
511
|
return terminal
|
|
391
512
|
|
|
392
|
-
def _handle_error_response(
|
|
393
|
-
self,
|
|
394
|
-
response: httpx.Response,
|
|
395
|
-
metadata: TransportMetadata,
|
|
396
|
-
error_model: type | None = None,
|
|
397
|
-
) -> None:
|
|
398
|
-
"""Handle HTTP error responses (4xx, 5xx).
|
|
399
|
-
|
|
400
|
-
If error_model is provided, attempts to deserialize the error body
|
|
401
|
-
into a typed Pydantic model before raising ApplicationError.
|
|
402
|
-
"""
|
|
403
|
-
if response.status_code >= 400:
|
|
404
|
-
error_data = None
|
|
405
|
-
typed_error = None
|
|
406
|
-
try:
|
|
407
|
-
error_data = response.json()
|
|
408
|
-
except Exception:
|
|
409
|
-
pass
|
|
410
|
-
|
|
411
|
-
# Try typed error deserialization
|
|
412
|
-
if error_model is not None and error_data is not None:
|
|
413
|
-
try:
|
|
414
|
-
ta = TypeAdapter(error_model)
|
|
415
|
-
typed_error = ta.validate_python(error_data)
|
|
416
|
-
except Exception:
|
|
417
|
-
pass # Fall back to raw error_data
|
|
418
|
-
|
|
419
|
-
raise ApplicationError.from_response(
|
|
420
|
-
response, metadata, error_data, typed_error=typed_error
|
|
421
|
-
)
|
|
422
|
-
|
|
423
|
-
def _parse_json_response(self, response: httpx.Response) -> dict[str, Any]:
|
|
424
|
-
"""Parse JSON response with error handling."""
|
|
425
|
-
try:
|
|
426
|
-
return response.json()
|
|
427
|
-
except Exception as e:
|
|
428
|
-
raise ValidationError(
|
|
429
|
-
f"Failed to parse JSON response: {e}",
|
|
430
|
-
cause=e,
|
|
431
|
-
)
|
|
432
|
-
|
|
433
|
-
def _validate_response_model(
|
|
434
|
-
self,
|
|
435
|
-
response: httpx.Response,
|
|
436
|
-
response_model: type[T] | type[Any] | str | _NoValidation,
|
|
437
|
-
metadata: TransportMetadata,
|
|
438
|
-
) -> ApiResponse[T] | ApiResponse[dict[str, Any]]:
|
|
439
|
-
"""Validate response using Pydantic model via TypeAdapter.
|
|
440
|
-
|
|
441
|
-
TypeAdapter handles all types: plain BaseModel, Generic types
|
|
442
|
-
(list[Model], dict[str, Model]), Union types, and primitives.
|
|
443
|
-
Uses validate_json for performance when raw bytes are available.
|
|
444
|
-
"""
|
|
445
|
-
# Handle special cases where no validation is needed
|
|
446
|
-
if response_model == "Any" or response_model is NO_VALIDATION:
|
|
447
|
-
json_response = self._parse_json_response(response)
|
|
448
|
-
return ApiResponse(json_response, metadata)
|
|
449
|
-
|
|
450
|
-
if response_model is Any:
|
|
451
|
-
json_response = self._parse_json_response(response)
|
|
452
|
-
return ApiResponse(json_response, metadata)
|
|
453
|
-
|
|
454
|
-
try:
|
|
455
|
-
ta = TypeAdapter(response_model)
|
|
456
|
-
# Prefer validate_json for Pydantic's fast Rust-based JSON parsing
|
|
457
|
-
content = response.content
|
|
458
|
-
if isinstance(content, (bytes, bytearray)):
|
|
459
|
-
validated_data = ta.validate_json(content)
|
|
460
|
-
else:
|
|
461
|
-
json_response = self._parse_json_response(response)
|
|
462
|
-
validated_data = ta.validate_python(json_response)
|
|
463
|
-
return ApiResponse(validated_data, metadata)
|
|
464
|
-
except PydanticValidationError as e:
|
|
465
|
-
raise ValidationError(
|
|
466
|
-
f"Response validation failed: {e}",
|
|
467
|
-
validation_errors=e.errors(),
|
|
468
|
-
cause=e,
|
|
469
|
-
)
|
|
470
|
-
|
|
471
513
|
def _make_request(
|
|
472
514
|
self,
|
|
473
515
|
|
|
@@ -501,33 +543,33 @@ class ClientBase(ABC):
|
|
|
501
543
|
# response so any middleware transforms apply; `raw` is used
|
|
502
544
|
# only as the metadata sidecar (preserves `.request` /
|
|
503
545
|
# `.extensions` / `.history` from the real wire response when
|
|
504
|
-
# available, fallback to
|
|
505
|
-
parsed_response = httpx.Response(
|
|
506
|
-
status_code=client_response.status,
|
|
507
|
-
headers=client_response.headers,
|
|
508
|
-
content=client_response.body,
|
|
509
|
-
)
|
|
546
|
+
# available, fallback to a synthetic for custom transports).
|
|
510
547
|
metadata = TransportMetadata(
|
|
511
548
|
status_code=client_response.status,
|
|
512
549
|
headers=client_response.headers,
|
|
513
550
|
timing=time.time() - start_time,
|
|
514
|
-
raw_response=client_response.raw
|
|
551
|
+
raw_response=client_response.raw
|
|
552
|
+
or _synthesize_raw_response(
|
|
553
|
+
client_response.status,
|
|
554
|
+
client_response.headers,
|
|
555
|
+
client_response.body,
|
|
556
|
+
),
|
|
515
557
|
)
|
|
516
558
|
|
|
517
559
|
# Handle error responses
|
|
518
|
-
|
|
519
|
-
|
|
560
|
+
_raise_for_error_status(
|
|
561
|
+
client_response.status,
|
|
562
|
+
client_response.body,
|
|
563
|
+
metadata,
|
|
564
|
+
error_model=error_model,
|
|
520
565
|
)
|
|
521
566
|
|
|
522
567
|
# Validate and return response
|
|
523
568
|
if response_model is not None:
|
|
524
|
-
return
|
|
525
|
-
parsed_response, response_model, metadata
|
|
526
|
-
)
|
|
569
|
+
return _validate_body(client_response.body, response_model, metadata)
|
|
527
570
|
else:
|
|
528
|
-
# No response_model provided - parse JSON
|
|
529
|
-
|
|
530
|
-
return ApiResponse(json_response, metadata)
|
|
571
|
+
# No response_model provided - parse JSON as-is
|
|
572
|
+
return ApiResponse(_parse_json_body(client_response.body), metadata)
|
|
531
573
|
|
|
532
574
|
except httpx.TimeoutException as e:
|
|
533
575
|
raise TimeoutError.from_httpx_timeout(e)
|
|
@@ -581,7 +623,12 @@ class ClientBase(ABC):
|
|
|
581
623
|
if response.status_code >= 400:
|
|
582
624
|
# read_full body so error parsing can see it
|
|
583
625
|
response.read()
|
|
584
|
-
|
|
626
|
+
_raise_for_error_status(
|
|
627
|
+
response.status_code,
|
|
628
|
+
response.content,
|
|
629
|
+
metadata,
|
|
630
|
+
error_model=error_model,
|
|
631
|
+
)
|
|
585
632
|
|
|
586
633
|
adapter: TypeAdapter[Any] | None = None
|
|
587
634
|
if (
|
|
@@ -734,7 +781,9 @@ class AsyncClientBase(ABC):
|
|
|
734
781
|
params: dict[str, Any] | None = None,
|
|
735
782
|
json_data: dict[str, Any] | None = None,
|
|
736
783
|
json_model: None = None,
|
|
784
|
+
headers_model: BaseModel | None = None,
|
|
737
785
|
response_model: type[T],
|
|
786
|
+
error_model: type[Any] | None = None,
|
|
738
787
|
) -> ApiResponse[T]: ...
|
|
739
788
|
|
|
740
789
|
@overload
|
|
@@ -748,6 +797,7 @@ class AsyncClientBase(ABC):
|
|
|
748
797
|
json_model: None = None,
|
|
749
798
|
headers_model: BaseModel | None = None,
|
|
750
799
|
response_model: None = None,
|
|
800
|
+
error_model: type[Any] | None = None,
|
|
751
801
|
) -> ApiResponse[dict[str, Any]]: ...
|
|
752
802
|
|
|
753
803
|
@overload
|
|
@@ -761,7 +811,8 @@ class AsyncClientBase(ABC):
|
|
|
761
811
|
json_model: None = None,
|
|
762
812
|
headers_model: BaseModel | None = None,
|
|
763
813
|
response_model: type[Any],
|
|
764
|
-
|
|
814
|
+
error_model: type[Any] | None = None,
|
|
815
|
+
) -> ApiResponse[Any]: ...
|
|
765
816
|
|
|
766
817
|
@overload
|
|
767
818
|
async def _make_request(
|
|
@@ -772,7 +823,9 @@ class AsyncClientBase(ABC):
|
|
|
772
823
|
params: dict[str, Any] | None = None,
|
|
773
824
|
json_data: dict[str, Any] | None = None,
|
|
774
825
|
json_model: None = None,
|
|
826
|
+
headers_model: BaseModel | None = None,
|
|
775
827
|
response_model: str,
|
|
828
|
+
error_model: type[Any] | None = None,
|
|
776
829
|
) -> ApiResponse[dict[str, Any]]: ...
|
|
777
830
|
|
|
778
831
|
@overload
|
|
@@ -786,6 +839,7 @@ class AsyncClientBase(ABC):
|
|
|
786
839
|
json_model: BaseModel,
|
|
787
840
|
headers_model: BaseModel | None = None,
|
|
788
841
|
response_model: type[T],
|
|
842
|
+
error_model: type[Any] | None = None,
|
|
789
843
|
) -> ApiResponse[T]: ...
|
|
790
844
|
|
|
791
845
|
@overload
|
|
@@ -799,6 +853,7 @@ class AsyncClientBase(ABC):
|
|
|
799
853
|
json_model: BaseModel,
|
|
800
854
|
headers_model: BaseModel | None = None,
|
|
801
855
|
response_model: None = None,
|
|
856
|
+
error_model: type[Any] | None = None,
|
|
802
857
|
) -> ApiResponse[dict[str, Any]]: ...
|
|
803
858
|
|
|
804
859
|
def _validate_request_params(
|
|
@@ -947,73 +1002,6 @@ class AsyncClientBase(ABC):
|
|
|
947
1002
|
|
|
948
1003
|
return terminal
|
|
949
1004
|
|
|
950
|
-
def _handle_error_response(
|
|
951
|
-
self,
|
|
952
|
-
response: httpx.Response,
|
|
953
|
-
metadata: TransportMetadata,
|
|
954
|
-
error_model: type | None = None,
|
|
955
|
-
) -> None:
|
|
956
|
-
"""Handle HTTP error responses (4xx, 5xx)."""
|
|
957
|
-
if response.status_code >= 400:
|
|
958
|
-
error_data = None
|
|
959
|
-
typed_error = None
|
|
960
|
-
try:
|
|
961
|
-
error_data = response.json()
|
|
962
|
-
except Exception:
|
|
963
|
-
pass
|
|
964
|
-
|
|
965
|
-
if error_model is not None and error_data is not None:
|
|
966
|
-
try:
|
|
967
|
-
ta = TypeAdapter(error_model)
|
|
968
|
-
typed_error = ta.validate_python(error_data)
|
|
969
|
-
except Exception:
|
|
970
|
-
pass
|
|
971
|
-
|
|
972
|
-
raise ApplicationError.from_response(
|
|
973
|
-
response, metadata, error_data, typed_error=typed_error
|
|
974
|
-
)
|
|
975
|
-
|
|
976
|
-
def _parse_json_response(self, response: httpx.Response) -> dict[str, Any]:
|
|
977
|
-
"""Parse JSON response with error handling."""
|
|
978
|
-
try:
|
|
979
|
-
return response.json()
|
|
980
|
-
except Exception as e:
|
|
981
|
-
raise ValidationError(
|
|
982
|
-
f"Failed to parse JSON response: {e}",
|
|
983
|
-
cause=e,
|
|
984
|
-
)
|
|
985
|
-
|
|
986
|
-
def _validate_response_model(
|
|
987
|
-
self,
|
|
988
|
-
response: httpx.Response,
|
|
989
|
-
response_model: type[T] | type[Any] | str | _NoValidation,
|
|
990
|
-
metadata: TransportMetadata,
|
|
991
|
-
) -> ApiResponse[T] | ApiResponse[dict[str, Any]]:
|
|
992
|
-
"""Validate response using Pydantic model via TypeAdapter."""
|
|
993
|
-
if response_model == "Any" or response_model is NO_VALIDATION:
|
|
994
|
-
json_response = self._parse_json_response(response)
|
|
995
|
-
return ApiResponse(json_response, metadata)
|
|
996
|
-
|
|
997
|
-
if response_model is Any:
|
|
998
|
-
json_response = self._parse_json_response(response)
|
|
999
|
-
return ApiResponse(json_response, metadata)
|
|
1000
|
-
|
|
1001
|
-
try:
|
|
1002
|
-
ta = TypeAdapter(response_model)
|
|
1003
|
-
content = response.content
|
|
1004
|
-
if isinstance(content, (bytes, bytearray)):
|
|
1005
|
-
validated_data = ta.validate_json(content)
|
|
1006
|
-
else:
|
|
1007
|
-
json_response = self._parse_json_response(response)
|
|
1008
|
-
validated_data = ta.validate_python(json_response)
|
|
1009
|
-
return ApiResponse(validated_data, metadata)
|
|
1010
|
-
except PydanticValidationError as e:
|
|
1011
|
-
raise ValidationError(
|
|
1012
|
-
f"Response validation failed: {e}",
|
|
1013
|
-
validation_errors=e.errors(),
|
|
1014
|
-
cause=e,
|
|
1015
|
-
)
|
|
1016
|
-
|
|
1017
1005
|
async def _make_request(
|
|
1018
1006
|
self,
|
|
1019
1007
|
|
|
@@ -1047,32 +1035,32 @@ class AsyncClientBase(ABC):
|
|
|
1047
1035
|
# response so any middleware transforms apply; `raw` is used
|
|
1048
1036
|
# only as the metadata sidecar (preserves `.request` /
|
|
1049
1037
|
# `.extensions` / `.history` from the real wire response when
|
|
1050
|
-
# available, fallback to
|
|
1051
|
-
parsed_response = httpx.Response(
|
|
1052
|
-
status_code=client_response.status,
|
|
1053
|
-
headers=client_response.headers,
|
|
1054
|
-
content=client_response.body,
|
|
1055
|
-
)
|
|
1038
|
+
# available, fallback to a synthetic for custom transports).
|
|
1056
1039
|
metadata = TransportMetadata(
|
|
1057
1040
|
status_code=client_response.status,
|
|
1058
1041
|
headers=client_response.headers,
|
|
1059
1042
|
timing=time.time() - start_time,
|
|
1060
|
-
raw_response=client_response.raw
|
|
1043
|
+
raw_response=client_response.raw
|
|
1044
|
+
or _synthesize_raw_response(
|
|
1045
|
+
client_response.status,
|
|
1046
|
+
client_response.headers,
|
|
1047
|
+
client_response.body,
|
|
1048
|
+
),
|
|
1061
1049
|
)
|
|
1062
1050
|
|
|
1063
1051
|
# Handle error responses
|
|
1064
|
-
|
|
1065
|
-
|
|
1052
|
+
_raise_for_error_status(
|
|
1053
|
+
client_response.status,
|
|
1054
|
+
client_response.body,
|
|
1055
|
+
metadata,
|
|
1056
|
+
error_model=error_model,
|
|
1066
1057
|
)
|
|
1067
1058
|
|
|
1068
1059
|
# Validate and return response
|
|
1069
1060
|
if response_model is not None:
|
|
1070
|
-
return
|
|
1071
|
-
parsed_response, response_model, metadata
|
|
1072
|
-
)
|
|
1061
|
+
return _validate_body(client_response.body, response_model, metadata)
|
|
1073
1062
|
else:
|
|
1074
|
-
|
|
1075
|
-
return ApiResponse(json_response, metadata)
|
|
1063
|
+
return ApiResponse(_parse_json_body(client_response.body), metadata)
|
|
1076
1064
|
|
|
1077
1065
|
except httpx.TimeoutException as e:
|
|
1078
1066
|
raise TimeoutError.from_httpx_timeout(e)
|
|
@@ -1090,7 +1078,7 @@ class AsyncClientBase(ABC):
|
|
|
1090
1078
|
headers_model: BaseModel | None = None,
|
|
1091
1079
|
item_model: type[T] | type[Any] | str | _NoValidation | None = None,
|
|
1092
1080
|
error_model: type | None = None,
|
|
1093
|
-
) -> AsyncIterator[
|
|
1081
|
+
) -> AsyncIterator[Any]:
|
|
1094
1082
|
"""Open an SSE stream and yield items validated against ``item_model``.
|
|
1095
1083
|
|
|
1096
1084
|
Errors raised when the server returns a non-2xx response are
|
|
@@ -1125,7 +1113,12 @@ class AsyncClientBase(ABC):
|
|
|
1125
1113
|
metadata = TransportMetadata.from_response(response, start_time)
|
|
1126
1114
|
if response.status_code >= 400:
|
|
1127
1115
|
await response.aread()
|
|
1128
|
-
|
|
1116
|
+
_raise_for_error_status(
|
|
1117
|
+
response.status_code,
|
|
1118
|
+
response.content,
|
|
1119
|
+
metadata,
|
|
1120
|
+
error_model=error_model,
|
|
1121
|
+
)
|
|
1129
1122
|
|
|
1130
1123
|
adapter: TypeAdapter[Any] | None = None
|
|
1131
1124
|
if (
|
|
@@ -4,12 +4,13 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import time
|
|
6
6
|
from dataclasses import dataclass
|
|
7
|
-
from typing import Any, Generic
|
|
7
|
+
from typing import Any, Generic
|
|
8
8
|
|
|
9
9
|
import httpx # noqa: TC002
|
|
10
|
+
from typing_extensions import TypeVar
|
|
10
11
|
|
|
11
|
-
T = TypeVar("T")
|
|
12
|
-
E = TypeVar("E")
|
|
12
|
+
T = TypeVar("T", covariant=True)
|
|
13
|
+
E = TypeVar("E", covariant=True, default=Any)
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
@dataclass(frozen=True)
|
|
@@ -26,7 +26,12 @@ class Request:
|
|
|
26
26
|
|
|
27
27
|
@dataclass(frozen=True)
|
|
28
28
|
class Response:
|
|
29
|
-
"""Transport response returned by custom ReflectAPI Python clients.
|
|
29
|
+
"""Transport response returned by custom ReflectAPI Python clients.
|
|
30
|
+
|
|
31
|
+
``body`` must already be decoded (any ``Content-Encoding`` reversed), as
|
|
32
|
+
``httpx.Response.content`` is. ``headers`` may still carry the wire
|
|
33
|
+
headers; the runtime never re-applies them to ``body``.
|
|
34
|
+
"""
|
|
30
35
|
|
|
31
36
|
status: int
|
|
32
37
|
# Permissive headers type so adapters wrapping httpx.Headers (which is
|
|
@@ -907,3 +907,269 @@ class TestMiddlewareTransformsAreParsed:
|
|
|
907
907
|
|
|
908
908
|
assert result.value.name == "healed"
|
|
909
909
|
assert result.metadata.status_code == 200
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
class TestCompressedResponseNotDoubleDecoded:
|
|
913
|
+
"""A transport that has already decompressed the body (httpx does this
|
|
914
|
+
when reading ``.content``) may still surface ``Content-Encoding: gzip``
|
|
915
|
+
in the headers. Rebuilding an ``httpx.Response`` from that pair must not
|
|
916
|
+
attempt a second decompression of the already-decoded bytes.
|
|
917
|
+
|
|
918
|
+
Regression test: previously this raised
|
|
919
|
+
``NetworkError: Error -3 while decompressing data: incorrect header check``.
|
|
920
|
+
"""
|
|
921
|
+
|
|
922
|
+
@staticmethod
|
|
923
|
+
def _gzip_handler(request: httpx.Request) -> httpx.Response:
|
|
924
|
+
import gzip
|
|
925
|
+
|
|
926
|
+
return httpx.Response(
|
|
927
|
+
200,
|
|
928
|
+
headers={
|
|
929
|
+
"content-type": "application/json",
|
|
930
|
+
"content-encoding": "gzip",
|
|
931
|
+
},
|
|
932
|
+
content=gzip.compress(b'{"name":"zipped","age":3}'),
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
def test_sync_gzip_response(self):
|
|
936
|
+
with httpx.Client(transport=httpx.MockTransport(self._gzip_handler)) as raw:
|
|
937
|
+
client = ClientBase("http://example.com", client=raw)
|
|
938
|
+
result = client._make_request("/test", response_model=SampleModel)
|
|
939
|
+
|
|
940
|
+
assert result.value.name == "zipped"
|
|
941
|
+
# Metadata still reflects the original wire headers.
|
|
942
|
+
assert result.metadata.headers.get("content-encoding") == "gzip"
|
|
943
|
+
|
|
944
|
+
@pytest.mark.asyncio
|
|
945
|
+
async def test_async_gzip_response(self):
|
|
946
|
+
async with httpx.AsyncClient(
|
|
947
|
+
transport=httpx.MockTransport(self._gzip_handler)
|
|
948
|
+
) as raw:
|
|
949
|
+
client = AsyncClientBase("http://example.com", client=raw)
|
|
950
|
+
result = await client._make_request("/test", response_model=SampleModel)
|
|
951
|
+
|
|
952
|
+
assert result.value.name == "zipped"
|
|
953
|
+
assert result.metadata.headers.get("content-encoding") == "gzip"
|
|
954
|
+
|
|
955
|
+
def test_sync_custom_transport_decoded_body_with_encoding_header(self):
|
|
956
|
+
"""A custom structural transport returning a decoded body while
|
|
957
|
+
keeping the compression header must also parse cleanly."""
|
|
958
|
+
|
|
959
|
+
class _DecodedButLabelled:
|
|
960
|
+
def request(self, request: Request) -> Response:
|
|
961
|
+
return Response(
|
|
962
|
+
status=200,
|
|
963
|
+
headers=httpx.Headers(
|
|
964
|
+
{
|
|
965
|
+
"content-type": "application/json",
|
|
966
|
+
"content-encoding": "gzip",
|
|
967
|
+
"content-length": "17",
|
|
968
|
+
}
|
|
969
|
+
),
|
|
970
|
+
body=b'{"name":"raw","age":9}',
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
client = ClientBase("http://example.com", client=_DecodedButLabelled())
|
|
974
|
+
result = client._make_request("/test", response_model=SampleModel)
|
|
975
|
+
assert result.value.name == "raw"
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
class TestParsingBypassesSyntheticHttpxResponse:
|
|
979
|
+
"""Response parsing operates on the structural body directly — it never
|
|
980
|
+
round-trips through a rebuilt ``httpx.Response``, so stale wire headers
|
|
981
|
+
(compression, content-length) can't corrupt parsing on any path.
|
|
982
|
+
"""
|
|
983
|
+
|
|
984
|
+
@staticmethod
|
|
985
|
+
def _gzip_error_handler(request: httpx.Request) -> httpx.Response:
|
|
986
|
+
import gzip
|
|
987
|
+
|
|
988
|
+
return httpx.Response(
|
|
989
|
+
404,
|
|
990
|
+
headers={
|
|
991
|
+
"content-type": "application/json",
|
|
992
|
+
"content-encoding": "gzip",
|
|
993
|
+
},
|
|
994
|
+
content=gzip.compress(b'{"reason":"missing"}'),
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
def test_sync_compressed_error_response_parses_error_body(self):
|
|
998
|
+
with httpx.Client(
|
|
999
|
+
transport=httpx.MockTransport(self._gzip_error_handler)
|
|
1000
|
+
) as raw:
|
|
1001
|
+
client = ClientBase("http://example.com", client=raw)
|
|
1002
|
+
with pytest.raises(ApplicationError) as exc_info:
|
|
1003
|
+
client._make_request("/test", response_model=SampleModel)
|
|
1004
|
+
|
|
1005
|
+
assert exc_info.value.status_code == 404
|
|
1006
|
+
assert exc_info.value.error_data == {"reason": "missing"}
|
|
1007
|
+
assert "API error 404: Not Found" in str(exc_info.value)
|
|
1008
|
+
|
|
1009
|
+
@pytest.mark.asyncio
|
|
1010
|
+
async def test_async_compressed_error_response_parses_error_body(self):
|
|
1011
|
+
async with httpx.AsyncClient(
|
|
1012
|
+
transport=httpx.MockTransport(self._gzip_error_handler)
|
|
1013
|
+
) as raw:
|
|
1014
|
+
client = AsyncClientBase("http://example.com", client=raw)
|
|
1015
|
+
with pytest.raises(ApplicationError) as exc_info:
|
|
1016
|
+
await client._make_request("/test", response_model=SampleModel)
|
|
1017
|
+
|
|
1018
|
+
assert exc_info.value.status_code == 404
|
|
1019
|
+
assert exc_info.value.error_data == {"reason": "missing"}
|
|
1020
|
+
|
|
1021
|
+
def test_sync_typed_error_from_compressed_response(self):
|
|
1022
|
+
class ErrorModel(BaseModel):
|
|
1023
|
+
reason: str
|
|
1024
|
+
|
|
1025
|
+
with httpx.Client(
|
|
1026
|
+
transport=httpx.MockTransport(self._gzip_error_handler)
|
|
1027
|
+
) as raw:
|
|
1028
|
+
client = ClientBase("http://example.com", client=raw)
|
|
1029
|
+
with pytest.raises(ApplicationError) as exc_info:
|
|
1030
|
+
client._make_request(
|
|
1031
|
+
"/test", response_model=SampleModel, error_model=ErrorModel
|
|
1032
|
+
)
|
|
1033
|
+
|
|
1034
|
+
assert exc_info.value.typed_error == ErrorModel(reason="missing")
|
|
1035
|
+
|
|
1036
|
+
def test_middleware_rewritten_body_ignores_stale_wire_headers(self):
|
|
1037
|
+
"""Middleware can replace the body entirely; the original wire
|
|
1038
|
+
headers (content-length, content-encoding) must not be applied to
|
|
1039
|
+
the new body during parsing."""
|
|
1040
|
+
import gzip
|
|
1041
|
+
from dataclasses import replace
|
|
1042
|
+
|
|
1043
|
+
from reflectapi_runtime.middleware import SyncMiddleware
|
|
1044
|
+
|
|
1045
|
+
class _Rewrite(SyncMiddleware):
|
|
1046
|
+
def handle(self, request, next_call):
|
|
1047
|
+
resp = next_call(request)
|
|
1048
|
+
return replace(resp, body=b'{"name":"rewritten","age":7}')
|
|
1049
|
+
|
|
1050
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1051
|
+
return httpx.Response(
|
|
1052
|
+
200,
|
|
1053
|
+
headers={
|
|
1054
|
+
"content-type": "application/json",
|
|
1055
|
+
"content-encoding": "gzip",
|
|
1056
|
+
},
|
|
1057
|
+
content=gzip.compress(b'{"name":"wire","age":1}'),
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
with httpx.Client(transport=httpx.MockTransport(handler)) as raw:
|
|
1061
|
+
client = ClientBase(
|
|
1062
|
+
"http://example.com", client=raw, middleware=[_Rewrite()]
|
|
1063
|
+
)
|
|
1064
|
+
result = client._make_request("/test", response_model=SampleModel)
|
|
1065
|
+
|
|
1066
|
+
assert result.value.name == "rewritten"
|
|
1067
|
+
|
|
1068
|
+
def test_no_response_model_parses_json_directly(self):
|
|
1069
|
+
import gzip
|
|
1070
|
+
|
|
1071
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1072
|
+
return httpx.Response(
|
|
1073
|
+
200,
|
|
1074
|
+
headers={
|
|
1075
|
+
"content-type": "application/json",
|
|
1076
|
+
"content-encoding": "gzip",
|
|
1077
|
+
},
|
|
1078
|
+
content=gzip.compress(b'{"free": "form"}'),
|
|
1079
|
+
)
|
|
1080
|
+
|
|
1081
|
+
with httpx.Client(transport=httpx.MockTransport(handler)) as raw:
|
|
1082
|
+
client = ClientBase("http://example.com", client=raw)
|
|
1083
|
+
result = client._make_request("/test")
|
|
1084
|
+
|
|
1085
|
+
assert result.value == {"free": "form"}
|
|
1086
|
+
|
|
1087
|
+
def test_invalid_json_error_body_still_raises_application_error(self):
|
|
1088
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1089
|
+
return httpx.Response(500, content=b"not json at all")
|
|
1090
|
+
|
|
1091
|
+
with httpx.Client(transport=httpx.MockTransport(handler)) as raw:
|
|
1092
|
+
client = ClientBase("http://example.com", client=raw)
|
|
1093
|
+
with pytest.raises(ApplicationError) as exc_info:
|
|
1094
|
+
client._make_request("/test", response_model=SampleModel)
|
|
1095
|
+
|
|
1096
|
+
assert exc_info.value.status_code == 500
|
|
1097
|
+
assert exc_info.value.error_data is None
|
|
1098
|
+
assert "API error 500: Internal Server Error" in str(exc_info.value)
|
|
1099
|
+
|
|
1100
|
+
def test_invalid_json_success_body_raises_validation_error(self):
|
|
1101
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1102
|
+
return httpx.Response(200, content=b"not json")
|
|
1103
|
+
|
|
1104
|
+
with httpx.Client(transport=httpx.MockTransport(handler)) as raw:
|
|
1105
|
+
client = ClientBase("http://example.com", client=raw)
|
|
1106
|
+
with pytest.raises(ValidationError):
|
|
1107
|
+
client._make_request("/test")
|
|
1108
|
+
|
|
1109
|
+
def test_synthetic_raw_response_fallback_is_sanitized(self):
|
|
1110
|
+
"""Custom transports without a wire-level `raw` get a synthetic
|
|
1111
|
+
raw_response whose compression headers are stripped so it is
|
|
1112
|
+
readable, while metadata.headers keeps the original wire view."""
|
|
1113
|
+
|
|
1114
|
+
class _DecodedButLabelled:
|
|
1115
|
+
def request(self, request: Request) -> Response:
|
|
1116
|
+
return Response(
|
|
1117
|
+
status=200,
|
|
1118
|
+
headers=httpx.Headers(
|
|
1119
|
+
{
|
|
1120
|
+
"content-type": "application/json",
|
|
1121
|
+
"content-encoding": "gzip",
|
|
1122
|
+
"content-length": "17",
|
|
1123
|
+
}
|
|
1124
|
+
),
|
|
1125
|
+
body=b'{"name":"raw","age":9}',
|
|
1126
|
+
)
|
|
1127
|
+
|
|
1128
|
+
client = ClientBase("http://example.com", client=_DecodedButLabelled())
|
|
1129
|
+
result = client._make_request("/test", response_model=SampleModel)
|
|
1130
|
+
|
|
1131
|
+
assert result.value.name == "raw"
|
|
1132
|
+
assert result.metadata.headers.get("content-encoding") == "gzip"
|
|
1133
|
+
raw = result.metadata.raw_response
|
|
1134
|
+
assert isinstance(raw, httpx.Response)
|
|
1135
|
+
assert "content-encoding" not in raw.headers
|
|
1136
|
+
assert raw.json() == {"name": "raw", "age": 9}
|
|
1137
|
+
|
|
1138
|
+
def test_wire_raw_response_still_preferred_over_synthetic(self):
|
|
1139
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
1140
|
+
return httpx.Response(200, content=b'{"name":"x","age":1}')
|
|
1141
|
+
|
|
1142
|
+
with httpx.Client(transport=httpx.MockTransport(handler)) as raw:
|
|
1143
|
+
client = ClientBase("http://example.com", client=raw)
|
|
1144
|
+
result = client._make_request("/test", response_model=SampleModel)
|
|
1145
|
+
|
|
1146
|
+
# The real wire response carries .request; a synthetic would not.
|
|
1147
|
+
assert result.metadata.raw_response.request is not None
|
|
1148
|
+
|
|
1149
|
+
def test_synthetic_raw_response_recomputes_stale_content_length(self):
|
|
1150
|
+
"""Even without Content-Encoding, the wire Content-Length may not
|
|
1151
|
+
describe the structural body (e.g. after middleware rewrites it).
|
|
1152
|
+
The stale value is dropped and httpx re-derives it from the actual
|
|
1153
|
+
body when constructing the synthetic."""
|
|
1154
|
+
|
|
1155
|
+
class _StaleLength:
|
|
1156
|
+
def request(self, request: Request) -> Response:
|
|
1157
|
+
return Response(
|
|
1158
|
+
status=200,
|
|
1159
|
+
headers=httpx.Headers(
|
|
1160
|
+
{
|
|
1161
|
+
"content-type": "application/json",
|
|
1162
|
+
"content-length": "9999",
|
|
1163
|
+
}
|
|
1164
|
+
),
|
|
1165
|
+
body=b'{"name":"raw","age":9}',
|
|
1166
|
+
)
|
|
1167
|
+
|
|
1168
|
+
client = ClientBase("http://example.com", client=_StaleLength())
|
|
1169
|
+
result = client._make_request("/test", response_model=SampleModel)
|
|
1170
|
+
|
|
1171
|
+
body = b'{"name":"raw","age":9}'
|
|
1172
|
+
assert result.metadata.raw_response.headers.get("content-length") == str(
|
|
1173
|
+
len(body)
|
|
1174
|
+
)
|
|
1175
|
+
assert result.metadata.headers.get("content-length") == "9999"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/exceptions.py
RENAMED
|
File without changes
|
|
File without changes
|
{reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/middleware.py
RENAMED
|
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
|
{reflectapi_runtime-0.17.6 → reflectapi_runtime-0.18.0}/tests/test_pydantic_serialization.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|