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,662 @@
1
+ """Async httpx2 transport adapter."""
2
+
3
+ import asyncio
4
+ import dataclasses
5
+ import ssl
6
+ from collections.abc import AsyncIterator, Callable, Iterator
7
+ from contextlib import suppress
8
+ from http.cookiejar import CookieJar, DefaultCookiePolicy
9
+ from typing import Self, cast
10
+
11
+ import httpx2
12
+
13
+ from ._async_transport import AsyncStreamingResponse
14
+ from ._transport import (
15
+ CaseInsensitiveHeaders,
16
+ ConnectError,
17
+ ProxyError,
18
+ ReadTimeoutError,
19
+ SendError,
20
+ TimeoutConfig,
21
+ TLSError,
22
+ TransportError,
23
+ TransportRequest,
24
+ TransportResponse,
25
+ )
26
+
27
+
28
+ class Httpx2Transport:
29
+ """Provide the shared async transport interface over httpx2.
30
+
31
+ Share an instance among tasks on one event loop. Logical RPC leases belong
32
+ to the task that checks them out and must be checked in by that task.
33
+ Streaming responses retain resources until they close.
34
+
35
+ Await `close()` after releasing the calling task's leases and streams.
36
+ Closing rejects unrelated new work, drains accepted work, and permanently
37
+ closes the transport.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ ca_certs: str | None = None,
43
+ proxy_url: str | None = None,
44
+ proxy_headers: dict[str, str] | None = None,
45
+ pool_maxsize: int = 10,
46
+ ) -> None:
47
+ """Initialize one shared async HTTP transport.
48
+
49
+ Args:
50
+ ca_certs: Optional certificate-authority bundle path.
51
+ proxy_url: Optional proxy URL.
52
+ proxy_headers: Optional headers sent to the proxy.
53
+ pool_maxsize: Maximum concurrent and keep-alive connections.
54
+ """
55
+ verify: ssl.SSLContext | bool = True
56
+ if ca_certs is not None:
57
+ verify = ssl.create_default_context(cafile=ca_certs)
58
+ proxy = None
59
+ if proxy_url is not None:
60
+ proxy = httpx2.Proxy(proxy_url, headers=proxy_headers)
61
+ self._client = httpx2.AsyncClient(
62
+ cookies=CookieJar(policy=DefaultCookiePolicy(allowed_domains=())),
63
+ verify=verify,
64
+ proxy=proxy,
65
+ trust_env=False,
66
+ limits=httpx2.Limits(
67
+ max_connections=pool_maxsize,
68
+ max_keepalive_connections=pool_maxsize,
69
+ ),
70
+ timeout=None,
71
+ )
72
+ self._active = 0
73
+ self._body_cleanup_tasks: set[asyncio.Task[None]] = set()
74
+ self._closed = False
75
+ self._closing = False
76
+ self._close_task: asyncio.Task[None] | None = None
77
+ self._idle = asyncio.Event()
78
+ self._idle.set()
79
+ self._lease_holders: dict[asyncio.Task[object], int] = {}
80
+ self._reserved_tasks: set[asyncio.Task[object]] = set()
81
+ self._stream_owners: dict[asyncio.Task[object], int] = {}
82
+
83
+ def _acquire_lease(self, task: asyncio.Task[object]) -> None:
84
+ """Acquire one active lease for an explicit task."""
85
+ self._active += 1
86
+ self._idle.clear()
87
+ self._lease_holders[task] = self._lease_holders.get(task, 0) + 1
88
+
89
+ def _end_active(self) -> None:
90
+ """Release one active lease and wake a draining close when idle."""
91
+ if self._active <= 0:
92
+ raise TransportError('async transport lease accounting underflow')
93
+ self._active -= 1
94
+ if self._active == 0:
95
+ self._idle.set()
96
+
97
+ def _finish_body_cleanup(self, task: asyncio.Task[None]) -> None:
98
+ """Consume and release one completed request-body cleanup task."""
99
+ with suppress(BaseException):
100
+ task.exception()
101
+ if task not in self._body_cleanup_tasks:
102
+ return
103
+ self._body_cleanup_tasks.remove(task)
104
+ self._end_active()
105
+
106
+ async def _finish_close(self) -> None:
107
+ """Drain active work, close the pool, and seal the transport."""
108
+ try:
109
+ await self._idle.wait()
110
+ try:
111
+ await self._client.aclose()
112
+ except (httpx2.HTTPError, httpx2.StreamError) as error:
113
+ raise _map_httpx2_error(error) from error
114
+ finally:
115
+ self._closed = True
116
+
117
+ def _has_lease(self, task: asyncio.Task[object]) -> bool:
118
+ """Return whether a task owns a logical RPC lease."""
119
+ return self._lease_holders.get(task, 0) > 0
120
+
121
+ async def _open_stream(
122
+ self,
123
+ request: httpx2.Request,
124
+ deadline: float | None,
125
+ ) -> httpx2.Response:
126
+ """Send through the response-header phase under the total deadline."""
127
+ try:
128
+ if deadline is None:
129
+ return await self._client.send(request, stream=True)
130
+ async with asyncio.timeout_at(deadline):
131
+ return await self._client.send(request, stream=True)
132
+ except TimeoutError as error:
133
+ if deadline is None:
134
+ raise SendError(str(error)) from error
135
+ raise ReadTimeoutError('total timeout exceeded before response body') from error
136
+ except (httpx2.HTTPError, httpx2.StreamError) as error:
137
+ raise _map_httpx2_error(error) from error
138
+
139
+ def _release_lease(self, task: asyncio.Task[object]) -> None:
140
+ """Release one active lease owned by an explicit task."""
141
+ count = self._lease_holders.get(task, 0)
142
+ if count <= 0:
143
+ raise TransportError('async transport lease accounting underflow')
144
+ if count == 1:
145
+ del self._lease_holders[task]
146
+ else:
147
+ self._lease_holders[task] = count - 1
148
+ self._end_active()
149
+
150
+ def _release_reservation(self, task: asyncio.Task[object]) -> None:
151
+ """Release one completed child-task reservation."""
152
+ if task not in self._reserved_tasks:
153
+ return
154
+ self._reserved_tasks.remove(task)
155
+ self._release_lease(task)
156
+
157
+ def _release_stream(self, owner: asyncio.Task[object]) -> None:
158
+ """Release an open-stream lease owned by a task."""
159
+ count = self._stream_owners.get(owner, 0)
160
+ if count <= 0:
161
+ raise TransportError('async streaming lease accounting underflow')
162
+ if count == 1:
163
+ del self._stream_owners[owner]
164
+ else:
165
+ self._stream_owners[owner] = count - 1
166
+ self._end_active()
167
+
168
+ def _require_send_available(self, task: asyncio.Task[object]) -> None:
169
+ """Reject new work after close while allowing a leased retry loop."""
170
+ if self._closed:
171
+ raise TransportError('async transport is closed')
172
+ if self._closing and not self._has_lease(task):
173
+ raise TransportError('async transport is closing')
174
+
175
+ def _begin_stream(self) -> asyncio.Task[object]:
176
+ """Acquire an open-stream lease for the current task."""
177
+ owner = _current_task()
178
+ self._require_send_available(owner)
179
+ self._active += 1
180
+ self._idle.clear()
181
+ self._stream_owners[owner] = self._stream_owners.get(owner, 0) + 1
182
+ return owner
183
+
184
+ def _retain_body_cleanup(self, body: 'bytes | _ThreadedBody | None') -> None:
185
+ """Retain background request-body cleanup as active transport work."""
186
+ task = _start_adapted_body_cleanup(body)
187
+ if task is None or task in self._body_cleanup_tasks:
188
+ return
189
+ self._body_cleanup_tasks.add(task)
190
+ self._active += 1
191
+ self._idle.clear()
192
+ task.add_done_callback(self._finish_body_cleanup)
193
+
194
+ def _retain_reserved_task(self, task: asyncio.Task[object]) -> None:
195
+ """Retain one accepted child task until its done callback runs."""
196
+ if task in self._reserved_tasks:
197
+ raise TransportError('async transport task is already reserved')
198
+ self._reserved_tasks.add(task)
199
+ self._acquire_lease(task)
200
+ task.add_done_callback(self._release_reservation)
201
+
202
+ def _reserve_stream_task(
203
+ self,
204
+ task: asyncio.Task[object],
205
+ owner: asyncio.Task[object],
206
+ ) -> None:
207
+ """Retain one child task on behalf of an open stream owner."""
208
+ if self._closed or self._stream_owners.get(owner, 0) <= 0:
209
+ raise TransportError('async transport stream is not active')
210
+ self._retain_reserved_task(task)
211
+
212
+ async def _send_once(self, request: TransportRequest) -> TransportResponse:
213
+ """Send and convert one buffered response."""
214
+ body = _adapt_body(request.body)
215
+ try:
216
+ response = await self._client.request(
217
+ request.method,
218
+ request.url,
219
+ headers=request.headers,
220
+ content=body,
221
+ timeout=_to_httpx2_timeout(request.timeout),
222
+ )
223
+ except BaseException:
224
+ self._retain_body_cleanup(body)
225
+ raise
226
+ await _close_adapted_body(body)
227
+ return TransportResponse(
228
+ status=response.status_code,
229
+ reason=response.reason_phrase,
230
+ headers=CaseInsensitiveHeaders(response.headers),
231
+ content=response.content,
232
+ )
233
+
234
+ def checkin(self) -> None:
235
+ """Release the current task's logical RPC lease.
236
+
237
+ Raises:
238
+ TransportError: If the current task owns no active lease.
239
+ """
240
+ owner = _current_task()
241
+ if not self._has_lease(owner):
242
+ raise TransportError('current task has no active lease')
243
+ self._release_lease(owner)
244
+
245
+ def checkout(self) -> None:
246
+ """Acquire a logical RPC lease for the current task.
247
+
248
+ Raises:
249
+ TransportError: If the transport is closed, or is closing and the
250
+ current task does not already hold a lease.
251
+ """
252
+ owner = _current_task()
253
+ self._require_send_available(owner)
254
+ self._acquire_lease(owner)
255
+
256
+ async def close(self) -> None:
257
+ """Drain active leases and close the pool exactly once.
258
+
259
+ Raises:
260
+ TransportError: If the current task owns a lease whose release
261
+ would depend on this call returning.
262
+ """
263
+ owner = _current_task()
264
+ if self._has_lease(owner) or self._stream_owners.get(owner, 0) > 0:
265
+ raise TransportError('cannot close async transport while holding an active lease')
266
+ if self._close_task is None:
267
+ self._closing = True
268
+ self._close_task = asyncio.create_task(self._finish_close())
269
+ await asyncio.shield(self._close_task)
270
+
271
+ def reserve(self, task: asyncio.Task[object]) -> None:
272
+ """Retain one child task until its done callback runs.
273
+
274
+ Args:
275
+ task: Child task whose scheduled work must survive close.
276
+
277
+ Raises:
278
+ TransportError: If the transport rejects the reserving owner or
279
+ `task` is already reserved.
280
+ """
281
+ owner = _current_task()
282
+ self._require_send_available(owner)
283
+ self._retain_reserved_task(task)
284
+
285
+ async def send(self, request: TransportRequest) -> TransportResponse:
286
+ """Send one buffered request.
287
+
288
+ Args:
289
+ request: Request expressed in shared transport types.
290
+
291
+ Returns:
292
+ The complete response in shared transport types.
293
+
294
+ Raises:
295
+ TransportError: If httpx2 cannot complete the request.
296
+ """
297
+ self._require_send_available(_current_task())
298
+ total = request.timeout.total
299
+ if total is None:
300
+ try:
301
+ return await self._send_once(request)
302
+ except (httpx2.HTTPError, httpx2.StreamError) as error:
303
+ raise _map_httpx2_error(error) from error
304
+ try:
305
+ async with asyncio.timeout(request.timeout.total):
306
+ return await self._send_once(request)
307
+ except TimeoutError as error:
308
+ raise ReadTimeoutError('total timeout exceeded') from error
309
+ except (httpx2.HTTPError, httpx2.StreamError) as error:
310
+ raise _map_httpx2_error(error) from error
311
+
312
+ async def send_streaming(self, request: TransportRequest) -> AsyncStreamingResponse:
313
+ """Send one request and stream its body under one total deadline.
314
+
315
+ Args:
316
+ request: Request expressed in shared transport types.
317
+
318
+ Returns:
319
+ A single-use streaming response in shared transport types.
320
+
321
+ Raises:
322
+ TransportError: If the request, response, or total deadline fails.
323
+ """
324
+ current_task = _current_task()
325
+ self._require_send_available(current_task)
326
+ deadline = None
327
+ if request.timeout.total is not None:
328
+ deadline = asyncio.get_running_loop().time() + request.timeout.total
329
+ body = _adapt_body(request.body)
330
+ try:
331
+ outgoing = self._client.build_request(
332
+ request.method,
333
+ request.url,
334
+ headers=request.headers,
335
+ content=body,
336
+ timeout=_to_httpx2_timeout(request.timeout),
337
+ )
338
+ except (httpx2.HTTPError, httpx2.StreamError) as error:
339
+ self._retain_body_cleanup(body)
340
+ raise _map_httpx2_error(error) from error
341
+ except BaseException:
342
+ self._retain_body_cleanup(body)
343
+ raise
344
+
345
+ owner = self._begin_stream()
346
+ try:
347
+ response = await self._open_stream(outgoing, deadline)
348
+ except BaseException:
349
+ self._retain_body_cleanup(body)
350
+ self._release_stream(owner)
351
+ raise
352
+ try:
353
+ await _close_adapted_body(body)
354
+ except BaseException:
355
+ with suppress(BaseException):
356
+ await response.aclose()
357
+ self._release_stream(owner)
358
+ raise
359
+
360
+ body_owner = _ResponseBody(
361
+ response,
362
+ deadline,
363
+ lambda: self._release_stream(owner),
364
+ lambda task: self._reserve_stream_task(task, owner),
365
+ )
366
+
367
+ return AsyncStreamingResponse(
368
+ status=response.status_code,
369
+ reason=response.reason_phrase,
370
+ headers=CaseInsensitiveHeaders(response.headers),
371
+ stream=body_owner.stream,
372
+ close_fn=body_owner.close,
373
+ )
374
+
375
+
376
+ @dataclasses.dataclass(frozen=True)
377
+ class _PulledChunk:
378
+ """Carry one worker-thread pull into its event-loop observation phase."""
379
+
380
+ chunk: bytes | None
381
+ observe: Callable[[bytes], None] | None
382
+
383
+
384
+ class _ResponseBody:
385
+ """Own response pulls and cleanup until the stream lease is released."""
386
+
387
+ def __init__(
388
+ self,
389
+ response: httpx2.Response,
390
+ deadline: float | None,
391
+ release: Callable[[], None],
392
+ reserve: Callable[[asyncio.Task[object]], None],
393
+ ) -> None:
394
+ """Initialize one deadline-aware async response body."""
395
+ self._deadline = deadline
396
+ self._pending: asyncio.Task[bytes] | None = None
397
+ self._release = release
398
+ self._reserve = reserve
399
+ self._response = response
400
+ self._task: asyncio.Task[None] | None = None
401
+
402
+ async def _finish(self) -> None:
403
+ """Join the active pull, close the response, and release its lease."""
404
+ pending = self._pending
405
+ if pending is not None:
406
+ with suppress(BaseException):
407
+ await asyncio.shield(pending)
408
+ if self._pending is pending:
409
+ self._pending = None
410
+ try:
411
+ await self._response.aclose()
412
+ except (httpx2.HTTPError, httpx2.StreamError) as error:
413
+ raise _map_httpx2_error(error) from error
414
+ finally:
415
+ self._release()
416
+
417
+ def _start(self) -> asyncio.Task[None]:
418
+ """Start idempotent cleanup retained by the transport."""
419
+ if self._task is None:
420
+ pending = self._pending
421
+ if pending is not None:
422
+ pending.cancel()
423
+ self._task = asyncio.create_task(self._finish())
424
+ self._reserve(self._task)
425
+ self._task.add_done_callback(_consume_task_exception)
426
+ return self._task
427
+
428
+ async def _pull(self, iterator: AsyncIterator[bytes]) -> bytes:
429
+ """Run one retained response pull behind a shielded consumer wait."""
430
+ if self._pending is not None:
431
+ raise TransportError('async response already has an active pull')
432
+ pull = asyncio.create_task(_next_response_chunk(iterator, self._deadline))
433
+ self._pending = pull
434
+ try:
435
+ self._reserve(pull)
436
+ except BaseException:
437
+ pull.cancel()
438
+ with suppress(BaseException):
439
+ await pull
440
+ if self._pending is pull:
441
+ self._pending = None
442
+ raise
443
+ try:
444
+ try:
445
+ return await asyncio.shield(pull)
446
+ except asyncio.CancelledError:
447
+ pull.cancel()
448
+ self._start()
449
+ raise
450
+ finally:
451
+ if pull.done() and self._pending is pull:
452
+ self._pending = None
453
+
454
+ async def close(self) -> None:
455
+ """Wait for cleanup unless the calling task is cancelling."""
456
+ cleanup = self._start()
457
+ current = asyncio.current_task()
458
+ if current is not None and current.cancelling():
459
+ return
460
+ await asyncio.shield(cleanup)
461
+
462
+ async def stream(self, size: int) -> AsyncIterator[bytes]:
463
+ """Yield mapped, deadline-aware response chunks.
464
+
465
+ Args:
466
+ size: Maximum preferred chunk size in bytes.
467
+
468
+ Yields:
469
+ Response body chunks.
470
+
471
+ Raises:
472
+ TransportError: If reading the response stream fails.
473
+ """
474
+ try:
475
+ iterator = self._response.aiter_bytes(size)
476
+ except (httpx2.HTTPError, httpx2.StreamError) as error:
477
+ raise _map_httpx2_error(error) from error
478
+ while True:
479
+ try:
480
+ yield await self._pull(iterator)
481
+ except StopAsyncIteration:
482
+ return
483
+
484
+
485
+ class _ThreadedBody(AsyncIterator[bytes]):
486
+ """Serialize off-loop iterator pulls with idempotent cleanup."""
487
+
488
+ def __init__(self, chunks: Iterator[bytes]) -> None:
489
+ """Initialize one threaded iterator-backed request body."""
490
+ self._chunks = chunks
491
+ self._close_task: asyncio.Task[None] | None = None
492
+ self._pending: asyncio.Task[_PulledChunk] | None = None
493
+
494
+ def __aiter__(self) -> Self:
495
+ """Return this asynchronous body iterator."""
496
+ return self
497
+
498
+ async def __anext__(self) -> bytes:
499
+ """Return the next body chunk from the iterator thread."""
500
+ if self._close_task is not None:
501
+ raise StopAsyncIteration
502
+ pull = asyncio.create_task(asyncio.to_thread(_next_chunk, self._chunks))
503
+ self._pending = pull
504
+ try:
505
+ try:
506
+ pulled = await asyncio.shield(pull)
507
+ except asyncio.CancelledError:
508
+ self._start_close()
509
+ raise
510
+ except Exception as error:
511
+ raise SendError(str(error)) from error
512
+ finally:
513
+ if pull.done():
514
+ self._pending = None
515
+ chunk = pulled.chunk
516
+ if chunk is None:
517
+ await self.aclose()
518
+ raise StopAsyncIteration
519
+ if chunk and pulled.observe is not None:
520
+ pulled.observe(chunk)
521
+ return chunk
522
+
523
+ async def _finish_close(self) -> None:
524
+ """Wait for an active pull before closing its iterator."""
525
+ pending = self._pending
526
+ if pending is not None:
527
+ with suppress(BaseException):
528
+ await asyncio.shield(pending)
529
+ await _close_body(self._chunks)
530
+
531
+ def _start_close(self) -> asyncio.Task[None]:
532
+ """Start idempotent cleanup and retain its task."""
533
+ if self._close_task is None:
534
+ self._close_task = asyncio.create_task(self._finish_close())
535
+ self._close_task.add_done_callback(_consume_task_exception)
536
+ return self._close_task
537
+
538
+ async def aclose(self) -> None:
539
+ """Close the underlying iterator exactly once and off-loop."""
540
+ close_task = self._start_close()
541
+ current = asyncio.current_task()
542
+ if current is not None and current.cancelling():
543
+ return
544
+ await asyncio.shield(close_task)
545
+
546
+
547
+ def _adapt_body(
548
+ body: bytes | Iterator[bytes] | None,
549
+ ) -> bytes | _ThreadedBody | None:
550
+ """Adapt a shared request body for httpx2 without blocking the loop."""
551
+ if body is None or isinstance(body, bytes):
552
+ return body
553
+ return _ThreadedBody(body)
554
+
555
+
556
+ async def _close_adapted_body(body: bytes | _ThreadedBody | None) -> None:
557
+ """Close an adapted streaming request body when one is present."""
558
+ if isinstance(body, _ThreadedBody):
559
+ await body.aclose()
560
+
561
+
562
+ async def _close_body(chunks: Iterator[bytes]) -> None:
563
+ """Close a synchronous request body off the event loop when supported."""
564
+ close = getattr(chunks, 'close', None)
565
+ if callable(close):
566
+ try:
567
+ await asyncio.to_thread(cast('Callable[[], object]', close))
568
+ except asyncio.CancelledError:
569
+ raise
570
+ except Exception as error:
571
+ raise SendError(str(error)) from error
572
+
573
+
574
+ def _consume_task_exception(task: asyncio.Task[None]) -> None:
575
+ """Retrieve a retained cleanup task's terminal exception."""
576
+ with suppress(BaseException):
577
+ task.exception()
578
+
579
+
580
+ def _current_task() -> asyncio.Task[object]:
581
+ """Return the current task or reject use outside an event-loop task."""
582
+ task = asyncio.current_task()
583
+ if task is None:
584
+ raise TransportError('async transport operation requires a running task')
585
+ return task
586
+
587
+
588
+ def _is_tls_failure(error: BaseException) -> bool:
589
+ """Return whether an exception cause chain contains an SSL failure."""
590
+ cause = error.__cause__
591
+ while cause is not None:
592
+ if isinstance(cause, ssl.SSLError):
593
+ return True
594
+ cause = cause.__cause__
595
+ return False
596
+
597
+
598
+ def _map_httpx2_error(error: httpx2.HTTPError | httpx2.StreamError) -> TransportError:
599
+ """Map an httpx2 failure into the shared transport vocabulary."""
600
+ if isinstance(error, httpx2.ProxyError):
601
+ return ProxyError(str(error))
602
+ if isinstance(error, (httpx2.ConnectError, httpx2.ConnectTimeout)):
603
+ if _is_tls_failure(error):
604
+ return TLSError(str(error))
605
+ return ConnectError(str(error))
606
+ if isinstance(error, httpx2.PoolTimeout):
607
+ return ConnectError(str(error))
608
+ if isinstance(error, httpx2.ReadTimeout):
609
+ return ReadTimeoutError(str(error))
610
+ return SendError(str(error))
611
+
612
+
613
+ def _next_chunk(chunks: Iterator[bytes]) -> _PulledChunk:
614
+ """Pull one chunk and capture an exact-name loop observer when supported."""
615
+ pull_for_async = getattr(chunks, 'pull_for_async', None)
616
+ observe_async_chunk = getattr(chunks, 'observe_async_chunk', None)
617
+ if callable(pull_for_async) and callable(observe_async_chunk):
618
+ return _PulledChunk(
619
+ chunk=cast('Callable[[], bytes | None]', pull_for_async)(),
620
+ observe=cast('Callable[[bytes], None]', observe_async_chunk),
621
+ )
622
+ return _PulledChunk(chunk=next(chunks, None), observe=None)
623
+
624
+
625
+ async def _next_response_chunk(
626
+ iterator: AsyncIterator[bytes],
627
+ deadline: float | None,
628
+ ) -> bytes:
629
+ """Read one response chunk against the remaining total budget."""
630
+ try:
631
+ if deadline is None:
632
+ return await anext(iterator)
633
+ async with asyncio.timeout_at(deadline):
634
+ return await anext(iterator)
635
+ except TimeoutError as error:
636
+ if deadline is None:
637
+ raise SendError(str(error)) from error
638
+ raise ReadTimeoutError('total timeout exceeded during response body') from error
639
+ except (httpx2.HTTPError, httpx2.StreamError) as error:
640
+ current = asyncio.current_task()
641
+ if current is not None and current.cancelling():
642
+ raise asyncio.CancelledError from error
643
+ raise _map_httpx2_error(error) from error
644
+
645
+
646
+ def _start_adapted_body_cleanup(
647
+ body: bytes | _ThreadedBody | None,
648
+ ) -> asyncio.Task[None] | None:
649
+ """Start and return request-body cleanup without delaying an error path."""
650
+ if isinstance(body, _ThreadedBody):
651
+ return body._start_close() # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
652
+ return None
653
+
654
+
655
+ def _to_httpx2_timeout(timeout: TimeoutConfig) -> httpx2.Timeout:
656
+ """Convert shared timeout fields to httpx2 phase budgets."""
657
+ return httpx2.Timeout(
658
+ connect=timeout.connect,
659
+ read=timeout.read,
660
+ write=timeout.read,
661
+ pool=timeout.connect,
662
+ )