reflectapi-runtime 0.17.5__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.
Files changed (36) hide show
  1. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/PKG-INFO +7 -4
  2. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/README.md +3 -2
  3. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/pyproject.toml +3 -1
  4. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/__init__.py +1 -1
  5. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/client.py +173 -180
  6. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/response.py +4 -3
  7. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/transport.py +6 -1
  8. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/tests/test_client.py +266 -0
  9. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/.gitignore +0 -0
  10. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/auth.py +0 -0
  11. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/batch.py +0 -0
  12. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/duration.py +0 -0
  13. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/exceptions.py +0 -0
  14. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/hypothesis_strategies.py +0 -0
  15. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/middleware.py +0 -0
  16. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/partial.py +0 -0
  17. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/serde.py +0 -0
  18. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/sse.py +0 -0
  19. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/streaming.py +0 -0
  20. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/testing.py +0 -0
  21. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/src/reflectapi_runtime/types.py +0 -0
  22. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/tests/__init__.py +0 -0
  23. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/tests/test_auth.py +0 -0
  24. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/tests/test_auth_negative_cases.py +0 -0
  25. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/tests/test_batch.py +0 -0
  26. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/tests/test_codegen_regressions.py +0 -0
  27. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/tests/test_edge_cases.py +0 -0
  28. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/tests/test_enhanced_features.py +0 -0
  29. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/tests/test_exceptions.py +0 -0
  30. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/tests/test_middleware.py +0 -0
  31. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/tests/test_partial.py +0 -0
  32. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/tests/test_pydantic_serialization.py +0 -0
  33. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/tests/test_response.py +0 -0
  34. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/tests/test_sse.py +0 -0
  35. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/tests/test_streaming.py +0 -0
  36. {reflectapi_runtime-0.17.5 → reflectapi_runtime-0.18.0}/tests/test_testing.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: reflectapi-runtime
3
- Version: 0.17.5
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
- - `ReflectapiOption` — three-state Option used by the generated models
54
- (`some` / `none` / `undefined`) so absent and explicit-null can round-trip.
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
- - `ReflectapiOption` — three-state Option used by the generated models
19
- (`some` / `none` / `undefined`) so absent and explicit-null can round-trip.
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.17.5"
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]
@@ -50,7 +50,7 @@ from .testing import (
50
50
  )
51
51
  from .types import BatchResult, ReflectapiEmpty, ReflectapiInfallible
52
52
 
53
- __version__ = "0.17.5"
53
+ __version__ = "0.18.0"
54
54
 
55
55
  __all__ = [
56
56
  # Authentication
@@ -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
- ) -> ApiResponse[dict[str, Any]]: ...
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 the synthetic for custom transports).
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 or parsed_response,
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
- self._handle_error_response(
519
- parsed_response, metadata, error_model=error_model
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 self._validate_response_model(
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 into dict
529
- json_response = self._parse_json_response(parsed_response)
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
- self._handle_error_response(response, metadata, error_model=error_model)
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
- ) -> ApiResponse[dict[str, Any]]: ...
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 the synthetic for custom transports).
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 or parsed_response,
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
- self._handle_error_response(
1065
- parsed_response, metadata, error_model=error_model
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 self._validate_response_model(
1071
- parsed_response, response_model, metadata
1072
- )
1061
+ return _validate_body(client_response.body, response_model, metadata)
1073
1062
  else:
1074
- json_response = self._parse_json_response(parsed_response)
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[T] | AsyncIterator[Any]:
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
- self._handle_error_response(response, metadata, error_model=error_model)
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, TypeVar
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"