reflectapi-runtime 0.18.0__tar.gz → 0.19.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.18.0 → reflectapi_runtime-0.19.0}/PKG-INFO +7 -3
  2. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/README.md +6 -2
  3. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/pyproject.toml +1 -1
  4. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/__init__.py +4 -2
  5. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/client.py +220 -117
  6. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/exceptions.py +10 -2
  7. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/response.py +96 -3
  8. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_client.py +136 -1
  9. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_sse.py +50 -1
  10. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/.gitignore +0 -0
  11. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/auth.py +0 -0
  12. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/batch.py +0 -0
  13. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/duration.py +0 -0
  14. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/hypothesis_strategies.py +0 -0
  15. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/middleware.py +0 -0
  16. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/partial.py +0 -0
  17. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/serde.py +0 -0
  18. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/sse.py +0 -0
  19. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/streaming.py +0 -0
  20. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/testing.py +0 -0
  21. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/transport.py +0 -0
  22. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/src/reflectapi_runtime/types.py +0 -0
  23. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/__init__.py +0 -0
  24. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_auth.py +0 -0
  25. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_auth_negative_cases.py +0 -0
  26. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_batch.py +0 -0
  27. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_codegen_regressions.py +0 -0
  28. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_edge_cases.py +0 -0
  29. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_enhanced_features.py +0 -0
  30. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_exceptions.py +0 -0
  31. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_middleware.py +0 -0
  32. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_partial.py +0 -0
  33. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_pydantic_serialization.py +0 -0
  34. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_response.py +0 -0
  35. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_streaming.py +0 -0
  36. {reflectapi_runtime-0.18.0 → reflectapi_runtime-0.19.0}/tests/test_testing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: reflectapi-runtime
3
- Version: 0.18.0
3
+ Version: 0.19.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
@@ -47,8 +47,12 @@ directly.
47
47
  - `ClientBase` / `AsyncClientBase` — base classes used by the generated
48
48
  `Client` / `AsyncClient`. They wrap `httpx` and handle request build-up,
49
49
  Pydantic-based response validation, and middleware.
50
- - `ApiResponse[T, E]` — typed wrapper around the response value, transport
51
- metadata, and the optional typed error.
50
+ - `ApiResponse[T, E, H]` — typed wrapper around the response value, transport
51
+ metadata, the optional typed error, and the API's declared response headers
52
+ (`.headers`).
53
+ - `ApiStream[T, H]` / `AsyncApiStream[T, H]` — iterators returned by streaming
54
+ methods, exposing the response's `metadata` and declared `headers` once
55
+ iteration starts.
52
56
  - `ApplicationError`, `NetworkError`, `TimeoutError`, `ValidationError` —
53
57
  exceptions raised by the generated methods on non-2xx, transport, and
54
58
  validation failures respectively.
@@ -10,8 +10,12 @@ directly.
10
10
  - `ClientBase` / `AsyncClientBase` — base classes used by the generated
11
11
  `Client` / `AsyncClient`. They wrap `httpx` and handle request build-up,
12
12
  Pydantic-based response validation, and middleware.
13
- - `ApiResponse[T, E]` — typed wrapper around the response value, transport
14
- metadata, and the optional typed error.
13
+ - `ApiResponse[T, E, H]` — typed wrapper around the response value, transport
14
+ metadata, the optional typed error, and the API's declared response headers
15
+ (`.headers`).
16
+ - `ApiStream[T, H]` / `AsyncApiStream[T, H]` — iterators returned by streaming
17
+ methods, exposing the response's `metadata` and declared `headers` once
18
+ iteration starts.
15
19
  - `ApplicationError`, `NetworkError`, `TimeoutError`, `ValidationError` —
16
20
  exceptions raised by the generated methods on non-2xx, transport, and
17
21
  validation failures respectively.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "reflectapi-runtime"
7
- version = "0.18.0"
7
+ version = "0.19.0"
8
8
  description = "Runtime library for ReflectAPI Python clients"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -38,7 +38,7 @@ from .hypothesis_strategies import (
38
38
  )
39
39
  from .middleware import AsyncMiddleware
40
40
  from .partial import ReflectapiPartialModel
41
- from .response import ApiResponse, TransportMetadata
41
+ from .response import ApiResponse, ApiStream, AsyncApiStream, TransportMetadata
42
42
  from .serde import parse_externally_tagged, serialize_externally_tagged
43
43
  from .streaming import AsyncStreamingClient, StreamingResponse
44
44
  from .testing import (
@@ -50,7 +50,7 @@ from .testing import (
50
50
  )
51
51
  from .types import BatchResult, ReflectapiEmpty, ReflectapiInfallible
52
52
 
53
- __version__ = "0.18.0"
53
+ __version__ = "0.19.0"
54
54
 
55
55
  __all__ = [
56
56
  # Authentication
@@ -94,6 +94,8 @@ __all__ = [
94
94
  "parse_externally_tagged",
95
95
  "serialize_externally_tagged",
96
96
  "StreamingResponse",
97
+ "ApiStream",
98
+ "AsyncApiStream",
97
99
  "TestClientMixin",
98
100
  "TimeoutError",
99
101
  "TransportMetadata",
@@ -5,9 +5,10 @@ from __future__ import annotations
5
5
  import contextlib
6
6
  import datetime
7
7
  import json
8
+ import logging
8
9
  import time
9
10
  from abc import ABC
10
- from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
11
+ from collections.abc import AsyncGenerator, Awaitable, Callable, Generator
11
12
  from typing import Any, TypeVar, overload
12
13
 
13
14
  import httpx
@@ -22,7 +23,7 @@ from .middleware import (
22
23
  SyncMiddleware,
23
24
  SyncMiddlewareChain,
24
25
  )
25
- from .response import ApiResponse, TransportMetadata
26
+ from .response import ApiResponse, ApiStream, AsyncApiStream, TransportMetadata
26
27
  from .sse import aparse_sse, parse_sse
27
28
  from .transport import AsyncClient, Client, Request, Response
28
29
 
@@ -34,6 +35,8 @@ class _NoValidation:
34
35
 
35
36
  NO_VALIDATION = _NoValidation()
36
37
 
38
+ logger = logging.getLogger(__name__)
39
+
37
40
  T = TypeVar("T", bound=BaseModel)
38
41
 
39
42
 
@@ -82,7 +85,11 @@ def _synthesize_raw_response(
82
85
  )
83
86
 
84
87
 
85
- def _parse_json_body(body: bytes | str | None) -> Any:
88
+ def _parse_json_body(
89
+ body: bytes | str | None,
90
+ metadata: TransportMetadata | None = None,
91
+ response_headers: Any | None = None,
92
+ ) -> Any:
86
93
  """Parse a response body as JSON, raising ``ValidationError`` on failure."""
87
94
  try:
88
95
  return json.loads(body if body is not None else b"")
@@ -90,6 +97,8 @@ def _parse_json_body(body: bytes | str | None) -> Any:
90
97
  raise ValidationError(
91
98
  f"Failed to parse JSON response: {e}",
92
99
  cause=e,
100
+ metadata=metadata,
101
+ headers=response_headers,
93
102
  )
94
103
 
95
104
 
@@ -98,6 +107,7 @@ def _raise_for_error_status(
98
107
  body: bytes | str | None,
99
108
  metadata: TransportMetadata,
100
109
  error_model: type | None = None,
110
+ response_headers: Any | None = None,
101
111
  ) -> None:
102
112
  """Raise ``ApplicationError`` for HTTP error responses (4xx, 5xx).
103
113
 
@@ -126,13 +136,37 @@ def _raise_for_error_status(
126
136
  metadata=metadata,
127
137
  error_data=error_data,
128
138
  typed_error=typed_error,
139
+ headers=response_headers,
129
140
  )
130
141
 
131
142
 
143
+ def _parse_response_headers(headers: Any, model: type[Any] | None) -> Any | None:
144
+ """Validate the API's declared response headers into ``model``.
145
+
146
+ Header names are case-insensitive, so they're lowercased to match the
147
+ model's (lowercase) aliases; undeclared headers are ignored. A value that
148
+ doesn't validate reads as absent rather than failing the call, which
149
+ would hide the response's real outcome (e.g. a 503 from a proxy); the raw
150
+ value stays in ``metadata.headers``.
151
+ """
152
+ if model is None:
153
+ return None
154
+ items = headers.items() if headers is not None else ()
155
+ lowered = {str(name).lower(): str(value) for name, value in items}
156
+ try:
157
+ return model.model_validate(lowered)
158
+ except PydanticValidationError as e:
159
+ invalid = {error["loc"][0] for error in e.errors() if error["loc"]}
160
+ logger.debug("ignoring malformed response headers: %s", sorted(invalid))
161
+ valid = {name: value for name, value in lowered.items() if name not in invalid}
162
+ return model.model_validate(valid)
163
+
164
+
132
165
  def _validate_body(
133
166
  body: bytes | str | None,
134
167
  response_model: type[T] | type[Any] | str | _NoValidation,
135
168
  metadata: TransportMetadata,
169
+ response_headers: Any | None = None,
136
170
  ) -> ApiResponse[T] | ApiResponse[dict[str, Any]]:
137
171
  """Validate a response body using Pydantic via TypeAdapter.
138
172
 
@@ -147,20 +181,28 @@ def _validate_body(
147
181
  or response_model is NO_VALIDATION
148
182
  or response_model is Any
149
183
  ):
150
- return ApiResponse(_parse_json_body(body), metadata)
184
+ return ApiResponse(
185
+ _parse_json_body(body, metadata, response_headers),
186
+ metadata,
187
+ response_headers,
188
+ )
151
189
 
152
190
  try:
153
191
  ta = TypeAdapter(response_model)
154
192
  if isinstance(body, (bytes, bytearray, str)):
155
193
  validated_data = ta.validate_json(body)
156
194
  else:
157
- validated_data = ta.validate_python(_parse_json_body(body))
158
- return ApiResponse(validated_data, metadata)
195
+ validated_data = ta.validate_python(
196
+ _parse_json_body(body, metadata, response_headers)
197
+ )
198
+ return ApiResponse(validated_data, metadata, response_headers)
159
199
  except PydanticValidationError as e:
160
200
  raise ValidationError(
161
201
  f"Response validation failed: {e}",
162
202
  validation_errors=e.errors(),
163
203
  cause=e,
204
+ metadata=metadata,
205
+ headers=response_headers,
164
206
  )
165
207
 
166
208
 
@@ -288,6 +330,7 @@ class ClientBase(ABC):
288
330
  headers_model: BaseModel | None = None,
289
331
  response_model: type[T],
290
332
  error_model: type[Any] | None = None,
333
+ response_headers_model: type[Any] | None = None,
291
334
  ) -> ApiResponse[T]: ...
292
335
 
293
336
  @overload
@@ -302,6 +345,7 @@ class ClientBase(ABC):
302
345
  headers_model: BaseModel | None = None,
303
346
  response_model: None = None,
304
347
  error_model: type[Any] | None = None,
348
+ response_headers_model: type[Any] | None = None,
305
349
  ) -> ApiResponse[dict[str, Any]]: ...
306
350
 
307
351
  @overload
@@ -316,6 +360,7 @@ class ClientBase(ABC):
316
360
  headers_model: BaseModel | None = None,
317
361
  response_model: type[Any],
318
362
  error_model: type[Any] | None = None,
363
+ response_headers_model: type[Any] | None = None,
319
364
  ) -> ApiResponse[Any]: ...
320
365
 
321
366
  @overload
@@ -330,6 +375,7 @@ class ClientBase(ABC):
330
375
  headers_model: BaseModel | None = None,
331
376
  response_model: str,
332
377
  error_model: type[Any] | None = None,
378
+ response_headers_model: type[Any] | None = None,
333
379
  ) -> ApiResponse[dict[str, Any]]: ...
334
380
 
335
381
  @overload
@@ -344,6 +390,7 @@ class ClientBase(ABC):
344
390
  headers_model: BaseModel | None = None,
345
391
  response_model: type[T],
346
392
  error_model: type[Any] | None = None,
393
+ response_headers_model: type[Any] | None = None,
347
394
  ) -> ApiResponse[T]: ...
348
395
 
349
396
  @overload
@@ -358,6 +405,7 @@ class ClientBase(ABC):
358
405
  headers_model: BaseModel | None = None,
359
406
  response_model: None = None,
360
407
  error_model: type[Any] | None = None,
408
+ response_headers_model: type[Any] | None = None,
361
409
  ) -> ApiResponse[dict[str, Any]]: ...
362
410
 
363
411
  def _validate_request_params(
@@ -521,6 +569,7 @@ class ClientBase(ABC):
521
569
  headers_model: BaseModel | None = None,
522
570
  response_model: type[T] | type[Any] | str | _NoValidation | None = None,
523
571
  error_model: type | None = None,
572
+ response_headers_model: type | None = None,
524
573
  ) -> ApiResponse[T] | ApiResponse[dict[str, Any]]:
525
574
  """Make an HTTP request and return an ApiResponse."""
526
575
  # Validate request parameters
@@ -556,20 +605,31 @@ class ClientBase(ABC):
556
605
  ),
557
606
  )
558
607
 
608
+ response_headers = _parse_response_headers(
609
+ client_response.headers, response_headers_model
610
+ )
611
+
559
612
  # Handle error responses
560
613
  _raise_for_error_status(
561
614
  client_response.status,
562
615
  client_response.body,
563
616
  metadata,
564
617
  error_model=error_model,
618
+ response_headers=response_headers,
565
619
  )
566
620
 
567
621
  # Validate and return response
568
622
  if response_model is not None:
569
- return _validate_body(client_response.body, response_model, metadata)
623
+ return _validate_body(
624
+ client_response.body, response_model, metadata, response_headers
625
+ )
570
626
  else:
571
627
  # No response_model provided - parse JSON as-is
572
- return ApiResponse(_parse_json_body(client_response.body), metadata)
628
+ return ApiResponse(
629
+ _parse_json_body(client_response.body, metadata, response_headers),
630
+ metadata,
631
+ response_headers,
632
+ )
573
633
 
574
634
  except httpx.TimeoutException as e:
575
635
  raise TimeoutError.from_httpx_timeout(e)
@@ -587,7 +647,8 @@ class ClientBase(ABC):
587
647
  headers_model: BaseModel | None = None,
588
648
  item_model: type[T] | type[Any] | str | _NoValidation | None = None,
589
649
  error_model: type | None = None,
590
- ) -> Iterator[T] | Iterator[Any]:
650
+ response_headers_model: type | None = None,
651
+ ) -> ApiStream[Any]:
591
652
  """Open an SSE stream and yield items validated against ``item_model``.
592
653
 
593
654
  Errors raised when the server returns a non-2xx response are
@@ -595,69 +656,79 @@ class ClientBase(ABC):
595
656
  problems raise ``NetworkError`` / ``TimeoutError``. Per-event
596
657
  validation failures raise ``ValidationError``.
597
658
 
598
- The HTTP connection is released when the generator is exhausted,
659
+ The request is sent when iteration starts; the returned stream's
660
+ ``metadata`` and ``headers`` are set once the response arrives. The
661
+ HTTP connection is released when the stream is exhausted,
599
662
  ``close()``-d, or garbage-collected, so consumers can ``break`` out
600
663
  of the loop without leaking sockets. Middleware is intentionally
601
664
  bypassed because typical middleware buffers the response body.
602
665
  """
603
666
 
604
- self._validate_request_params(json_data, json_model)
667
+ def open_items(stream: ApiStream[Any]) -> Generator[Any, None, None]:
668
+ self._validate_request_params(json_data, json_model)
605
669
 
606
- url = f"{self.base_url}/{path.lstrip('/')}"
607
- client_request = self._build_client_request(
608
- path, json_data, json_model, headers_model
609
- )
610
- request = self._build_httpx_request(client_request, url, params)
611
- request.headers["accept"] = "text/event-stream"
612
-
613
- start_time = time.time()
614
- try:
615
- response = self._client.send(request, stream=True)
616
- except httpx.TimeoutException as e:
617
- raise TimeoutError.from_httpx_timeout(e)
618
- except httpx.RequestError as e:
619
- raise NetworkError.from_httpx_error(e)
620
-
621
- try:
622
- metadata = TransportMetadata.from_response(response, start_time)
623
- if response.status_code >= 400:
624
- # read_full body so error parsing can see it
625
- response.read()
626
- _raise_for_error_status(
627
- response.status_code,
628
- response.content,
629
- metadata,
630
- error_model=error_model,
631
- )
632
-
633
- adapter: TypeAdapter[Any] | None = None
634
- if (
635
- item_model is not None
636
- and item_model is not NO_VALIDATION
637
- and item_model != "Any"
638
- and item_model is not Any
639
- ):
640
- adapter = TypeAdapter(item_model)
670
+ url = f"{self.base_url}/{path.lstrip('/')}"
671
+ client_request = self._build_client_request(
672
+ path, json_data, json_model, headers_model
673
+ )
674
+ request = self._build_httpx_request(client_request, url, params)
675
+ request.headers["accept"] = "text/event-stream"
641
676
 
677
+ start_time = time.time()
642
678
  try:
643
- for event in parse_sse(response.iter_lines()):
644
- if adapter is None:
645
- yield json.loads(event.data)
646
- else:
647
- try:
648
- yield adapter.validate_json(event.data)
649
- except PydanticValidationError as e:
650
- raise ValidationError(
651
- f"SSE event validation failed: {e}",
652
- validation_errors=e.errors(),
653
- cause=e,
654
- )
679
+ response = self._client.send(request, stream=True)
655
680
  except httpx.TimeoutException as e:
656
681
  raise TimeoutError.from_httpx_timeout(e)
657
682
  except httpx.RequestError as e:
658
683
  raise NetworkError.from_httpx_error(e)
659
- finally:
660
- response.close()
684
+
685
+ try:
686
+ metadata = TransportMetadata.from_response(response, start_time)
687
+ response_headers = _parse_response_headers(
688
+ response.headers, response_headers_model
689
+ )
690
+ stream._set_response(metadata, response_headers)
691
+ if response.status_code >= 400:
692
+ # read_full body so error parsing can see it
693
+ response.read()
694
+ _raise_for_error_status(
695
+ response.status_code,
696
+ response.content,
697
+ metadata,
698
+ error_model=error_model,
699
+ response_headers=response_headers,
700
+ )
701
+
702
+ adapter: TypeAdapter[Any] | None = None
703
+ if (
704
+ item_model is not None
705
+ and item_model is not NO_VALIDATION
706
+ and item_model != "Any"
707
+ and item_model is not Any
708
+ ):
709
+ adapter = TypeAdapter(item_model)
710
+
711
+ try:
712
+ for event in parse_sse(response.iter_lines()):
713
+ if adapter is None:
714
+ yield json.loads(event.data)
715
+ else:
716
+ try:
717
+ yield adapter.validate_json(event.data)
718
+ except PydanticValidationError as e:
719
+ raise ValidationError(
720
+ f"SSE event validation failed: {e}",
721
+ validation_errors=e.errors(),
722
+ cause=e,
723
+ )
724
+ except httpx.TimeoutException as e:
725
+ raise TimeoutError.from_httpx_timeout(e)
726
+ except httpx.RequestError as e:
727
+ raise NetworkError.from_httpx_error(e)
728
+ finally:
729
+ response.close()
730
+
731
+ return ApiStream(open_items)
661
732
 
662
733
 
663
734
  class AsyncClientBase(ABC):
@@ -784,6 +855,7 @@ class AsyncClientBase(ABC):
784
855
  headers_model: BaseModel | None = None,
785
856
  response_model: type[T],
786
857
  error_model: type[Any] | None = None,
858
+ response_headers_model: type[Any] | None = None,
787
859
  ) -> ApiResponse[T]: ...
788
860
 
789
861
  @overload
@@ -798,6 +870,7 @@ class AsyncClientBase(ABC):
798
870
  headers_model: BaseModel | None = None,
799
871
  response_model: None = None,
800
872
  error_model: type[Any] | None = None,
873
+ response_headers_model: type[Any] | None = None,
801
874
  ) -> ApiResponse[dict[str, Any]]: ...
802
875
 
803
876
  @overload
@@ -812,6 +885,7 @@ class AsyncClientBase(ABC):
812
885
  headers_model: BaseModel | None = None,
813
886
  response_model: type[Any],
814
887
  error_model: type[Any] | None = None,
888
+ response_headers_model: type[Any] | None = None,
815
889
  ) -> ApiResponse[Any]: ...
816
890
 
817
891
  @overload
@@ -826,6 +900,7 @@ class AsyncClientBase(ABC):
826
900
  headers_model: BaseModel | None = None,
827
901
  response_model: str,
828
902
  error_model: type[Any] | None = None,
903
+ response_headers_model: type[Any] | None = None,
829
904
  ) -> ApiResponse[dict[str, Any]]: ...
830
905
 
831
906
  @overload
@@ -840,6 +915,7 @@ class AsyncClientBase(ABC):
840
915
  headers_model: BaseModel | None = None,
841
916
  response_model: type[T],
842
917
  error_model: type[Any] | None = None,
918
+ response_headers_model: type[Any] | None = None,
843
919
  ) -> ApiResponse[T]: ...
844
920
 
845
921
  @overload
@@ -854,6 +930,7 @@ class AsyncClientBase(ABC):
854
930
  headers_model: BaseModel | None = None,
855
931
  response_model: None = None,
856
932
  error_model: type[Any] | None = None,
933
+ response_headers_model: type[Any] | None = None,
857
934
  ) -> ApiResponse[dict[str, Any]]: ...
858
935
 
859
936
  def _validate_request_params(
@@ -1013,6 +1090,7 @@ class AsyncClientBase(ABC):
1013
1090
  headers_model: BaseModel | None = None,
1014
1091
  response_model: type[T] | type[Any] | str | _NoValidation | None = None,
1015
1092
  error_model: type | None = None,
1093
+ response_headers_model: type | None = None,
1016
1094
  ) -> ApiResponse[T] | ApiResponse[dict[str, Any]]:
1017
1095
  """Make an HTTP request and return an ApiResponse."""
1018
1096
  # Validate request parameters
@@ -1048,26 +1126,37 @@ class AsyncClientBase(ABC):
1048
1126
  ),
1049
1127
  )
1050
1128
 
1129
+ response_headers = _parse_response_headers(
1130
+ client_response.headers, response_headers_model
1131
+ )
1132
+
1051
1133
  # Handle error responses
1052
1134
  _raise_for_error_status(
1053
1135
  client_response.status,
1054
1136
  client_response.body,
1055
1137
  metadata,
1056
1138
  error_model=error_model,
1139
+ response_headers=response_headers,
1057
1140
  )
1058
1141
 
1059
1142
  # Validate and return response
1060
1143
  if response_model is not None:
1061
- return _validate_body(client_response.body, response_model, metadata)
1144
+ return _validate_body(
1145
+ client_response.body, response_model, metadata, response_headers
1146
+ )
1062
1147
  else:
1063
- return ApiResponse(_parse_json_body(client_response.body), metadata)
1148
+ return ApiResponse(
1149
+ _parse_json_body(client_response.body, metadata, response_headers),
1150
+ metadata,
1151
+ response_headers,
1152
+ )
1064
1153
 
1065
1154
  except httpx.TimeoutException as e:
1066
1155
  raise TimeoutError.from_httpx_timeout(e)
1067
1156
  except httpx.RequestError as e:
1068
1157
  raise NetworkError.from_httpx_error(e)
1069
1158
 
1070
- async def _make_sse_request(
1159
+ def _make_sse_request(
1071
1160
  self,
1072
1161
 
1073
1162
  path: str,
@@ -1078,7 +1167,8 @@ class AsyncClientBase(ABC):
1078
1167
  headers_model: BaseModel | None = None,
1079
1168
  item_model: type[T] | type[Any] | str | _NoValidation | None = None,
1080
1169
  error_model: type | None = None,
1081
- ) -> AsyncIterator[Any]:
1170
+ response_headers_model: type | None = None,
1171
+ ) -> AsyncApiStream[Any]:
1082
1172
  """Open an SSE stream and yield items validated against ``item_model``.
1083
1173
 
1084
1174
  Errors raised when the server returns a non-2xx response are
@@ -1086,65 +1176,78 @@ class AsyncClientBase(ABC):
1086
1176
  problems raise ``NetworkError`` / ``TimeoutError``. Per-event
1087
1177
  validation failures raise ``ValidationError``.
1088
1178
 
1089
- The HTTP connection is released when the async generator is
1090
- exhausted or ``aclose()``-d, so consumers can ``break`` out of the
1091
- loop without leaking sockets. Middleware is intentionally
1092
- bypassed because typical middleware buffers the response body.
1179
+ The request is sent when iteration starts; the returned stream's
1180
+ ``metadata`` and ``headers`` are set once the response arrives. The
1181
+ HTTP connection is released when the stream is exhausted or
1182
+ ``aclose()``-d, so consumers can ``break`` out of the loop without
1183
+ leaking sockets. Middleware is intentionally bypassed because
1184
+ typical middleware buffers the response body.
1093
1185
  """
1094
1186
 
1095
- self._validate_request_params(json_data, json_model)
1096
-
1097
- url = f"{self.base_url}/{path.lstrip('/')}"
1098
- client_request = self._build_client_request(
1099
- path, json_data, json_model, headers_model
1100
- )
1101
- request = self._build_httpx_request(client_request, url, params)
1102
- request.headers["accept"] = "text/event-stream"
1103
-
1104
- start_time = time.time()
1105
- try:
1106
- response = await self._client.send(request, stream=True)
1107
- except httpx.TimeoutException as e:
1108
- raise TimeoutError.from_httpx_timeout(e)
1109
- except httpx.RequestError as e:
1110
- raise NetworkError.from_httpx_error(e)
1187
+ async def open_items(
1188
+ stream: AsyncApiStream[Any],
1189
+ ) -> AsyncGenerator[Any, None]:
1190
+ self._validate_request_params(json_data, json_model)
1111
1191
 
1112
- try:
1113
- metadata = TransportMetadata.from_response(response, start_time)
1114
- if response.status_code >= 400:
1115
- await response.aread()
1116
- _raise_for_error_status(
1117
- response.status_code,
1118
- response.content,
1119
- metadata,
1120
- error_model=error_model,
1121
- )
1122
-
1123
- adapter: TypeAdapter[Any] | None = None
1124
- if (
1125
- item_model is not None
1126
- and item_model is not NO_VALIDATION
1127
- and item_model != "Any"
1128
- and item_model is not Any
1129
- ):
1130
- adapter = TypeAdapter(item_model)
1192
+ url = f"{self.base_url}/{path.lstrip('/')}"
1193
+ client_request = self._build_client_request(
1194
+ path, json_data, json_model, headers_model
1195
+ )
1196
+ request = self._build_httpx_request(client_request, url, params)
1197
+ request.headers["accept"] = "text/event-stream"
1131
1198
 
1199
+ start_time = time.time()
1132
1200
  try:
1133
- async for event in aparse_sse(response.aiter_lines()):
1134
- if adapter is None:
1135
- yield json.loads(event.data)
1136
- else:
1137
- try:
1138
- yield adapter.validate_json(event.data)
1139
- except PydanticValidationError as e:
1140
- raise ValidationError(
1141
- f"SSE event validation failed: {e}",
1142
- validation_errors=e.errors(),
1143
- cause=e,
1144
- )
1201
+ response = await self._client.send(request, stream=True)
1145
1202
  except httpx.TimeoutException as e:
1146
1203
  raise TimeoutError.from_httpx_timeout(e)
1147
1204
  except httpx.RequestError as e:
1148
1205
  raise NetworkError.from_httpx_error(e)
1149
- finally:
1150
- await response.aclose()
1206
+
1207
+ try:
1208
+ metadata = TransportMetadata.from_response(response, start_time)
1209
+ response_headers = _parse_response_headers(
1210
+ response.headers, response_headers_model
1211
+ )
1212
+ stream._set_response(metadata, response_headers)
1213
+ if response.status_code >= 400:
1214
+ await response.aread()
1215
+ _raise_for_error_status(
1216
+ response.status_code,
1217
+ response.content,
1218
+ metadata,
1219
+ error_model=error_model,
1220
+ response_headers=response_headers,
1221
+ )
1222
+
1223
+ adapter: TypeAdapter[Any] | None = None
1224
+ if (
1225
+ item_model is not None
1226
+ and item_model is not NO_VALIDATION
1227
+ and item_model != "Any"
1228
+ and item_model is not Any
1229
+ ):
1230
+ adapter = TypeAdapter(item_model)
1231
+
1232
+ try:
1233
+ async for event in aparse_sse(response.aiter_lines()):
1234
+ if adapter is None:
1235
+ yield json.loads(event.data)
1236
+ else:
1237
+ try:
1238
+ yield adapter.validate_json(event.data)
1239
+ except PydanticValidationError as e:
1240
+ raise ValidationError(
1241
+ f"SSE event validation failed: {e}",
1242
+ validation_errors=e.errors(),
1243
+ cause=e,
1244
+ )
1245
+ except httpx.TimeoutException as e:
1246
+ raise TimeoutError.from_httpx_timeout(e)
1247
+ except httpx.RequestError as e:
1248
+ raise NetworkError.from_httpx_error(e)
1249
+ finally:
1250
+ await response.aclose()
1251
+
1252
+ return AsyncApiStream(open_items)
1253
+
@@ -22,10 +22,15 @@ class ApiError(Exception):
22
22
  *,
23
23
  metadata: TransportMetadata | None = None,
24
24
  cause: Exception | None = None,
25
+ headers: Any | None = None,
25
26
  ) -> None:
26
27
  super().__init__(message)
27
28
  self.metadata = metadata
28
29
  self.cause = cause
30
+ # The API's declared response headers, validated into the generated
31
+ # headers model; None if the API declares none or no response arrived.
32
+ # All headers, untyped, are in ``metadata.headers``.
33
+ self.headers = headers
29
34
 
30
35
  @property
31
36
  def status_code(self) -> int | None:
@@ -79,8 +84,9 @@ class ApplicationError(ApiError):
79
84
  metadata: TransportMetadata,
80
85
  error_data: Any | None = None,
81
86
  typed_error: Any | None = None,
87
+ headers: Any | None = None,
82
88
  ) -> None:
83
- super().__init__(message, metadata=metadata)
89
+ super().__init__(message, metadata=metadata, headers=headers)
84
90
  self.error_data = error_data
85
91
  self.typed_error = typed_error
86
92
 
@@ -124,6 +130,8 @@ class ValidationError(ApiError):
124
130
  *,
125
131
  validation_errors: list[Any] | None = None,
126
132
  cause: Exception | None = None,
133
+ metadata: TransportMetadata | None = None,
134
+ headers: Any | None = None,
127
135
  ) -> None:
128
- super().__init__(message, cause=cause)
136
+ super().__init__(message, metadata=metadata, cause=cause, headers=headers)
129
137
  self.validation_errors = validation_errors or []
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import time
6
+ from collections.abc import AsyncGenerator, AsyncIterator, Callable, Generator, Iterator
6
7
  from dataclasses import dataclass
7
8
  from typing import Any, Generic
8
9
 
@@ -11,6 +12,11 @@ from typing_extensions import TypeVar
11
12
 
12
13
  T = TypeVar("T", covariant=True)
13
14
  E = TypeVar("E", covariant=True, default=Any)
15
+ H = TypeVar("H", covariant=True, default=Any)
16
+ # Streams record the response after construction, so their parameters
17
+ # appear in setter arguments and can't be covariant.
18
+ Item = TypeVar("Item")
19
+ StreamH = TypeVar("StreamH", default=Any)
14
20
 
15
21
 
16
22
  @dataclass(frozen=True)
@@ -38,21 +44,25 @@ class TransportMetadata:
38
44
  )
39
45
 
40
46
 
41
- class ApiResponse(Generic[T, E]):
47
+ class ApiResponse(Generic[T, E, H]):
42
48
  """Wrapper for API responses with typed success and error values.
43
49
 
44
50
  Type parameters:
45
51
  T: The success response type.
46
52
  E: The error response type (defaults to Any when not specified).
53
+ H: The API's declared response headers model (defaults to Any).
47
54
 
48
55
  Provides ergonomic access to both the deserialized value and transport metadata.
49
56
  Supports `ApiResponse[OutputType]` (backward compatible) and
50
57
  `ApiResponse[OutputType, ErrorType]` (with typed errors).
51
58
  """
52
59
 
53
- def __init__(self, value: T, metadata: TransportMetadata) -> None:
60
+ def __init__(
61
+ self, value: T, metadata: TransportMetadata, headers: H | None = None
62
+ ) -> None:
54
63
  self._value = value
55
64
  self._metadata = metadata
65
+ self._headers = headers
56
66
 
57
67
  @property
58
68
  def value(self) -> T:
@@ -64,6 +74,15 @@ class ApiResponse(Generic[T, E]):
64
74
  """Transport metadata including timing, headers, and status."""
65
75
  return self._metadata
66
76
 
77
+ @property
78
+ def headers(self) -> H | None:
79
+ """The API's declared response headers, validated into its headers model.
80
+
81
+ ``None`` if the API declares no response headers. All headers,
82
+ untyped, are in ``metadata.headers``.
83
+ """
84
+ return self._headers
85
+
67
86
  @property
68
87
  def data(self) -> T:
69
88
  """Alias for value for ergonomic access (useful when the payload is a dict)."""
@@ -79,7 +98,7 @@ class ApiResponse(Generic[T, E]):
79
98
  List of available attributes from both wrapper and value.
80
99
  """
81
100
  # Get ApiResponse's own attributes
82
- wrapper_attrs = ["value", "metadata", "data"]
101
+ wrapper_attrs = ["value", "metadata", "headers", "data"]
83
102
 
84
103
  # Get attributes from the wrapped value
85
104
  value_attrs = []
@@ -134,3 +153,77 @@ class ApiResponse(Generic[T, E]):
134
153
  return (
135
154
  f"ApiResponse(value={self._value!r}, status={self._metadata.status_code})"
136
155
  )
156
+
157
+
158
+ class ApiStream(Iterator[Item], Generic[Item, StreamH]):
159
+ """Items of a streaming endpoint, with the HTTP response they came from.
160
+
161
+ The request is sent when iteration starts, so ``metadata`` and
162
+ ``headers`` are ``None`` until the first item is requested. ``headers``
163
+ holds the API's declared response headers, validated into its headers
164
+ model; all headers, untyped, are in ``metadata.headers``.
165
+ """
166
+
167
+ def __init__(
168
+ self,
169
+ open_items: Callable[[ApiStream[Item, StreamH]], Generator[Item, None, None]],
170
+ ) -> None:
171
+ self._metadata: TransportMetadata | None = None
172
+ self._headers: StreamH | None = None
173
+ self._items = open_items(self)
174
+
175
+ @property
176
+ def metadata(self) -> TransportMetadata | None:
177
+ return self._metadata
178
+
179
+ @property
180
+ def headers(self) -> StreamH | None:
181
+ return self._headers
182
+
183
+ def _set_response(
184
+ self, metadata: TransportMetadata, headers: StreamH | None
185
+ ) -> None:
186
+ self._metadata = metadata
187
+ self._headers = headers
188
+
189
+ def __next__(self) -> Item:
190
+ return next(self._items)
191
+
192
+ def close(self) -> None:
193
+ """Release the connection without reading the rest of the stream."""
194
+ self._items.close()
195
+
196
+
197
+ class AsyncApiStream(AsyncIterator[Item], Generic[Item, StreamH]):
198
+ """Async counterpart of :class:`ApiStream`."""
199
+
200
+ def __init__(
201
+ self,
202
+ open_items: Callable[
203
+ [AsyncApiStream[Item, StreamH]], AsyncGenerator[Item, None]
204
+ ],
205
+ ) -> None:
206
+ self._metadata: TransportMetadata | None = None
207
+ self._headers: StreamH | None = None
208
+ self._items = open_items(self)
209
+
210
+ @property
211
+ def metadata(self) -> TransportMetadata | None:
212
+ return self._metadata
213
+
214
+ @property
215
+ def headers(self) -> StreamH | None:
216
+ return self._headers
217
+
218
+ def _set_response(
219
+ self, metadata: TransportMetadata, headers: StreamH | None
220
+ ) -> None:
221
+ self._metadata = metadata
222
+ self._headers = headers
223
+
224
+ async def __anext__(self) -> Item:
225
+ return await self._items.__anext__()
226
+
227
+ async def aclose(self) -> None:
228
+ """Release the connection without reading the rest of the stream."""
229
+ await self._items.aclose()
@@ -1,10 +1,11 @@
1
1
  """Tests for the client base classes."""
2
2
 
3
+ import datetime
3
4
  from unittest.mock import AsyncMock, Mock
4
5
 
5
6
  import httpx
6
7
  import pytest
7
- from pydantic import BaseModel
8
+ from pydantic import BaseModel, ConfigDict, Field
8
9
 
9
10
  from reflectapi_runtime import (
10
11
  ApiResponse,
@@ -1173,3 +1174,137 @@ class TestParsingBypassesSyntheticHttpxResponse:
1173
1174
  len(body)
1174
1175
  )
1175
1176
  assert result.metadata.headers.get("content-length") == "9999"
1177
+
1178
+
1179
+ class SampleResponseHeaders(BaseModel):
1180
+ """Shaped like a generated response headers model."""
1181
+
1182
+ model_config = ConfigDict(populate_by_name=True)
1183
+
1184
+ retry_after: str | None = Field(default=None, validation_alias="retry-after")
1185
+ request_id: str | None = Field(default=None, validation_alias="x-request-id")
1186
+
1187
+
1188
+ class FixedResponseClient:
1189
+ def __init__(self, status: int, headers: dict[str, str], body: bytes) -> None:
1190
+ self.response = Response(
1191
+ status=status, headers=httpx.Headers(headers), body=body
1192
+ )
1193
+
1194
+ def request(self, request: Request) -> Response:
1195
+ return self.response
1196
+
1197
+
1198
+ class AsyncFixedResponseClient(FixedResponseClient):
1199
+ async def request(self, request: Request) -> Response: # type: ignore[override]
1200
+ return self.response
1201
+
1202
+
1203
+ class TestResponseHeaders:
1204
+ def test_declared_headers_on_success(self):
1205
+ transport = FixedResponseClient(
1206
+ 200,
1207
+ {"X-Request-ID": "req-1", "cf-ray": "abc"},
1208
+ b'{"name":"test","age":25}',
1209
+ )
1210
+ client = ClientBase("http://example.com", client=transport)
1211
+
1212
+ result = client._make_request(
1213
+ "/test",
1214
+ response_model=SampleModel,
1215
+ response_headers_model=SampleResponseHeaders,
1216
+ )
1217
+
1218
+ assert result.headers == SampleResponseHeaders(request_id="req-1")
1219
+ assert result.metadata.headers["cf-ray"] == "abc"
1220
+
1221
+ def test_declared_headers_on_application_error(self):
1222
+ transport = FixedResponseClient(429, {"retry-after": "7"}, b"slow down")
1223
+ client = ClientBase("http://example.com", client=transport)
1224
+
1225
+ with pytest.raises(ApplicationError) as exc_info:
1226
+ client._make_request(
1227
+ "/test",
1228
+ response_model=SampleModel,
1229
+ response_headers_model=SampleResponseHeaders,
1230
+ )
1231
+
1232
+ assert exc_info.value.headers == SampleResponseHeaders(retry_after="7")
1233
+
1234
+ def test_declared_headers_on_invalid_success_body(self):
1235
+ transport = FixedResponseClient(200, {"x-request-id": "req-2"}, b'{"name":1}')
1236
+ client = ClientBase("http://example.com", client=transport)
1237
+
1238
+ with pytest.raises(ValidationError) as exc_info:
1239
+ client._make_request(
1240
+ "/test",
1241
+ response_model=SampleModel,
1242
+ response_headers_model=SampleResponseHeaders,
1243
+ )
1244
+
1245
+ assert exc_info.value.status_code == 200
1246
+ assert exc_info.value.headers == SampleResponseHeaders(request_id="req-2")
1247
+
1248
+ def test_malformed_typed_header_reads_as_absent(self):
1249
+ class TypedHeaders(BaseModel):
1250
+ model_config = ConfigDict(populate_by_name=True)
1251
+
1252
+ expires_at: datetime.datetime | None = Field(
1253
+ default=None, validation_alias="x-expires-at"
1254
+ )
1255
+ request_id: str | None = Field(
1256
+ default=None, validation_alias="x-request-id"
1257
+ )
1258
+
1259
+ transport = FixedResponseClient(
1260
+ 503, {"x-expires-at": "garbage", "x-request-id": "req-3"}, b"unavailable"
1261
+ )
1262
+ client = ClientBase("http://example.com", client=transport)
1263
+
1264
+ with pytest.raises(ApplicationError) as exc_info:
1265
+ client._make_request(
1266
+ "/test",
1267
+ response_model=SampleModel,
1268
+ response_headers_model=TypedHeaders,
1269
+ )
1270
+
1271
+ assert exc_info.value.status_code == 503
1272
+ assert exc_info.value.headers == TypedHeaders(request_id="req-3")
1273
+ assert exc_info.value.metadata.headers["x-expires-at"] == "garbage"
1274
+
1275
+ def test_no_declared_headers(self):
1276
+ client = ClientBase("http://example.com", client=ShapeClient())
1277
+
1278
+ result = client._make_request("/test", response_model=SampleModel)
1279
+
1280
+ assert result.headers is None
1281
+
1282
+ @pytest.mark.asyncio
1283
+ async def test_declared_headers_on_async_success(self):
1284
+ transport = AsyncFixedResponseClient(
1285
+ 200, {"retry-after": "3"}, b'{"name":"test","age":25}'
1286
+ )
1287
+ client = AsyncClientBase("http://example.com", client=transport)
1288
+
1289
+ result = await client._make_request(
1290
+ "/test",
1291
+ response_model=SampleModel,
1292
+ response_headers_model=SampleResponseHeaders,
1293
+ )
1294
+
1295
+ assert result.headers == SampleResponseHeaders(retry_after="3")
1296
+
1297
+ @pytest.mark.asyncio
1298
+ async def test_declared_headers_on_async_application_error(self):
1299
+ transport = AsyncFixedResponseClient(503, {"retry-after": "30"}, b"unavailable")
1300
+ client = AsyncClientBase("http://example.com", client=transport)
1301
+
1302
+ with pytest.raises(ApplicationError) as exc_info:
1303
+ await client._make_request(
1304
+ "/test",
1305
+ response_model=SampleModel,
1306
+ response_headers_model=SampleResponseHeaders,
1307
+ )
1308
+
1309
+ assert exc_info.value.status_code == 503
1310
+ assert exc_info.value.headers == SampleResponseHeaders(retry_after="30")
@@ -10,7 +10,7 @@ from typing import Any
10
10
 
11
11
  import httpx
12
12
  import pytest
13
- from pydantic import BaseModel
13
+ from pydantic import BaseModel, ConfigDict, Field
14
14
 
15
15
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
16
16
 
@@ -130,6 +130,12 @@ class _Pet(BaseModel):
130
130
  weight: float
131
131
 
132
132
 
133
+ class _StreamHeaders(BaseModel):
134
+ model_config = ConfigDict(populate_by_name=True)
135
+
136
+ request_id: str | None = Field(default=None, validation_alias="x-request-id")
137
+
138
+
133
139
  class _SyncDemo(ClientBase):
134
140
  """Minimal concrete subclass — exposes _make_sse_request as a public method."""
135
141
 
@@ -138,6 +144,11 @@ class _SyncDemo(ClientBase):
138
144
  "pets", item_model=_Pet, error_model=None
139
145
  )
140
146
 
147
+ def stream_pets_with_headers(self) -> Any:
148
+ return self._make_sse_request(
149
+ "pets", item_model=_Pet, response_headers_model=_StreamHeaders
150
+ )
151
+
141
152
 
142
153
  class _AsyncDemo(AsyncClientBase):
143
154
  def stream_pets(self) -> Any:
@@ -145,6 +156,11 @@ class _AsyncDemo(AsyncClientBase):
145
156
  "pets", item_model=_Pet, error_model=None
146
157
  )
147
158
 
159
+ def stream_pets_with_headers(self) -> Any:
160
+ return self._make_sse_request(
161
+ "pets", item_model=_Pet, response_headers_model=_StreamHeaders
162
+ )
163
+
148
164
 
149
165
  def _sse_body(items: list[dict]) -> bytes:
150
166
  return ("".join(f"data: {json.dumps(it)}\n\n" for it in items)).encode()
@@ -349,3 +365,36 @@ def event_loop(): # pragma: no cover - pytest-asyncio compatibility shim
349
365
  loop = asyncio.new_event_loop()
350
366
  yield loop
351
367
  loop.close()
368
+
369
+
370
+ def test_sync_stream_exposes_response_once_iteration_starts() -> None:
371
+ def handler(request: httpx.Request) -> httpx.Response:
372
+ body = _sse_body([{"name": "fido", "weight": 1.5}])
373
+ return httpx.Response(
374
+ 200, content=body, headers={"X-Request-ID": "req-1"}
375
+ )
376
+
377
+ client = _make_sync_client(httpx.MockTransport(handler))
378
+ stream = client.stream_pets_with_headers()
379
+ assert stream.headers is None and stream.metadata is None
380
+ assert next(stream) == _Pet(name="fido", weight=1.5)
381
+ assert stream.metadata.status_code == 200
382
+ assert stream.headers == _StreamHeaders(request_id="req-1")
383
+ stream.close()
384
+ client.close()
385
+
386
+
387
+ @pytest.mark.asyncio
388
+ async def test_async_stream_init_error_carries_headers() -> None:
389
+ def handler(request: httpx.Request) -> httpx.Response:
390
+ return httpx.Response(
391
+ 429, text="slow down", headers={"x-request-id": "req-2"}
392
+ )
393
+
394
+ client = _make_async_client(httpx.MockTransport(handler))
395
+ stream = client.stream_pets_with_headers()
396
+ with pytest.raises(ApplicationError) as ei:
397
+ await stream.__anext__()
398
+ assert ei.value.headers == _StreamHeaders(request_id="req-2")
399
+ assert stream.headers == _StreamHeaders(request_id="req-2")
400
+ await client.aclose()