sg-python-api 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,945 @@
1
+ """Async attempt, consumption, retry, and observer orchestration."""
2
+
3
+ import asyncio
4
+ import copy
5
+ import dataclasses
6
+ import inspect
7
+ import sys
8
+ import threading
9
+ import time
10
+ from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Iterator, Mapping
11
+ from contextlib import suppress
12
+ from io import BytesIO
13
+ from typing import Protocol, TypeVar, cast
14
+
15
+ from . import _protocol, _transport
16
+ from ._async_transport import AsyncTransport
17
+ from ._config import (
18
+ CompatibilityWarningCode,
19
+ EventRoute,
20
+ ObserverCallback,
21
+ Outcome,
22
+ ProgressEvent,
23
+ RequestContext,
24
+ RequestEvent,
25
+ RetryEvent,
26
+ compatibility_warning_events,
27
+ event_route,
28
+ notify_observer,
29
+ response_correlation_id,
30
+ )
31
+ from ._execution import (
32
+ AttemptOutcomes,
33
+ AttemptRetry,
34
+ AttemptState,
35
+ AttemptSuccess,
36
+ ExceptionCapture,
37
+ SuppressedExceptionCapture,
38
+ build_rpc_request,
39
+ )
40
+ from ._transport import (
41
+ ReadTimeoutError,
42
+ SendError,
43
+ TransportError,
44
+ TransportRequest,
45
+ )
46
+
47
+ _T = TypeVar('_T')
48
+
49
+ _DETACHED_CLEANUP_TASKS: set[asyncio.Task[None]] = set()
50
+ _HTTP_REDIRECT = 302
51
+
52
+
53
+ @dataclasses.dataclass(frozen=True)
54
+ class AsyncAttemptControl:
55
+ """Consume async attempt bytes under deadline and progress control."""
56
+
57
+ _attempt: int
58
+ _clock: Callable[[], float]
59
+ _deadline: float | None
60
+ _observer: ObserverCallback | None
61
+ _operation: _protocol.OperationCategory
62
+ _route: EventRoute
63
+
64
+ def _check_deadline(self) -> None:
65
+ """Raise when cumulative monotonic attempt time exceeds its limit."""
66
+ if self._deadline is not None and self._clock() > self._deadline:
67
+ raise ReadTimeoutError('total attempt timeout exceeded')
68
+
69
+ async def _consume_buffered(self, body: bytes, total_bytes: int | None = None) -> bytes:
70
+ """Observe a received buffered body without rebuilding it."""
71
+ self._check_deadline()
72
+ if body:
73
+ notify_observer(
74
+ self._observer,
75
+ ProgressEvent(
76
+ operation=self._operation,
77
+ route=self._route,
78
+ attempt=self._attempt,
79
+ bytes_transferred=len(body),
80
+ total_bytes=total_bytes,
81
+ ),
82
+ )
83
+ return body
84
+
85
+ def _remaining_timeout(self) -> float | None:
86
+ """Return the remaining cumulative attempt timeout."""
87
+ if self._deadline is None:
88
+ return None
89
+ remaining = self._deadline - self._clock()
90
+ if remaining < 0:
91
+ raise ReadTimeoutError('total attempt timeout exceeded')
92
+ return remaining
93
+
94
+ async def consume_into(
95
+ self,
96
+ chunks: AsyncIterator[bytes],
97
+ sink: Callable[[bytes], object | Awaitable[object]],
98
+ total_bytes: int | None = None,
99
+ ) -> None:
100
+ """Write async chunks under deadline and progress control.
101
+
102
+ Args:
103
+ chunks: Fresh response-body chunks for this attempt.
104
+ sink: Synchronous or awaitable attempt-local byte sink.
105
+ total_bytes: Known complete body size, when available.
106
+
107
+ Raises:
108
+ ReadTimeoutError: If cumulative attempt time exceeds the total
109
+ limit.
110
+ """
111
+ transferred = 0
112
+ async for chunk in chunks:
113
+ sink_result = sink(chunk)
114
+ if inspect.isawaitable(sink_result):
115
+ await sink_result
116
+ transferred += len(chunk)
117
+ self._check_deadline()
118
+ if chunk:
119
+ notify_observer(
120
+ self._observer,
121
+ ProgressEvent(
122
+ operation=self._operation,
123
+ route=self._route,
124
+ attempt=self._attempt,
125
+ bytes_transferred=transferred,
126
+ total_bytes=total_bytes,
127
+ ),
128
+ )
129
+
130
+ async def consume(
131
+ self,
132
+ chunks: AsyncIterator[bytes],
133
+ total_bytes: int | None = None,
134
+ ) -> bytes:
135
+ """Collect async chunks while enforcing the attempt deadline.
136
+
137
+ Args:
138
+ chunks: Fresh response-body chunks for this attempt.
139
+ total_bytes: Known complete body size, when available.
140
+
141
+ Returns:
142
+ Concatenated response bytes.
143
+
144
+ Raises:
145
+ ReadTimeoutError: If cumulative attempt time exceeds the total
146
+ limit.
147
+ """
148
+ body = BytesIO()
149
+ await self.consume_into(chunks, body.write, total_bytes=total_bytes)
150
+ return body.getvalue()
151
+
152
+
153
+ @dataclasses.dataclass(frozen=True)
154
+ class _AsyncDriverTiming:
155
+ """Provide explicit deterministic async attempt timing sources."""
156
+
157
+ clock: Callable[[], float] = time.monotonic
158
+ jitter_seed: Callable[[], float] = time.perf_counter
159
+ sleep: Callable[[float], Awaitable[None]] = asyncio.sleep
160
+ wall_clock: Callable[[], float] = time.time
161
+
162
+
163
+ class AsyncLiveResponse(Protocol):
164
+ """Minimal response lifecycle required by the async executor.
165
+
166
+ Attributes:
167
+ headers: Read-only normalized response headers.
168
+ status: HTTP response status code.
169
+ """
170
+
171
+ def close(self) -> Awaitable[None] | None:
172
+ """Release this attempt response exactly once.
173
+
174
+ Returns:
175
+ Awaitable cleanup for async responses, otherwise `None`.
176
+ """
177
+ ...
178
+
179
+ @property
180
+ def headers(self) -> Mapping[str, str]:
181
+ """Return normalized response headers.
182
+
183
+ Returns:
184
+ Read-only response headers.
185
+ """
186
+ ...
187
+
188
+ @property
189
+ def status(self) -> int:
190
+ """Return the HTTP response status.
191
+
192
+ Returns:
193
+ HTTP status code.
194
+ """
195
+ ...
196
+
197
+
198
+ @dataclasses.dataclass(frozen=True)
199
+ class AsyncExecutionOptions:
200
+ """Control async executor transport choreography.
201
+
202
+ Attributes:
203
+ compatibility_warning_codes: Compatibility warnings emitted before
204
+ attempts.
205
+ logical_method_name: Optional observer-facing operation name.
206
+ redirect_factory: Optional request factory for redirect responses.
207
+ streaming: Whether the response body is consumed as a stream.
208
+ """
209
+
210
+ compatibility_warning_codes: tuple[CompatibilityWarningCode, ...] = ()
211
+ logical_method_name: str | None = None
212
+ redirect_factory: Callable[['AsyncLiveResponse'], TransportRequest] | None = None
213
+ streaming: bool = False
214
+
215
+
216
+ class AsyncDriver:
217
+ """Own the only async transport attempt and retry loop."""
218
+
219
+ def __init__(
220
+ self,
221
+ transport: AsyncTransport,
222
+ timing: _AsyncDriverTiming | None = None,
223
+ ) -> None:
224
+ """Initialize an async driver around one transport and timing source.
225
+
226
+ Args:
227
+ transport: Async transport used for every request attempt.
228
+ timing: Optional deterministic timing sources.
229
+ """
230
+ sources = timing or _AsyncDriverTiming()
231
+ self._clock = sources.clock
232
+ self._outcomes = AttemptOutcomes(clock=sources.clock, jitter_seed=sources.jitter_seed)
233
+ self._sleep = sources.sleep
234
+ self._transport = transport
235
+ self._wall_clock = sources.wall_clock
236
+
237
+ def _attempt_state(
238
+ self,
239
+ operation: _protocol.OperationDescriptor,
240
+ context: RequestContext,
241
+ route: EventRoute,
242
+ attempt: int,
243
+ ) -> AttemptState[AsyncAttemptControl]:
244
+ """Build immutable timing and observer state for one fresh attempt."""
245
+ started = self._clock()
246
+ deadline = None if context.timeout.total is None else started + context.timeout.total
247
+ control = AsyncAttemptControl(
248
+ _attempt=attempt,
249
+ _clock=self._clock,
250
+ _deadline=deadline,
251
+ _observer=context.on_progress,
252
+ _operation=operation.category,
253
+ _route=route,
254
+ )
255
+ return AttemptState(
256
+ context=context,
257
+ control=control,
258
+ number=attempt,
259
+ operation=operation,
260
+ route=route,
261
+ started=started,
262
+ )
263
+
264
+ async def _close_received_response_after_send_failure(
265
+ self,
266
+ response: AsyncLiveResponse,
267
+ state: AttemptState[AsyncAttemptControl],
268
+ primary_exception: BaseException,
269
+ response_status: int | None,
270
+ correlation_id: str | None,
271
+ ) -> None:
272
+ """Close a received response while preserving the active exception."""
273
+ _close_error, cancellation = await _close_response_capturing(
274
+ response,
275
+ primary_exception,
276
+ )
277
+ self._outcomes.emit_response(
278
+ state,
279
+ status=response_status,
280
+ outcome='error',
281
+ correlation_id=correlation_id,
282
+ )
283
+ if cancellation is not None:
284
+ raise cancellation
285
+
286
+ async def _consume_response(
287
+ self,
288
+ response: AsyncLiveResponse,
289
+ state: AttemptState[AsyncAttemptControl],
290
+ consumer: Callable[[AsyncLiveResponse, AsyncAttemptControl], Awaitable[_T]],
291
+ ) -> AttemptRetry | AttemptSuccess[_T]:
292
+ """Classify and consume a response with exact-once cleanup."""
293
+ attempt_result: AttemptRetry | AttemptSuccess[_T] | None = None
294
+ correlation_id: str | None = None
295
+ exception_capture = ExceptionCapture()
296
+ planned_outcome: Outcome = 'error'
297
+ response_status: int | None = None
298
+ try:
299
+ with exception_capture:
300
+ response_status = response.status
301
+ response_headers = response.headers
302
+ correlation_id = response_correlation_id(response_headers)
303
+ state.control._check_deadline() # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
304
+ failure = _protocol.classify_http_failure(
305
+ response_status,
306
+ response_headers,
307
+ self._wall_clock(),
308
+ )
309
+ retry = (
310
+ None
311
+ if failure is None
312
+ else self._outcomes.retry(state, failure, response_status)
313
+ )
314
+ if retry is None:
315
+ attempt_result = AttemptSuccess(await consumer(response, state.control))
316
+ planned_outcome = 'success'
317
+ else:
318
+ attempt_result = retry
319
+ planned_outcome = 'retry'
320
+ except TransportError as exc:
321
+ with exception_capture:
322
+ retry = self._outcomes.retry(
323
+ state,
324
+ _protocol.classify_transport_failure(exc, response_received=True),
325
+ response_status,
326
+ )
327
+ if retry is None:
328
+ raise
329
+ attempt_result = retry
330
+ planned_outcome = 'retry'
331
+ finally:
332
+ close_error, cancellation = await _close_response_capturing(
333
+ response,
334
+ exception_capture.exception,
335
+ )
336
+ self._outcomes.emit_response(
337
+ state,
338
+ status=response_status,
339
+ outcome=(
340
+ 'error'
341
+ if exception_capture.exception is not None or cancellation is not None
342
+ else planned_outcome
343
+ ),
344
+ correlation_id=correlation_id,
345
+ )
346
+ if cancellation is not None:
347
+ raise cancellation
348
+ if (
349
+ close_error is not None
350
+ and attempt_result is None
351
+ and exception_capture.exception is None
352
+ ):
353
+ raise SendError('response cleanup failed') from None
354
+ if attempt_result is None:
355
+ raise AssertionError('response consumption ended without an outcome')
356
+ return attempt_result
357
+
358
+ def _prepare_request(
359
+ self,
360
+ request: TransportRequest,
361
+ state: AttemptState[AsyncAttemptControl],
362
+ ) -> TransportRequest:
363
+ """Attach progress control and publish one redacted request event."""
364
+ prepared = _request_with_progress(request, state)
365
+ notify_observer(
366
+ state.context.on_request,
367
+ RequestEvent(
368
+ operation=state.operation.category,
369
+ route=state.route,
370
+ http_method=prepared.method,
371
+ attempt=state.number,
372
+ ),
373
+ )
374
+ return prepared
375
+
376
+ async def _redirect_request(
377
+ self,
378
+ response: AsyncLiveResponse,
379
+ state: AttemptState[AsyncAttemptControl],
380
+ redirect_factory: Callable[[AsyncLiveResponse], TransportRequest],
381
+ ) -> TransportRequest:
382
+ """Close one redirect response and return its next request."""
383
+ correlation_id: str | None = None
384
+ exception_capture = ExceptionCapture()
385
+ request: TransportRequest | None = None
386
+ response_status: int | None = None
387
+ try:
388
+ with exception_capture:
389
+ response_status = response.status
390
+ correlation_id = response_correlation_id(response.headers)
391
+ request = redirect_factory(response)
392
+ finally:
393
+ close_error, cancellation = await _close_response_capturing(
394
+ response,
395
+ exception_capture.exception,
396
+ )
397
+ self._outcomes.emit_response(
398
+ state,
399
+ status=response_status,
400
+ outcome=(
401
+ 'error'
402
+ if exception_capture.exception is not None or cancellation is not None
403
+ else 'success'
404
+ ),
405
+ correlation_id=correlation_id,
406
+ )
407
+ if cancellation is not None:
408
+ raise cancellation
409
+ if close_error is not None and request is None and exception_capture.exception is None:
410
+ raise SendError('response cleanup failed') from None
411
+ return cast('TransportRequest', request)
412
+
413
+ async def _retry(self, observer: ObserverCallback | None, event: RetryEvent) -> None:
414
+ """Publish a retry event and sleep after attempt cleanup."""
415
+ notify_observer(observer, event)
416
+ await self._sleep(event.delay)
417
+
418
+ async def _send_request(
419
+ self,
420
+ request: TransportRequest,
421
+ state: AttemptState[AsyncAttemptControl],
422
+ *,
423
+ redirected: bool,
424
+ streaming: bool,
425
+ ) -> AsyncLiveResponse | AttemptRetry:
426
+ """Send once and close the request body under response ownership."""
427
+ correlation_id: str | None = None
428
+ response: AsyncLiveResponse | None = None
429
+ response_status: int | None = None
430
+ try:
431
+ try:
432
+ if redirected:
433
+ request = _request_with_remaining_timeout(request, state.control)
434
+ if streaming:
435
+ response = await self._transport.send_streaming(request)
436
+ else:
437
+ response = await self._transport.send(request)
438
+ response_status = response.status
439
+ correlation_id = response_correlation_id(response.headers)
440
+ finally:
441
+ await _close_request_body_after_send(request)
442
+ except BaseException as exc:
443
+ if response is not None:
444
+ await self._close_received_response_after_send_failure(
445
+ response,
446
+ state,
447
+ exc,
448
+ response_status,
449
+ correlation_id,
450
+ )
451
+ raise
452
+ if not isinstance(exc, TransportError):
453
+ raise
454
+ retry = self._outcomes.send_failure(exc, state)
455
+ if retry is None:
456
+ raise
457
+ return retry
458
+ if response is None:
459
+ raise AssertionError('transport returned without a response')
460
+ return response
461
+
462
+ async def execute(
463
+ self,
464
+ operation: _protocol.OperationDescriptor,
465
+ context: RequestContext,
466
+ request_factory: Callable[[int], TransportRequest],
467
+ consumer: Callable[[AsyncLiveResponse, AsyncAttemptControl], Awaitable[_T]],
468
+ options: AsyncExecutionOptions | None = None,
469
+ ) -> _T:
470
+ """Execute one logical operation through fresh transport attempts.
471
+
472
+ Args:
473
+ operation: Immutable replay and route classification.
474
+ context: Immutable operation configuration.
475
+ request_factory: Synchronous fresh-attempt builder.
476
+ consumer: Async buffered or streaming response consumer.
477
+ options: Redirect, streaming, and observer controls.
478
+
479
+ Returns:
480
+ The successful consumer result.
481
+
482
+ Raises:
483
+ TransportError: If a terminal or exhausted transport failure
484
+ occurs.
485
+ Exception: If request construction, status validation, response
486
+ consumption, or cleanup fails.
487
+ """
488
+ execution_options = options or AsyncExecutionOptions()
489
+ for event in compatibility_warning_events(
490
+ execution_options.compatibility_warning_codes,
491
+ context.timeout,
492
+ ):
493
+ notify_observer(context.on_compatibility_warning, event)
494
+ route = event_route(operation)
495
+ self._transport.checkout()
496
+ try:
497
+ for attempt in range(1, context.max_rpc_attempts + 1):
498
+ state = self._attempt_state(operation, context, route, attempt)
499
+ request = request_factory(attempt)
500
+ redirected = False
501
+ while True:
502
+ request = self._prepare_request(request, state)
503
+ sent = await self._send_request(
504
+ request,
505
+ state,
506
+ redirected=redirected,
507
+ streaming=execution_options.streaming,
508
+ )
509
+ if isinstance(sent, AttemptRetry):
510
+ await self._retry(context.on_retry, sent.event)
511
+ break
512
+ response = sent
513
+
514
+ redirect_factory = execution_options.redirect_factory
515
+ if (
516
+ redirect_factory is not None
517
+ and not redirected
518
+ and response.status == _HTTP_REDIRECT
519
+ ):
520
+ request = await self._redirect_request(
521
+ response,
522
+ state,
523
+ redirect_factory,
524
+ )
525
+ redirected = True
526
+ continue
527
+
528
+ attempt_result = await self._consume_response(response, state, consumer)
529
+ if isinstance(attempt_result, AttemptRetry):
530
+ await self._retry(context.on_retry, attempt_result.event)
531
+ break
532
+ return attempt_result.result
533
+ finally:
534
+ self._transport.checkin()
535
+ raise AssertionError('attempt loop exhausted without a terminal outcome')
536
+
537
+ async def _execute_rpc(
538
+ self,
539
+ method_name: str,
540
+ params: object,
541
+ context: RequestContext,
542
+ include_auth: bool,
543
+ options: AsyncExecutionOptions,
544
+ ) -> object:
545
+ """Supply fresh RPC builders and a decoder to the common executor."""
546
+ api_url = context.api_url
547
+ if api_url is None:
548
+ raise ValueError('server parameters must be configured before RPC I/O')
549
+ operation = _protocol.classify_rpc_operation(
550
+ method_name,
551
+ options.logical_method_name,
552
+ params,
553
+ context.has_one_use_auth_token,
554
+ )
555
+
556
+ def request_factory(_attempt: int) -> TransportRequest:
557
+ return build_rpc_request(method_name, params, context, include_auth=include_auth)
558
+
559
+ async def consume(
560
+ response: AsyncLiveResponse,
561
+ control: AsyncAttemptControl,
562
+ ) -> object:
563
+ buffered = cast('_AsyncBufferedResponse', response)
564
+ _protocol.check_http_status(
565
+ buffered.status,
566
+ buffered.reason,
567
+ context.canonical_origin or '',
568
+ buffered.headers,
569
+ buffered.content,
570
+ )
571
+ body = await control._consume_buffered( # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
572
+ buffered.content,
573
+ total_bytes=len(buffered.content),
574
+ )
575
+ decoded = _protocol.decode_response(buffered.headers, body)
576
+ _protocol.raise_fault(decoded)
577
+ return _protocol.extract_results(decoded)
578
+
579
+ return await self.execute(
580
+ operation,
581
+ context,
582
+ request_factory,
583
+ consume,
584
+ options=options,
585
+ )
586
+
587
+ async def call(
588
+ self,
589
+ method_name: str,
590
+ params: object,
591
+ context: RequestContext,
592
+ include_auth: bool = True,
593
+ compatibility_warning_codes: tuple[CompatibilityWarningCode, ...] = (),
594
+ *,
595
+ _logical_method_name: str | None = None,
596
+ ) -> object:
597
+ """Adapt one RPC to the common async attempt executor.
598
+
599
+ Args:
600
+ method_name: Normalized RPC wire method name.
601
+ params: Normalized method parameters.
602
+ context: Immutable operation configuration.
603
+ include_auth: Whether to include captured authentication.
604
+ compatibility_warning_codes: Sanitized compatibility notices.
605
+ _logical_method_name: Optional logical operation name when it
606
+ differs from the wire method.
607
+
608
+ Returns:
609
+ Decoded RPC results.
610
+
611
+ Raises:
612
+ TransportError: If a terminal or exhausted transport failure
613
+ occurs.
614
+ Exception: If request construction, status validation, response
615
+ consumption, or cleanup fails.
616
+ """
617
+ normalized_params = copy.deepcopy(params)
618
+ options = AsyncExecutionOptions(
619
+ compatibility_warning_codes=compatibility_warning_codes,
620
+ logical_method_name=_logical_method_name,
621
+ )
622
+ return await self._execute_rpc(
623
+ method_name,
624
+ normalized_params,
625
+ context,
626
+ include_auth=False if method_name == 'info' else include_auth,
627
+ options=options,
628
+ )
629
+
630
+ async def call_pages(
631
+ self,
632
+ build_page_params: Callable[[int], object],
633
+ first_page: int,
634
+ method_name: str,
635
+ context: RequestContext,
636
+ ) -> AsyncGenerator[object, None]:
637
+ """Yield page results in order from one immutable context.
638
+
639
+ The sequence is unbounded, so the consumer decides when to stop and
640
+ must call `aclose()` on early exit. When prefetch is active, cleanup
641
+ cancels and drains pending prefetch work.
642
+
643
+ Args:
644
+ build_page_params: Build independent parameters for a page number.
645
+ first_page: First page number to request.
646
+ method_name: Normalized RPC method name.
647
+ context: Immutable operation configuration.
648
+
649
+ Yields:
650
+ Decoded RPC results in page order.
651
+ """
652
+ if context.page_prefetch <= 0:
653
+ page = first_page
654
+ while True:
655
+ yield await self.call(method_name, build_page_params(page), context)
656
+ page += 1
657
+
658
+ pending: dict[int, asyncio.Task[object]] = {}
659
+
660
+ def submit(page_number: int) -> None:
661
+ if page_number not in pending:
662
+ params = build_page_params(page_number)
663
+ task = asyncio.create_task(self.call(method_name, params, context))
664
+ pending[page_number] = task
665
+ self._transport.reserve(task)
666
+
667
+ page = first_page
668
+ try:
669
+ submit(page)
670
+ while True:
671
+ yield await pending.pop(page)
672
+ page += 1
673
+ for page_number in range(page, page + context.page_prefetch):
674
+ submit(page_number)
675
+ finally:
676
+ tasks = tuple(pending.values())
677
+ for task in tasks:
678
+ task.cancel()
679
+ if tasks:
680
+ await asyncio.gather(*tasks, return_exceptions=True)
681
+
682
+ async def poll(
683
+ self,
684
+ predicate: Callable[[], Awaitable[bool]],
685
+ attempts: int,
686
+ interval: float,
687
+ ) -> bool:
688
+ """Poll an async predicate with driver-owned sleeps between reads.
689
+
690
+ Args:
691
+ predicate: Awaitable read operation returning readiness.
692
+ attempts: Maximum number of predicate calls.
693
+ interval: Seconds to sleep between calls.
694
+
695
+ Returns:
696
+ Whether the predicate became true within the attempt bound.
697
+
698
+ Raises:
699
+ ValueError: If `attempts` is not positive or `interval` is
700
+ negative.
701
+ """
702
+ if attempts <= 0:
703
+ raise ValueError('poll attempts must be positive')
704
+ if interval < 0:
705
+ raise ValueError('poll interval must be nonnegative')
706
+ for attempt in range(attempts):
707
+ if await predicate():
708
+ return True
709
+ if attempt + 1 < attempts:
710
+ await self._sleep(interval)
711
+ return False
712
+
713
+
714
+ class _AsyncBufferedResponse(AsyncLiveResponse, Protocol):
715
+ """Buffered response fields consumed only by the RPC adapter."""
716
+
717
+ @property
718
+ def content(self) -> bytes:
719
+ """Return the buffered response body.
720
+
721
+ Returns:
722
+ Complete response body bytes.
723
+ """
724
+ ...
725
+
726
+ @property
727
+ def reason(self) -> str:
728
+ """Return the HTTP reason phrase.
729
+
730
+ Returns:
731
+ HTTP reason phrase.
732
+ """
733
+ ...
734
+
735
+
736
+ class _ObservedRequestBody(Iterator[bytes]):
737
+ """Publish progress while owning exact-once request-body cleanup."""
738
+
739
+ def __init__(
740
+ self,
741
+ chunks: Iterator[bytes],
742
+ total_bytes: int | None,
743
+ state: AttemptState[AsyncAttemptControl],
744
+ ) -> None:
745
+ """Initialize request-body observation for one attempt."""
746
+ self._condition = threading.Condition()
747
+ self._chunks: Iterator[bytes] | None = chunks
748
+ self._active_pulls = 0
749
+ self._closed = False
750
+ self._closing = False
751
+ self._state = state
752
+ self._total_bytes = total_bytes
753
+ self._transferred = 0
754
+
755
+ def __iter__(self) -> '_ObservedRequestBody':
756
+ """Return this request-body iterator."""
757
+ return self
758
+
759
+ def __next__(self) -> bytes:
760
+ """Return and observe the next request-body chunk."""
761
+ chunk = self.pull_for_async()
762
+ if chunk is None:
763
+ raise StopIteration
764
+ self.observe_async_chunk(chunk)
765
+ return chunk
766
+
767
+ def close(self) -> None:
768
+ """Close the source iterator exactly once."""
769
+ with self._condition:
770
+ if self._closed:
771
+ return
772
+ if self._closing:
773
+ while not self._closed:
774
+ self._condition.wait()
775
+ return
776
+ self._closing = True
777
+ while self._active_pulls:
778
+ self._condition.wait()
779
+ chunks = self._chunks
780
+ self._chunks = None
781
+ try:
782
+ if chunks is not None:
783
+ close = _transport.body_close(chunks)
784
+ if close is not None:
785
+ close()
786
+ finally:
787
+ with self._condition:
788
+ self._closed = True
789
+ self._condition.notify_all()
790
+
791
+ def observe_async_chunk(self, chunk: bytes) -> None:
792
+ """Account for and report one asynchronously pulled chunk.
793
+
794
+ Args:
795
+ chunk: Chunk pulled from the source iterator.
796
+
797
+ Raises:
798
+ ReadTimeoutError: If the attempt deadline passed before the chunk
799
+ could be returned.
800
+ """
801
+ self._transferred += len(chunk)
802
+ if not chunk:
803
+ return
804
+ self._state.control._check_deadline() # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
805
+ notify_observer(
806
+ self._state.context.on_progress,
807
+ ProgressEvent(
808
+ operation=self._state.operation.category,
809
+ route=self._state.route,
810
+ attempt=self._state.number,
811
+ bytes_transferred=self._transferred,
812
+ total_bytes=self._total_bytes,
813
+ ),
814
+ )
815
+
816
+ def pull_for_async(self) -> bytes | None:
817
+ """Pull one source chunk without observing progress.
818
+
819
+ Returns:
820
+ The next chunk, or `None` when the source is exhausted or the body
821
+ is closing or closed.
822
+ """
823
+ with self._condition:
824
+ chunks = self._chunks
825
+ if chunks is None or self._closing:
826
+ return None
827
+ self._active_pulls += 1
828
+ try:
829
+ return next(chunks, None)
830
+ finally:
831
+ with self._condition:
832
+ self._active_pulls -= 1
833
+ self._condition.notify_all()
834
+
835
+
836
+ async def _attempt_response_close(response: AsyncLiveResponse) -> None:
837
+ """Run response cleanup to completion."""
838
+ result = response.close()
839
+ if inspect.isawaitable(result):
840
+ await result
841
+
842
+
843
+ async def _close_response_capturing(
844
+ response: AsyncLiveResponse,
845
+ operation_exception: BaseException | None,
846
+ ) -> tuple[BaseException | None, asyncio.CancelledError | None]:
847
+ """Join response cleanup while retaining the first caller cancellation."""
848
+ cancellation: asyncio.CancelledError | None = None
849
+ current = asyncio.current_task()
850
+ if (
851
+ isinstance(operation_exception, asyncio.CancelledError)
852
+ and current is not None
853
+ and current.cancelling() > 0
854
+ ):
855
+ cancellation = operation_exception
856
+ current.uncancel()
857
+ cleanup = asyncio.create_task(_attempt_response_close(response))
858
+ cleanup_capture = SuppressedExceptionCapture()
859
+ while not cleanup.done():
860
+ with cleanup_capture:
861
+ await asyncio.shield(cleanup)
862
+ error = cleanup_capture.exception
863
+ if (
864
+ isinstance(error, asyncio.CancelledError)
865
+ and current is not None
866
+ and current.cancelling() > 0
867
+ ):
868
+ if cancellation is None:
869
+ cancellation = error
870
+ current.uncancel()
871
+ continue
872
+ if error is not None:
873
+ break
874
+ result_capture = SuppressedExceptionCapture()
875
+ with result_capture:
876
+ cleanup.result()
877
+ return result_capture.exception, cancellation
878
+
879
+
880
+ def _finish_detached_cleanup(task: asyncio.Task[None]) -> None:
881
+ """Consume and release one completed detached cleanup task."""
882
+ with suppress(BaseException):
883
+ task.exception()
884
+ _DETACHED_CLEANUP_TASKS.discard(task)
885
+
886
+
887
+ def _request_with_progress(
888
+ request: TransportRequest,
889
+ state: AttemptState[AsyncAttemptControl],
890
+ ) -> TransportRequest:
891
+ """Wrap only streamed request bodies in attempt progress control."""
892
+ body = request.body
893
+ if body is None or isinstance(body, bytes):
894
+ return request
895
+ return dataclasses.replace(
896
+ request,
897
+ body=_ObservedRequestBody(
898
+ body,
899
+ _transport.content_length(request.headers),
900
+ state,
901
+ ),
902
+ )
903
+
904
+
905
+ def _request_with_remaining_timeout(
906
+ request: TransportRequest,
907
+ control: AsyncAttemptControl,
908
+ ) -> TransportRequest:
909
+ """Restrict a redirect hop to its remaining cumulative attempt timeout."""
910
+ return _transport.narrow_total_timeout(
911
+ request,
912
+ control._remaining_timeout(), # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
913
+ )
914
+
915
+
916
+ def _retain_detached_cleanup(task: asyncio.Task[None]) -> None:
917
+ """Retain a detached cleanup task until its completion is observed."""
918
+ _DETACHED_CLEANUP_TASKS.add(task)
919
+ task.add_done_callback(_finish_detached_cleanup)
920
+
921
+
922
+ async def _close_request_body_after_send(request: TransportRequest) -> None:
923
+ """Clean up a request body without replacing a primary exception."""
924
+ body = request.body
925
+ if body is None or isinstance(body, bytes):
926
+ return
927
+ primary_exception = sys.exception()
928
+
929
+ def run_close() -> None:
930
+ close = _transport.body_close(body)
931
+ if close is not None:
932
+ close()
933
+
934
+ cleanup = asyncio.create_task(asyncio.to_thread(run_close))
935
+ if isinstance(primary_exception, asyncio.CancelledError):
936
+ _retain_detached_cleanup(cleanup)
937
+ return
938
+ try:
939
+ await asyncio.wait({cleanup})
940
+ except asyncio.CancelledError:
941
+ _retain_detached_cleanup(cleanup)
942
+ raise
943
+ cleanup_error = asyncio.CancelledError() if cleanup.cancelled() else cleanup.exception()
944
+ if primary_exception is None and cleanup_error is not None:
945
+ raise cleanup_error