vercel-queue 0.7.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.
- vercel/queue/__init__.py +8 -0
- vercel/queue/__main__.py +10 -0
- vercel/queue/_internal/__init__.py +1 -0
- vercel/queue/_internal/api_async.py +219 -0
- vercel/queue/_internal/api_common.py +104 -0
- vercel/queue/_internal/api_sync.py +137 -0
- vercel/queue/_internal/asgi.py +145 -0
- vercel/queue/_internal/asynctools.py +40 -0
- vercel/queue/_internal/cli.py +313 -0
- vercel/queue/_internal/client.py +1109 -0
- vercel/queue/_internal/client_sync.py +432 -0
- vercel/queue/_internal/config.py +160 -0
- vercel/queue/_internal/constants.py +50 -0
- vercel/queue/_internal/devserver.py +205 -0
- vercel/queue/_internal/embedded.py +1900 -0
- vercel/queue/_internal/errors.py +262 -0
- vercel/queue/_internal/http.py +634 -0
- vercel/queue/_internal/lease.py +1007 -0
- vercel/queue/_internal/log.py +143 -0
- vercel/queue/_internal/messages.py +122 -0
- vercel/queue/_internal/multipart.py +255 -0
- vercel/queue/_internal/names.py +101 -0
- vercel/queue/_internal/polling.py +200 -0
- vercel/queue/_internal/push.py +246 -0
- vercel/queue/_internal/response.py +111 -0
- vercel/queue/_internal/retry.py +86 -0
- vercel/queue/_internal/streams.py +313 -0
- vercel/queue/_internal/subscribers.py +1025 -0
- vercel/queue/_internal/transports.py +363 -0
- vercel/queue/_internal/types.py +300 -0
- vercel/queue/_internal/typeutils.py +203 -0
- vercel/queue/devserver.py +24 -0
- vercel/queue/embedded.py +50 -0
- vercel/queue/py.typed +1 -0
- vercel/queue/sync.py +8 -0
- vercel/queue/testing/__init__.py +14 -0
- vercel/queue/testing/pytest.py +42 -0
- vercel/queue/testing/state.py +32 -0
- vercel/queue/version.py +3 -0
- vercel_queue-0.7.0.dist-info/METADATA +681 -0
- vercel_queue-0.7.0.dist-info/RECORD +43 -0
- vercel_queue-0.7.0.dist-info/WHEEL +4 -0
- vercel_queue-0.7.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Generic, Protocol, TypeAlias, TypeVar, runtime_checkable
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import logging
|
|
7
|
+
import warnings
|
|
8
|
+
from collections.abc import (
|
|
9
|
+
AsyncIterable,
|
|
10
|
+
AsyncIterator,
|
|
11
|
+
Callable,
|
|
12
|
+
Iterable,
|
|
13
|
+
Iterator,
|
|
14
|
+
Mapping,
|
|
15
|
+
)
|
|
16
|
+
from contextlib import AbstractAsyncContextManager, AbstractContextManager
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from email.utils import parsedate_to_datetime
|
|
20
|
+
from threading import Lock
|
|
21
|
+
from urllib.parse import urljoin, urlsplit
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
from anyio.lowlevel import get_async_backend
|
|
25
|
+
|
|
26
|
+
from .asynctools import iter_bytes_async
|
|
27
|
+
from .config import resolve_token, resolve_token_async
|
|
28
|
+
from .constants import HEADER_RETRY_AFTER
|
|
29
|
+
from .errors import CommunicationError
|
|
30
|
+
from .log import content_type, debug_enabled, debug_log, redact_text, safe_header_names, safe_url
|
|
31
|
+
from .types import Duration, Headers, RawHeaders, RequestContent, duration_to_float_seconds
|
|
32
|
+
|
|
33
|
+
_httpx_logger = logging.getLogger("httpx")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class _HttpxFilterState:
|
|
38
|
+
lock: Lock
|
|
39
|
+
installed: bool = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_httpx_filter_state = _HttpxFilterState(lock=Lock())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BaseHttpResponse(Protocol):
|
|
46
|
+
@property
|
|
47
|
+
def headers(self) -> Mapping[str, str]: ...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@runtime_checkable
|
|
51
|
+
class AsyncHttpMessage(BaseHttpResponse, Protocol):
|
|
52
|
+
"""Minimal async HTTP message surface for queue push deliveries."""
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def headers(self) -> Mapping[str, str]: ...
|
|
56
|
+
|
|
57
|
+
def aiter_bytes(self, chunk_size: int | None = None) -> AsyncIterator[bytes]: ...
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@runtime_checkable
|
|
61
|
+
class AsyncHttpResponse(AsyncHttpMessage, Protocol):
|
|
62
|
+
"""Minimal async HTTP response surface used by the queue client."""
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def status_code(self) -> int: ...
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def text(self) -> str: ...
|
|
69
|
+
|
|
70
|
+
def json(self) -> Any: ...
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@runtime_checkable
|
|
74
|
+
class HttpResponse(BaseHttpResponse, Protocol):
|
|
75
|
+
"""Minimal sync HTTP response interface."""
|
|
76
|
+
|
|
77
|
+
def iter_bytes(self, chunk_size: int | None = None) -> Iterator[bytes]: ...
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class SyncHttpResponse(HttpResponse, Protocol):
|
|
81
|
+
"""Minimal sync HTTP response surface used by the queue client."""
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def status_code(self) -> int: ...
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def text(self) -> str: ...
|
|
88
|
+
|
|
89
|
+
def json(self) -> Any: ...
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
PushDeliveryBody: TypeAlias = bytes | Iterable[bytes] | AsyncIterable[bytes]
|
|
93
|
+
AsyncPushDeliveryBody: TypeAlias = bytes | AsyncIterable[bytes]
|
|
94
|
+
PushDeliveryInput: TypeAlias = PushDeliveryBody | HttpResponse
|
|
95
|
+
AsyncPushDeliveryInput: TypeAlias = AsyncPushDeliveryBody | AsyncHttpMessage
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class SyncHttpClient(Protocol):
|
|
99
|
+
def request(
|
|
100
|
+
self,
|
|
101
|
+
method: str,
|
|
102
|
+
url: str,
|
|
103
|
+
*,
|
|
104
|
+
headers: Mapping[str, str],
|
|
105
|
+
content: RequestContent | None = None,
|
|
106
|
+
json: object | None = None,
|
|
107
|
+
timeout: float | None = None,
|
|
108
|
+
) -> SyncHttpResponse: ...
|
|
109
|
+
|
|
110
|
+
def stream(
|
|
111
|
+
self,
|
|
112
|
+
method: str,
|
|
113
|
+
url: str,
|
|
114
|
+
*,
|
|
115
|
+
headers: Mapping[str, str],
|
|
116
|
+
timeout: float | None = None,
|
|
117
|
+
) -> AbstractContextManager[SyncHttpResponse]: ...
|
|
118
|
+
|
|
119
|
+
def close(self) -> None: ...
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class AsyncHttpClient(Protocol):
|
|
123
|
+
async def request(
|
|
124
|
+
self,
|
|
125
|
+
method: str,
|
|
126
|
+
url: str,
|
|
127
|
+
*,
|
|
128
|
+
headers: Mapping[str, str],
|
|
129
|
+
content: RequestContent | None = None,
|
|
130
|
+
json: object | None = None,
|
|
131
|
+
timeout: float | None = None, # noqa: ASYNC109
|
|
132
|
+
) -> AsyncHttpResponse: ...
|
|
133
|
+
|
|
134
|
+
def stream(
|
|
135
|
+
self,
|
|
136
|
+
method: str,
|
|
137
|
+
url: str,
|
|
138
|
+
*,
|
|
139
|
+
headers: Mapping[str, str],
|
|
140
|
+
timeout: float | None = None,
|
|
141
|
+
) -> AbstractAsyncContextManager[AsyncHttpResponse]: ...
|
|
142
|
+
|
|
143
|
+
async def aclose(self) -> None: ...
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
SyncHttpClientFactory: TypeAlias = Callable[..., SyncHttpClient]
|
|
147
|
+
AsyncHttpClientFactory: TypeAlias = Callable[..., AsyncHttpClient]
|
|
148
|
+
_ClientT = TypeVar("_ClientT", bound=SyncHttpClient | AsyncHttpClient)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(kw_only=True)
|
|
152
|
+
class _HttpClientPoolEntry(Generic[_ClientT]):
|
|
153
|
+
client: _ClientT
|
|
154
|
+
in_use: int = 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class _HttpClientPool(Generic[_ClientT]):
|
|
158
|
+
def __init__(self, *, max_size: int | None = 32) -> None:
|
|
159
|
+
if max_size is not None and max_size < 1:
|
|
160
|
+
raise ValueError("max_size must be positive or None")
|
|
161
|
+
self._max_size = max_size
|
|
162
|
+
self._clients: dict[object, _HttpClientPoolEntry[_ClientT]] = {}
|
|
163
|
+
self._lru: list[object] = []
|
|
164
|
+
self._lock = Lock()
|
|
165
|
+
|
|
166
|
+
def acquire(self, key: object, factory: Callable[[], _ClientT]) -> _ClientT:
|
|
167
|
+
with self._lock:
|
|
168
|
+
entry = self._clients.get(key)
|
|
169
|
+
if entry is None:
|
|
170
|
+
entry = _HttpClientPoolEntry(client=factory())
|
|
171
|
+
self._clients[key] = entry
|
|
172
|
+
self._lru.append(key)
|
|
173
|
+
else:
|
|
174
|
+
self._mark_recent(key)
|
|
175
|
+
entry.in_use += 1
|
|
176
|
+
return entry.client
|
|
177
|
+
|
|
178
|
+
def release(self, key: object) -> list[_ClientT]:
|
|
179
|
+
with self._lock:
|
|
180
|
+
entry = self._clients.get(key)
|
|
181
|
+
if entry is None:
|
|
182
|
+
raise RuntimeError("HTTP client pool entry was released after eviction")
|
|
183
|
+
if entry.in_use <= 0:
|
|
184
|
+
raise RuntimeError("HTTP client pool entry was released too many times")
|
|
185
|
+
entry.in_use -= 1
|
|
186
|
+
if entry.in_use == 0:
|
|
187
|
+
self._mark_recent(key)
|
|
188
|
+
return self._evict_if_needed_locked()
|
|
189
|
+
|
|
190
|
+
def reset(self) -> list[_ClientT]:
|
|
191
|
+
with self._lock:
|
|
192
|
+
clients = [entry.client for entry in self._clients.values()]
|
|
193
|
+
self._clients.clear()
|
|
194
|
+
self._lru.clear()
|
|
195
|
+
return clients
|
|
196
|
+
|
|
197
|
+
def _mark_recent(self, key: object) -> None:
|
|
198
|
+
try:
|
|
199
|
+
self._lru.remove(key)
|
|
200
|
+
except ValueError:
|
|
201
|
+
pass
|
|
202
|
+
self._lru.append(key)
|
|
203
|
+
|
|
204
|
+
def _evict_if_needed_locked(self) -> list[_ClientT]:
|
|
205
|
+
evicted: list[_ClientT] = []
|
|
206
|
+
if self._max_size is None:
|
|
207
|
+
return evicted
|
|
208
|
+
while len(self._clients) > self._max_size:
|
|
209
|
+
for key in list(self._lru):
|
|
210
|
+
entry = self._clients[key]
|
|
211
|
+
if entry.in_use > 0:
|
|
212
|
+
continue
|
|
213
|
+
self._lru.remove(key)
|
|
214
|
+
self._clients.pop(key, None)
|
|
215
|
+
evicted.append(entry.client)
|
|
216
|
+
break
|
|
217
|
+
else:
|
|
218
|
+
warnings.warn(
|
|
219
|
+
"queue HTTP client pool is over capacity because all clients are active",
|
|
220
|
+
ResourceWarning,
|
|
221
|
+
stacklevel=3,
|
|
222
|
+
)
|
|
223
|
+
break
|
|
224
|
+
return evicted
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
_sync_http_client_pool: _HttpClientPool[SyncHttpClient] = _HttpClientPool()
|
|
228
|
+
_async_http_client_pool: _HttpClientPool[AsyncHttpClient] = _HttpClientPool()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _sync_factory_key(factory: SyncHttpClientFactory | None) -> object:
|
|
232
|
+
return factory or httpx.Client
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _async_factory_key(factory: AsyncHttpClientFactory | None) -> object:
|
|
236
|
+
return factory or httpx.AsyncClient
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _async_backend_key() -> object:
|
|
240
|
+
return get_async_backend().current_token()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def reset_http_client_pools_for_tests() -> None:
|
|
244
|
+
for client in _sync_http_client_pool.reset():
|
|
245
|
+
client.close()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
async def reset_async_http_client_pool_for_tests() -> None:
|
|
249
|
+
for client in _async_http_client_pool.reset():
|
|
250
|
+
try:
|
|
251
|
+
await client.aclose()
|
|
252
|
+
except RuntimeError as exc:
|
|
253
|
+
if not _is_async_client_close_context_error(exc):
|
|
254
|
+
raise
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _is_async_client_close_context_error(exc: RuntimeError) -> bool:
|
|
258
|
+
message = str(exc)
|
|
259
|
+
return (
|
|
260
|
+
"Event loop is closed" in message
|
|
261
|
+
or "must be called from async context" in message
|
|
262
|
+
or "no running event loop" in message
|
|
263
|
+
or "handler is closed" in message
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def headers_from_raw(headers: RawHeaders) -> Headers:
|
|
268
|
+
if isinstance(headers, httpx.Headers):
|
|
269
|
+
return headers
|
|
270
|
+
return httpx.Headers(headers)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class _SyncResponse:
|
|
274
|
+
def __init__(self, response: SyncHttpResponse) -> None:
|
|
275
|
+
self._response = response
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def status_code(self) -> int:
|
|
279
|
+
return self._response.status_code
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def headers(self) -> Mapping[str, str]:
|
|
283
|
+
return self._response.headers
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def text(self) -> str:
|
|
287
|
+
return self._response.text
|
|
288
|
+
|
|
289
|
+
def aiter_bytes(self, chunk_size: int | None = None) -> AsyncIterator[bytes]:
|
|
290
|
+
return iter_bytes_async(self._response.iter_bytes(chunk_size=chunk_size))
|
|
291
|
+
|
|
292
|
+
def json(self) -> Any:
|
|
293
|
+
return self._response.json()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
async def response_text(response: AsyncHttpResponse) -> str:
|
|
297
|
+
try:
|
|
298
|
+
return response.text
|
|
299
|
+
except httpx.ResponseNotRead:
|
|
300
|
+
body = bytearray()
|
|
301
|
+
async for chunk in response.aiter_bytes():
|
|
302
|
+
body.extend(chunk)
|
|
303
|
+
return body.decode("utf-8", errors="replace")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def parse_retry_after(response: AsyncHttpResponse) -> int | None:
|
|
307
|
+
value = response.headers.get(HEADER_RETRY_AFTER)
|
|
308
|
+
if not value:
|
|
309
|
+
return None
|
|
310
|
+
try:
|
|
311
|
+
parsed = int(value)
|
|
312
|
+
except (TypeError, ValueError):
|
|
313
|
+
try:
|
|
314
|
+
retry_at = parsedate_to_datetime(value)
|
|
315
|
+
except (TypeError, ValueError):
|
|
316
|
+
return None
|
|
317
|
+
if retry_at.tzinfo is None:
|
|
318
|
+
retry_at = retry_at.replace(tzinfo=timezone.utc)
|
|
319
|
+
delay = (retry_at.astimezone(timezone.utc) - datetime.now(timezone.utc)).total_seconds()
|
|
320
|
+
return max(1, int(delay))
|
|
321
|
+
return max(1, parsed) if parsed >= 0 else None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def response_headers(response: BaseHttpResponse) -> Mapping[str, str]:
|
|
325
|
+
headers = response.headers
|
|
326
|
+
if isinstance(headers, httpx.Headers):
|
|
327
|
+
return headers
|
|
328
|
+
return headers_from_raw(headers)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class BaseQueueRuntime:
|
|
332
|
+
def __init__(self, *, timeout: Duration | None) -> None:
|
|
333
|
+
_install_httpx_request_log_filter()
|
|
334
|
+
self.timeout = None if timeout is None else duration_to_float_seconds(timeout)
|
|
335
|
+
if self.timeout is not None and self.timeout < 0:
|
|
336
|
+
raise ValueError("timeout must be non-negative")
|
|
337
|
+
self._base_url: str | None = None
|
|
338
|
+
|
|
339
|
+
def configure_base_url(self, base_url: str) -> None:
|
|
340
|
+
self._base_url = base_url.rstrip("/")
|
|
341
|
+
|
|
342
|
+
def _resolve_runtime_url(self, url: str) -> str:
|
|
343
|
+
parsed = urlsplit(url)
|
|
344
|
+
if parsed.scheme and parsed.netloc:
|
|
345
|
+
return url
|
|
346
|
+
if url.startswith("/"):
|
|
347
|
+
if self._base_url is None:
|
|
348
|
+
raise RuntimeError("queue runtime base URL is not configured")
|
|
349
|
+
return urljoin(self._base_url + "/", url.lstrip("/"))
|
|
350
|
+
raise ValueError("queue runtime HTTP URLs must be absolute or root-relative")
|
|
351
|
+
|
|
352
|
+
async def token(self, token: str | None) -> str:
|
|
353
|
+
raise NotImplementedError
|
|
354
|
+
|
|
355
|
+
async def _request(self, method: str, url: str, **kwargs: Any) -> AsyncHttpResponse:
|
|
356
|
+
raise NotImplementedError
|
|
357
|
+
|
|
358
|
+
async def request(
|
|
359
|
+
self,
|
|
360
|
+
method: str,
|
|
361
|
+
url: str,
|
|
362
|
+
*,
|
|
363
|
+
headers: Mapping[str, str],
|
|
364
|
+
content: RequestContent | None = None,
|
|
365
|
+
json: object | None = None,
|
|
366
|
+
) -> AsyncHttpResponse:
|
|
367
|
+
return await self._request(
|
|
368
|
+
method=method,
|
|
369
|
+
url=self._resolve_runtime_url(url),
|
|
370
|
+
content=content,
|
|
371
|
+
json=json,
|
|
372
|
+
headers=headers,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
async def get(self, url: str, *, headers: Mapping[str, str]) -> AsyncHttpResponse:
|
|
376
|
+
return await self.request("GET", url, headers=headers)
|
|
377
|
+
|
|
378
|
+
async def post(
|
|
379
|
+
self,
|
|
380
|
+
url: str,
|
|
381
|
+
*,
|
|
382
|
+
headers: Mapping[str, str],
|
|
383
|
+
content: RequestContent | None = None,
|
|
384
|
+
json: object | None = None,
|
|
385
|
+
) -> AsyncHttpResponse:
|
|
386
|
+
return await self.request(
|
|
387
|
+
method="POST",
|
|
388
|
+
url=url,
|
|
389
|
+
content=content,
|
|
390
|
+
json=json,
|
|
391
|
+
headers=headers,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
def stream_post(
|
|
395
|
+
self,
|
|
396
|
+
url: str,
|
|
397
|
+
*,
|
|
398
|
+
headers: Mapping[str, str],
|
|
399
|
+
) -> AbstractAsyncContextManager[AsyncHttpResponse]:
|
|
400
|
+
return self.stream("POST", url, headers=headers)
|
|
401
|
+
|
|
402
|
+
def stream(
|
|
403
|
+
self,
|
|
404
|
+
method: str,
|
|
405
|
+
url: str,
|
|
406
|
+
*,
|
|
407
|
+
headers: Mapping[str, str],
|
|
408
|
+
) -> AbstractAsyncContextManager[AsyncHttpResponse]:
|
|
409
|
+
raise NotImplementedError
|
|
410
|
+
|
|
411
|
+
async def delete(self, url: str, *, headers: Mapping[str, str]) -> AsyncHttpResponse:
|
|
412
|
+
return await self.request(method="DELETE", url=url, headers=headers)
|
|
413
|
+
|
|
414
|
+
async def patch(
|
|
415
|
+
self,
|
|
416
|
+
url: str,
|
|
417
|
+
*,
|
|
418
|
+
headers: Mapping[str, str],
|
|
419
|
+
json: object,
|
|
420
|
+
) -> AsyncHttpResponse:
|
|
421
|
+
return await self.request(method="PATCH", url=url, json=json, headers=headers)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
class SyncQueueRuntime(BaseQueueRuntime):
|
|
425
|
+
def __init__(
|
|
426
|
+
self,
|
|
427
|
+
*,
|
|
428
|
+
timeout: Duration | None,
|
|
429
|
+
client_factory: SyncHttpClientFactory | None = None,
|
|
430
|
+
) -> None:
|
|
431
|
+
super().__init__(timeout=timeout)
|
|
432
|
+
self._client_factory = client_factory
|
|
433
|
+
self._client_key = _sync_factory_key(client_factory)
|
|
434
|
+
|
|
435
|
+
def _acquire_client(self) -> SyncHttpClient:
|
|
436
|
+
factory = self._client_factory or httpx.Client
|
|
437
|
+
return _sync_http_client_pool.acquire(
|
|
438
|
+
self._client_key,
|
|
439
|
+
factory,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
def _release_client(self) -> None:
|
|
443
|
+
for client in _sync_http_client_pool.release(self._client_key):
|
|
444
|
+
client.close()
|
|
445
|
+
|
|
446
|
+
async def token(self, token: str | None) -> str:
|
|
447
|
+
return resolve_token(token)
|
|
448
|
+
|
|
449
|
+
async def _request(self, method: str, url: str, **kwargs: Any) -> AsyncHttpResponse:
|
|
450
|
+
_log_http_request(method, url, self.timeout, "request", kwargs.get("headers", {}))
|
|
451
|
+
kwargs["timeout"] = self.timeout
|
|
452
|
+
client = self._acquire_client()
|
|
453
|
+
try:
|
|
454
|
+
response = client.request(method, url, **kwargs)
|
|
455
|
+
except httpx.TransportError as exc:
|
|
456
|
+
_log_http_error(method, url, exc)
|
|
457
|
+
raise CommunicationError(str(exc)) from exc
|
|
458
|
+
finally:
|
|
459
|
+
self._release_client()
|
|
460
|
+
_log_http_response(method, url, response.status_code, response.headers)
|
|
461
|
+
return _SyncResponse(response)
|
|
462
|
+
|
|
463
|
+
@contextlib.asynccontextmanager
|
|
464
|
+
async def stream(
|
|
465
|
+
self,
|
|
466
|
+
method: str,
|
|
467
|
+
url: str,
|
|
468
|
+
*,
|
|
469
|
+
headers: Mapping[str, str],
|
|
470
|
+
) -> AsyncIterator[AsyncHttpResponse]:
|
|
471
|
+
resolved_url = self._resolve_runtime_url(url)
|
|
472
|
+
_log_http_request(method, resolved_url, self.timeout, "stream", headers)
|
|
473
|
+
client = self._acquire_client()
|
|
474
|
+
try:
|
|
475
|
+
with client.stream(
|
|
476
|
+
method,
|
|
477
|
+
resolved_url,
|
|
478
|
+
headers=headers,
|
|
479
|
+
timeout=self.timeout,
|
|
480
|
+
) as response:
|
|
481
|
+
_log_http_response(method, resolved_url, response.status_code, response.headers)
|
|
482
|
+
yield _SyncResponse(response)
|
|
483
|
+
except httpx.TransportError as exc:
|
|
484
|
+
_log_http_error(method, resolved_url, exc)
|
|
485
|
+
raise CommunicationError(str(exc)) from exc
|
|
486
|
+
finally:
|
|
487
|
+
self._release_client()
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
class AsyncQueueRuntime(BaseQueueRuntime):
|
|
491
|
+
def __init__(
|
|
492
|
+
self,
|
|
493
|
+
*,
|
|
494
|
+
timeout: Duration | None,
|
|
495
|
+
client_factory: AsyncHttpClientFactory | None = None,
|
|
496
|
+
) -> None:
|
|
497
|
+
super().__init__(timeout=timeout)
|
|
498
|
+
self._client_factory = client_factory
|
|
499
|
+
|
|
500
|
+
def _client_key(self) -> tuple[object, object]:
|
|
501
|
+
return (_async_backend_key(), _async_factory_key(self._client_factory))
|
|
502
|
+
|
|
503
|
+
def _acquire_client(self) -> tuple[object, AsyncHttpClient]:
|
|
504
|
+
key = self._client_key()
|
|
505
|
+
factory = self._client_factory or httpx.AsyncClient
|
|
506
|
+
return key, _async_http_client_pool.acquire(
|
|
507
|
+
key,
|
|
508
|
+
factory,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
async def _release_client(self, key: object) -> None:
|
|
512
|
+
for client in _async_http_client_pool.release(key):
|
|
513
|
+
try:
|
|
514
|
+
await client.aclose()
|
|
515
|
+
except RuntimeError as exc:
|
|
516
|
+
if not _is_async_client_close_context_error(exc):
|
|
517
|
+
raise
|
|
518
|
+
|
|
519
|
+
async def token(self, token: str | None) -> str:
|
|
520
|
+
return await resolve_token_async(token)
|
|
521
|
+
|
|
522
|
+
async def _request(self, method: str, url: str, **kwargs: Any) -> AsyncHttpResponse:
|
|
523
|
+
_log_http_request(method, url, self.timeout, "request", kwargs.get("headers", {}))
|
|
524
|
+
kwargs["timeout"] = self.timeout
|
|
525
|
+
key, client = self._acquire_client()
|
|
526
|
+
try:
|
|
527
|
+
response = await client.request(method, url, **kwargs)
|
|
528
|
+
except httpx.TransportError as exc:
|
|
529
|
+
_log_http_error(method, url, exc)
|
|
530
|
+
raise CommunicationError(str(exc)) from exc
|
|
531
|
+
finally:
|
|
532
|
+
await self._release_client(key)
|
|
533
|
+
_log_http_response(method, url, response.status_code, response.headers)
|
|
534
|
+
return response
|
|
535
|
+
|
|
536
|
+
@contextlib.asynccontextmanager
|
|
537
|
+
async def stream(
|
|
538
|
+
self,
|
|
539
|
+
method: str,
|
|
540
|
+
url: str,
|
|
541
|
+
*,
|
|
542
|
+
headers: Mapping[str, str],
|
|
543
|
+
) -> AsyncIterator[AsyncHttpResponse]:
|
|
544
|
+
resolved_url = self._resolve_runtime_url(url)
|
|
545
|
+
_log_http_request(method, resolved_url, self.timeout, "stream", headers)
|
|
546
|
+
key, client = self._acquire_client()
|
|
547
|
+
try:
|
|
548
|
+
async with client.stream(
|
|
549
|
+
method,
|
|
550
|
+
resolved_url,
|
|
551
|
+
headers=headers,
|
|
552
|
+
timeout=self.timeout,
|
|
553
|
+
) as response:
|
|
554
|
+
_log_http_response(method, resolved_url, response.status_code, response.headers)
|
|
555
|
+
yield response
|
|
556
|
+
except httpx.TransportError as exc:
|
|
557
|
+
_log_http_error(method, resolved_url, exc)
|
|
558
|
+
raise CommunicationError(str(exc)) from exc
|
|
559
|
+
finally:
|
|
560
|
+
await self._release_client(key)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _log_http_request(
|
|
564
|
+
method: str,
|
|
565
|
+
url: str,
|
|
566
|
+
timeout: float | None,
|
|
567
|
+
kind: str,
|
|
568
|
+
headers: object,
|
|
569
|
+
) -> None:
|
|
570
|
+
if not debug_enabled():
|
|
571
|
+
return
|
|
572
|
+
debug_log(
|
|
573
|
+
"http.request",
|
|
574
|
+
method=method,
|
|
575
|
+
url=safe_url(url),
|
|
576
|
+
timeout=timeout,
|
|
577
|
+
kind=kind,
|
|
578
|
+
headers=safe_header_names(headers) if isinstance(headers, Mapping) else [],
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _log_http_response(
|
|
583
|
+
method: str,
|
|
584
|
+
url: str,
|
|
585
|
+
status_code: int,
|
|
586
|
+
headers: Mapping[str, str],
|
|
587
|
+
) -> None:
|
|
588
|
+
if not debug_enabled():
|
|
589
|
+
return
|
|
590
|
+
debug_log(
|
|
591
|
+
"http.response",
|
|
592
|
+
method=method,
|
|
593
|
+
url=safe_url(url),
|
|
594
|
+
status_code=status_code,
|
|
595
|
+
content_type=content_type(headers),
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _log_http_error(method: str, url: str, exc: BaseException) -> None:
|
|
600
|
+
if not debug_enabled():
|
|
601
|
+
return
|
|
602
|
+
debug_log(
|
|
603
|
+
"http.error",
|
|
604
|
+
method=method,
|
|
605
|
+
url=safe_url(url),
|
|
606
|
+
exception_class=exc.__class__.__name__,
|
|
607
|
+
exception_message=redact_text(str(exc)),
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
class _QueueHttpxRequestLogFilter(logging.Filter):
|
|
612
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
613
|
+
args = record.args
|
|
614
|
+
if not isinstance(args, tuple):
|
|
615
|
+
return True
|
|
616
|
+
return not any(_is_queue_url(str(arg)) for arg in args)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _is_queue_url(value: str) -> bool:
|
|
620
|
+
path_parts = [part for part in urlsplit(value).path.split("/") if part]
|
|
621
|
+
for index in range(len(path_parts) - 3):
|
|
622
|
+
if path_parts[index : index + 3] == ["api", "v3", "topic"]:
|
|
623
|
+
return True
|
|
624
|
+
return False
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _install_httpx_request_log_filter() -> None:
|
|
628
|
+
if _httpx_filter_state.installed:
|
|
629
|
+
return
|
|
630
|
+
with _httpx_filter_state.lock:
|
|
631
|
+
if _httpx_filter_state.installed:
|
|
632
|
+
return
|
|
633
|
+
_httpx_logger.addFilter(_QueueHttpxRequestLogFilter())
|
|
634
|
+
_httpx_filter_state.installed = True
|