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.
Files changed (43) hide show
  1. vercel/queue/__init__.py +8 -0
  2. vercel/queue/__main__.py +10 -0
  3. vercel/queue/_internal/__init__.py +1 -0
  4. vercel/queue/_internal/api_async.py +219 -0
  5. vercel/queue/_internal/api_common.py +104 -0
  6. vercel/queue/_internal/api_sync.py +137 -0
  7. vercel/queue/_internal/asgi.py +145 -0
  8. vercel/queue/_internal/asynctools.py +40 -0
  9. vercel/queue/_internal/cli.py +313 -0
  10. vercel/queue/_internal/client.py +1109 -0
  11. vercel/queue/_internal/client_sync.py +432 -0
  12. vercel/queue/_internal/config.py +160 -0
  13. vercel/queue/_internal/constants.py +50 -0
  14. vercel/queue/_internal/devserver.py +205 -0
  15. vercel/queue/_internal/embedded.py +1900 -0
  16. vercel/queue/_internal/errors.py +262 -0
  17. vercel/queue/_internal/http.py +634 -0
  18. vercel/queue/_internal/lease.py +1007 -0
  19. vercel/queue/_internal/log.py +143 -0
  20. vercel/queue/_internal/messages.py +122 -0
  21. vercel/queue/_internal/multipart.py +255 -0
  22. vercel/queue/_internal/names.py +101 -0
  23. vercel/queue/_internal/polling.py +200 -0
  24. vercel/queue/_internal/push.py +246 -0
  25. vercel/queue/_internal/response.py +111 -0
  26. vercel/queue/_internal/retry.py +86 -0
  27. vercel/queue/_internal/streams.py +313 -0
  28. vercel/queue/_internal/subscribers.py +1025 -0
  29. vercel/queue/_internal/transports.py +363 -0
  30. vercel/queue/_internal/types.py +300 -0
  31. vercel/queue/_internal/typeutils.py +203 -0
  32. vercel/queue/devserver.py +24 -0
  33. vercel/queue/embedded.py +50 -0
  34. vercel/queue/py.typed +1 -0
  35. vercel/queue/sync.py +8 -0
  36. vercel/queue/testing/__init__.py +14 -0
  37. vercel/queue/testing/pytest.py +42 -0
  38. vercel/queue/testing/state.py +32 -0
  39. vercel/queue/version.py +3 -0
  40. vercel_queue-0.7.0.dist-info/METADATA +681 -0
  41. vercel_queue-0.7.0.dist-info/RECORD +43 -0
  42. vercel_queue-0.7.0.dist-info/WHEEL +4 -0
  43. vercel_queue-0.7.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,200 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import concurrent.futures
6
+ import threading
7
+ from collections.abc import Callable, Iterator
8
+
9
+ import anyio
10
+
11
+ from .errors import InvalidLimitError
12
+ from .names import SanitizedName
13
+ from .subscribers import call_subscriber, call_subscriber_sync, poll_targets_for_subscriber
14
+ from .types import Duration, StrContainer, duration_to_float_seconds
15
+
16
+ MAX_POLL_LIMIT = 10
17
+
18
+
19
+ def _request_limit(limit: int | None) -> int:
20
+ if limit is None:
21
+ return MAX_POLL_LIMIT
22
+ if limit < 1 or limit > MAX_POLL_LIMIT:
23
+ raise InvalidLimitError(limit)
24
+ return limit
25
+
26
+
27
+ def _idle_interval_seconds(interval: Duration) -> float:
28
+ seconds = duration_to_float_seconds(interval)
29
+ if seconds < 0:
30
+ raise ValueError("interval must be non-negative")
31
+ return seconds
32
+
33
+
34
+ async def _poll_topic_batch_async(
35
+ *,
36
+ poll: Callable[..., Any],
37
+ subscriber: Callable[..., Any],
38
+ topic: str,
39
+ consumer_group: SanitizedName,
40
+ request_limit: int,
41
+ lease_duration: Duration | None,
42
+ ) -> int:
43
+ handled = 0
44
+ async for delivery in poll(
45
+ topic,
46
+ consumer_group,
47
+ limit=request_limit,
48
+ lease_duration=lease_duration,
49
+ ):
50
+ async with delivery as message:
51
+ await call_subscriber(subscriber, message)
52
+ handled += 1
53
+ return handled
54
+
55
+
56
+ def _poll_topic_batch_sync(
57
+ *,
58
+ poll: Callable[..., Iterator[Any]],
59
+ subscriber: Callable[..., Any],
60
+ topic: str,
61
+ consumer_group: SanitizedName,
62
+ request_limit: int,
63
+ lease_duration: Duration | None,
64
+ stop: threading.Event,
65
+ ) -> int:
66
+ handled = 0
67
+ for delivery in poll(
68
+ topic,
69
+ consumer_group,
70
+ limit=request_limit,
71
+ lease_duration=lease_duration,
72
+ ):
73
+ if stop.is_set():
74
+ break
75
+ with delivery as message:
76
+ call_subscriber_sync(subscriber, message)
77
+ handled += 1
78
+ return handled
79
+
80
+
81
+ async def poll_and_handle_async(
82
+ *,
83
+ poll: Callable[..., Any],
84
+ subscriber: Callable[..., Any],
85
+ topics: StrContainer | None,
86
+ interval: Duration,
87
+ limit: int | None,
88
+ lease_duration: Duration | None,
89
+ ) -> None:
90
+ targets = poll_targets_for_subscriber(subscriber, topics)
91
+ request_limit = _request_limit(limit)
92
+ idle_seconds = _idle_interval_seconds(interval)
93
+
94
+ while True:
95
+ handled_any = False
96
+ for topic, consumer_group in targets:
97
+ while True:
98
+ handled_batch = await _poll_topic_batch_async(
99
+ poll=poll,
100
+ subscriber=subscriber,
101
+ topic=topic,
102
+ consumer_group=consumer_group,
103
+ request_limit=request_limit,
104
+ lease_duration=lease_duration,
105
+ )
106
+ handled_any = handled_any or handled_batch > 0
107
+ if limit is not None or handled_batch == 0:
108
+ break
109
+ if not handled_any:
110
+ await anyio.sleep(idle_seconds)
111
+
112
+
113
+ def run_poll_and_handle_sync(
114
+ *,
115
+ poll: Callable[..., Iterator[Any]],
116
+ subscriber: Callable[..., Any],
117
+ topics: StrContainer | None,
118
+ interval: Duration,
119
+ limit: int | None,
120
+ lease_duration: Duration | None,
121
+ stop: threading.Event,
122
+ ) -> None:
123
+ targets = poll_targets_for_subscriber(subscriber, topics)
124
+ request_limit = _request_limit(limit)
125
+ idle_seconds = _idle_interval_seconds(interval)
126
+
127
+ while not stop.is_set():
128
+ handled_any = False
129
+ for topic, consumer_group in targets:
130
+ if stop.is_set():
131
+ break
132
+ while not stop.is_set():
133
+ handled_batch = _poll_topic_batch_sync(
134
+ poll=poll,
135
+ subscriber=subscriber,
136
+ topic=topic,
137
+ consumer_group=consumer_group,
138
+ request_limit=request_limit,
139
+ lease_duration=lease_duration,
140
+ stop=stop,
141
+ )
142
+ handled_any = handled_any or handled_batch > 0
143
+ if limit is not None or handled_batch == 0:
144
+ break
145
+ if not handled_any:
146
+ stop.wait(idle_seconds)
147
+
148
+
149
+ class PollingFuture(concurrent.futures.Future[None]):
150
+ def __init__(self) -> None:
151
+ super().__init__()
152
+ self._stop = threading.Event()
153
+ self._cancel_requested = False
154
+
155
+ @property
156
+ def stop_event(self) -> threading.Event:
157
+ return self._stop
158
+
159
+ def cancel(self) -> bool:
160
+ if self.done():
161
+ return False
162
+ self._cancel_requested = True
163
+ self._stop.set()
164
+ return True
165
+
166
+ def cancelled(self) -> bool:
167
+ return self._cancel_requested and self.done()
168
+
169
+ def result(self, timeout: float | None = None) -> None:
170
+ result = super().result(timeout)
171
+ if self._cancel_requested:
172
+ raise concurrent.futures.CancelledError
173
+ return result
174
+
175
+ def exception(self, timeout: float | None = None) -> BaseException | None:
176
+ if self._cancel_requested and self.done():
177
+ raise concurrent.futures.CancelledError
178
+ return super().exception(timeout)
179
+
180
+
181
+ def start_sync_polling_thread(
182
+ target: Callable[[threading.Event], None],
183
+ *,
184
+ name: str,
185
+ ) -> PollingFuture:
186
+ future = PollingFuture()
187
+
188
+ def run() -> None:
189
+ try:
190
+ target(future.stop_event)
191
+ except BaseException as exc: # noqa: BLE001
192
+ if not future.done():
193
+ future.set_exception(exc)
194
+ else:
195
+ if not future.done():
196
+ future.set_result(None)
197
+
198
+ thread = threading.Thread(target=run, name=name, daemon=True)
199
+ thread.start()
200
+ return future
@@ -0,0 +1,246 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Generic, TypeGuard, TypeVar, cast
4
+
5
+ import json
6
+ from collections.abc import AsyncIterable, AsyncIterator
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timezone
9
+
10
+ from .asynctools import iter_bytes_async
11
+ from .config import validate_region
12
+ from .constants import (
13
+ CLOUD_EVENT_HEADER_TYPE,
14
+ CLOUD_EVENT_HEADER_VQS_CONSUMER_GROUP,
15
+ CLOUD_EVENT_HEADER_VQS_CREATED_AT,
16
+ CLOUD_EVENT_HEADER_VQS_DELIVERY_COUNT,
17
+ CLOUD_EVENT_HEADER_VQS_EXPIRES_AT,
18
+ CLOUD_EVENT_HEADER_VQS_MESSAGE_ID,
19
+ CLOUD_EVENT_HEADER_VQS_RECEIPT_HANDLE,
20
+ CLOUD_EVENT_HEADER_VQS_REGION,
21
+ CLOUD_EVENT_HEADER_VQS_TOPIC,
22
+ CLOUD_EVENT_HEADER_VQS_VISIBILITY_DEADLINE,
23
+ CLOUD_EVENT_TYPE_V2BETA,
24
+ CONTENT_TYPE_JSON,
25
+ CONTENT_TYPE_OCTET_STREAM,
26
+ HEADER_CONTENT_TYPE,
27
+ )
28
+ from .errors import ProtocolError
29
+ from .http import (
30
+ AsyncHttpMessage,
31
+ AsyncPushDeliveryBody,
32
+ AsyncPushDeliveryInput,
33
+ HttpResponse,
34
+ PushDeliveryBody,
35
+ PushDeliveryInput,
36
+ headers_from_raw,
37
+ response_headers,
38
+ )
39
+ from .log import debug_log
40
+ from .messages import parse_optional_datetime, parse_required_datetime
41
+ from .names import SanitizedName
42
+ from .types import (
43
+ Headers,
44
+ Message,
45
+ MessageMetadata,
46
+ RawHeaders,
47
+ ReceiptHandle,
48
+ Transport,
49
+ )
50
+
51
+ T = TypeVar("T")
52
+
53
+
54
+ @dataclass(frozen=True, kw_only=True)
55
+ class ParsedPushDelivery(Generic[T]):
56
+ metadata: MessageMetadata
57
+ message: Message[T] | None
58
+
59
+
60
+ async def parse_push_delivery(
61
+ raw_body: PushDeliveryBody,
62
+ headers: RawHeaders | None = None,
63
+ *,
64
+ transport: Transport[T] | None = None,
65
+ ) -> ParsedPushDelivery[T]:
66
+ headers = headers_from_raw(headers or {})
67
+ metadata = _parse_push_delivery_metadata(headers)
68
+ debug_log(
69
+ "push.delivery_metadata",
70
+ metadata=_debug_metadata(metadata),
71
+ )
72
+ if not metadata.receipt_handle:
73
+ return ParsedPushDelivery(metadata=metadata, message=None)
74
+ # v2beta inline callbacks send the stored payload bytes directly; there is
75
+ # no JSON CloudEvent envelope to unwrap.
76
+ message = Message(
77
+ payload=await _deserialize_push_delivery(
78
+ raw_body,
79
+ metadata.content_type or CONTENT_TYPE_OCTET_STREAM,
80
+ transport,
81
+ ),
82
+ metadata=metadata,
83
+ )
84
+ return ParsedPushDelivery(
85
+ metadata=metadata,
86
+ message=message,
87
+ )
88
+
89
+
90
+ def accept_input_sync(
91
+ raw_body_or_response: PushDeliveryBody | HttpResponse,
92
+ headers: RawHeaders | None,
93
+ ) -> tuple[PushDeliveryBody, Headers]:
94
+ return _accept_input_from_response(
95
+ raw_body_or_response,
96
+ headers,
97
+ allow_async_response=False,
98
+ )
99
+
100
+
101
+ def accept_input(
102
+ raw_body_or_response: AsyncPushDeliveryInput,
103
+ headers: RawHeaders | None,
104
+ ) -> tuple[AsyncPushDeliveryBody, Headers]:
105
+ raw_body, resolved_headers = _accept_input_from_response(
106
+ raw_body_or_response,
107
+ headers,
108
+ allow_async_response=True,
109
+ )
110
+ return cast("AsyncPushDeliveryBody", raw_body), resolved_headers
111
+
112
+
113
+ def _parse_push_delivery_metadata(headers: Headers) -> MessageMetadata:
114
+ event_type = headers.get(CLOUD_EVENT_HEADER_TYPE, "")
115
+ if event_type != CLOUD_EVENT_TYPE_V2BETA:
116
+ raise ValueError(
117
+ f"Invalid CloudEvent type: expected {CLOUD_EVENT_TYPE_V2BETA!r}, got {event_type!r}"
118
+ )
119
+
120
+ topic = headers.get(CLOUD_EVENT_HEADER_VQS_TOPIC, "")
121
+ consumer_group = headers.get(CLOUD_EVENT_HEADER_VQS_CONSUMER_GROUP, "")
122
+ message_id = headers.get(CLOUD_EVENT_HEADER_VQS_MESSAGE_ID, "")
123
+ receipt_handle = headers.get(CLOUD_EVENT_HEADER_VQS_RECEIPT_HANDLE, "")
124
+ if not consumer_group:
125
+ raise ProtocolError("push delivery metadata must include consumer_group")
126
+ if not topic or not message_id:
127
+ raise ProtocolError("missing required ce-vqs* headers")
128
+
129
+ region = validate_region(headers.get(CLOUD_EVENT_HEADER_VQS_REGION))
130
+ if not receipt_handle:
131
+ return MessageMetadata(
132
+ message_id=message_id,
133
+ delivery_count=1,
134
+ created_at=datetime.now(timezone.utc),
135
+ topic=topic,
136
+ consumer_group=SanitizedName(consumer_group),
137
+ receipt_handle=None,
138
+ content_type=headers.get(HEADER_CONTENT_TYPE),
139
+ region=region,
140
+ )
141
+
142
+ delivery_count_raw = headers.get(CLOUD_EVENT_HEADER_VQS_DELIVERY_COUNT, "1")
143
+ try:
144
+ delivery_count = int(delivery_count_raw)
145
+ except ValueError:
146
+ delivery_count = 1
147
+
148
+ content_type = headers.get(HEADER_CONTENT_TYPE, CONTENT_TYPE_OCTET_STREAM)
149
+ return MessageMetadata(
150
+ message_id=message_id,
151
+ delivery_count=delivery_count,
152
+ created_at=parse_required_datetime(
153
+ headers.get(CLOUD_EVENT_HEADER_VQS_CREATED_AT),
154
+ CLOUD_EVENT_HEADER_VQS_CREATED_AT,
155
+ ),
156
+ expires_at=parse_optional_datetime(
157
+ headers.get(CLOUD_EVENT_HEADER_VQS_EXPIRES_AT),
158
+ CLOUD_EVENT_HEADER_VQS_EXPIRES_AT,
159
+ ),
160
+ topic=topic,
161
+ consumer_group=SanitizedName(consumer_group),
162
+ receipt_handle=ReceiptHandle(receipt_handle),
163
+ content_type=content_type,
164
+ region=region,
165
+ visibility_deadline=parse_optional_datetime(
166
+ headers.get(CLOUD_EVENT_HEADER_VQS_VISIBILITY_DEADLINE),
167
+ CLOUD_EVENT_HEADER_VQS_VISIBILITY_DEADLINE,
168
+ ),
169
+ )
170
+
171
+
172
+ def _debug_metadata(metadata: MessageMetadata) -> dict[str, object]:
173
+ return {
174
+ "message_id": metadata.message_id,
175
+ "delivery_count": metadata.delivery_count,
176
+ "created_at": metadata.created_at,
177
+ "expires_at": metadata.expires_at,
178
+ "topic": metadata.topic,
179
+ "consumer_group": str(metadata.consumer_group),
180
+ "receipt_handle": metadata.receipt_handle,
181
+ "content_type": metadata.content_type,
182
+ "region": metadata.region,
183
+ "visibility_deadline": metadata.visibility_deadline,
184
+ }
185
+
186
+
187
+ def parse_push_delivery_metadata(headers: RawHeaders) -> MessageMetadata:
188
+ return _parse_push_delivery_metadata(headers_from_raw(headers))
189
+
190
+
191
+ async def _deserialize_push_delivery(
192
+ payload: PushDeliveryBody,
193
+ content_type: str,
194
+ transport: Transport[T] | None,
195
+ ) -> T:
196
+ chunks = _push_delivery_chunks(payload)
197
+ if transport is not None:
198
+ return await transport.deserialize(chunks, content_type=content_type)
199
+ buffered = await _collect_push_delivery_body(chunks)
200
+ if CONTENT_TYPE_JSON in content_type.lower():
201
+ return json.loads(buffered.decode("utf-8"))
202
+ return cast("T", buffered)
203
+
204
+
205
+ def _push_delivery_chunks(
206
+ payload: PushDeliveryBody,
207
+ ) -> AsyncIterator[bytes]:
208
+ if isinstance(payload, bytes):
209
+ return _one_chunk_async(payload)
210
+ if isinstance(payload, AsyncIterable):
211
+ return aiter(cast("AsyncIterable[bytes]", payload))
212
+ return iter_bytes_async(payload)
213
+
214
+
215
+ async def _collect_push_delivery_body(payload: AsyncIterable[bytes]) -> bytes:
216
+ chunks = bytearray()
217
+ async for chunk in payload:
218
+ chunks.extend(chunk)
219
+ return bytes(chunks)
220
+
221
+
222
+ async def _one_chunk_async(payload: bytes) -> AsyncIterator[bytes]:
223
+ yield payload
224
+
225
+
226
+ def _accept_input_from_response(
227
+ raw_body_or_response: PushDeliveryInput | AsyncPushDeliveryInput,
228
+ headers: RawHeaders | None,
229
+ *,
230
+ allow_async_response: bool,
231
+ ) -> tuple[PushDeliveryBody, Headers]:
232
+ if headers is not None:
233
+ if allow_async_response and not _is_async_push_delivery_body(raw_body_or_response):
234
+ raise TypeError("async accept() requires bytes or an async byte iterable")
235
+ return cast("PushDeliveryBody", raw_body_or_response), headers_from_raw(headers)
236
+ if allow_async_response and isinstance(raw_body_or_response, AsyncHttpMessage):
237
+ return raw_body_or_response.aiter_bytes(), response_headers(raw_body_or_response)
238
+ if allow_async_response:
239
+ raise TypeError("async accept() requires headers or an async HTTP response")
240
+ if isinstance(raw_body_or_response, HttpResponse):
241
+ return raw_body_or_response.iter_bytes(), response_headers(raw_body_or_response)
242
+ raise TypeError("accept() missing required headers")
243
+
244
+
245
+ def _is_async_push_delivery_body(value: object) -> TypeGuard[AsyncPushDeliveryBody]:
246
+ return isinstance(value, (bytes, AsyncIterable))
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+
6
+ from .errors import (
7
+ BadRequestError,
8
+ ConsumerDiscoveryError,
9
+ ConsumerRegistryNotConfiguredError,
10
+ DuplicateIdempotencyKeyError,
11
+ ForbiddenError,
12
+ InternalServerError,
13
+ MessageAlreadyProcessedError,
14
+ MessageLeaseExpiredError,
15
+ MessageLockedError,
16
+ MessageNotFoundError,
17
+ MessageNotInFlightError,
18
+ MessageUnavailableError,
19
+ ReceiptHandleMismatchError,
20
+ ServiceError,
21
+ ThrottledError,
22
+ UnauthorizedError,
23
+ )
24
+ from .http import AsyncHttpResponse, parse_retry_after, response_text
25
+ from .types import MessageID
26
+
27
+ _QUEUE_STATUS_ERRORS = {
28
+ 401: UnauthorizedError,
29
+ 403: ForbiddenError,
30
+ }
31
+
32
+
33
+ async def queue_response_error(
34
+ response: AsyncHttpResponse,
35
+ *,
36
+ message_id: MessageID | None = None,
37
+ ) -> Exception | None:
38
+ if response.status_code == 400:
39
+ return BadRequestError(await response_text(response) or "Invalid parameters")
40
+ if error_type := _QUEUE_STATUS_ERRORS.get(response.status_code):
41
+ return error_type()
42
+ if response.status_code == 404 and message_id:
43
+ return MessageNotFoundError(message_id)
44
+ if response.status_code == 409 and message_id:
45
+ return await _conflict_error(response, message_id)
46
+ if response.status_code == 410 and message_id:
47
+ return MessageAlreadyProcessedError(message_id)
48
+ if response.status_code == 429:
49
+ return ThrottledError(parse_retry_after(response))
50
+ if response.status_code >= 500:
51
+ return InternalServerError(
52
+ await response_text(response) or f"Server error: {response.status_code}"
53
+ )
54
+ if response.status_code >= 300:
55
+ return ServiceError(
56
+ response.status_code,
57
+ await response_text(response) or f"Unexpected queue response: {response.status_code}",
58
+ )
59
+ return None
60
+
61
+
62
+ async def send_response_error(response: AsyncHttpResponse) -> Exception | None:
63
+ if response.status_code == 409:
64
+ return DuplicateIdempotencyKeyError("Duplicate idempotency key detected")
65
+ if response.status_code == 502:
66
+ return ConsumerDiscoveryError(await response_text(response) or None)
67
+ if response.status_code == 503:
68
+ return ConsumerRegistryNotConfiguredError(await response_text(response) or None)
69
+ return await queue_response_error(response)
70
+
71
+
72
+ async def _conflict_error(response: AsyncHttpResponse, message_id: MessageID) -> Exception:
73
+ body = await _response_body(response)
74
+ retry_after = parse_retry_after(response)
75
+ if body.original_message_id:
76
+ return MessageUnavailableError(
77
+ message_id,
78
+ f"originalMessageId={body.original_message_id}",
79
+ original_message_id=body.original_message_id,
80
+ )
81
+ if body.error == "Message is not currently in-flight":
82
+ return MessageNotInFlightError(message_id, retry_after)
83
+ if body.error == "Message lease has expired":
84
+ return MessageLeaseExpiredError(message_id, retry_after)
85
+ if body.error == "Receipt handle does not match current lease holder":
86
+ return ReceiptHandleMismatchError(message_id, retry_after)
87
+ return MessageLockedError(message_id, retry_after, body.error)
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class _ResponseBody:
92
+ error: str | None = None
93
+ original_message_id: MessageID | None = None
94
+
95
+
96
+ async def _response_body(response: AsyncHttpResponse) -> _ResponseBody:
97
+ text = await response_text(response)
98
+ if not text:
99
+ return _ResponseBody()
100
+ try:
101
+ data = json.loads(text)
102
+ except ValueError:
103
+ return _ResponseBody(error=text)
104
+ if not isinstance(data, dict):
105
+ return _ResponseBody(error=text)
106
+ original_message_id = data.get("originalMessageId")
107
+ error = data.get("error")
108
+ return _ResponseBody(
109
+ error=str(error) if error else None,
110
+ original_message_id=MessageID(str(original_message_id)) if original_message_id else None,
111
+ )
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TypeVar
4
+
5
+ import time
6
+ from collections.abc import Awaitable, Callable
7
+
8
+ import anyio
9
+
10
+ from .errors import RetryableError, ThrottledError
11
+ from .log import debug_log
12
+
13
+ T = TypeVar("T")
14
+
15
+ _TRANSIENT_FOLLOW_UP_RETRY_SECONDS = 0.1
16
+ DIRECTIVE_FOLLOW_UP_ATTEMPTS = 3
17
+
18
+
19
+ async def retry_async_follow_up(
20
+ operation: Callable[[], Awaitable[T]],
21
+ *,
22
+ event_prefix: str = "follow_up",
23
+ sleep: Callable[[float], Awaitable[None]] | None = None,
24
+ ) -> T:
25
+ sleep = sleep or anyio.sleep
26
+ last_error: BaseException | None = None
27
+ for attempt in range(DIRECTIVE_FOLLOW_UP_ATTEMPTS):
28
+ try:
29
+ debug_log(f"{event_prefix}.retry_attempt", attempt=attempt + 1)
30
+ return await operation()
31
+ except Exception as exc:
32
+ if not _is_retryable_follow_up_error(exc):
33
+ raise
34
+ last_error = exc
35
+ if attempt == DIRECTIVE_FOLLOW_UP_ATTEMPTS - 1:
36
+ break
37
+ await sleep(_follow_up_retry_delay(exc))
38
+ if last_error is not None:
39
+ debug_log(
40
+ f"{event_prefix}.retry_exhausted",
41
+ attempts=DIRECTIVE_FOLLOW_UP_ATTEMPTS,
42
+ exception_class=last_error.__class__.__name__,
43
+ exception_message=str(last_error),
44
+ )
45
+ raise last_error
46
+ raise RuntimeError("retry loop exited without result or error")
47
+
48
+
49
+ def retry_sync_follow_up(
50
+ operation: Callable[[], T],
51
+ *,
52
+ event_prefix: str = "follow_up",
53
+ sleep: Callable[[float], None] | None = None,
54
+ ) -> T:
55
+ sleep = sleep or time.sleep
56
+ last_error: BaseException | None = None
57
+ for attempt in range(DIRECTIVE_FOLLOW_UP_ATTEMPTS):
58
+ try:
59
+ debug_log(f"{event_prefix}.retry_attempt", attempt=attempt + 1)
60
+ return operation()
61
+ except Exception as exc:
62
+ if not _is_retryable_follow_up_error(exc):
63
+ raise
64
+ last_error = exc
65
+ if attempt == DIRECTIVE_FOLLOW_UP_ATTEMPTS - 1:
66
+ break
67
+ sleep(_follow_up_retry_delay(exc))
68
+ if last_error is not None:
69
+ debug_log(
70
+ f"{event_prefix}.retry_exhausted",
71
+ attempts=DIRECTIVE_FOLLOW_UP_ATTEMPTS,
72
+ exception_class=last_error.__class__.__name__,
73
+ exception_message=str(last_error),
74
+ )
75
+ raise last_error
76
+ raise RuntimeError("retry loop exited without result or error")
77
+
78
+
79
+ def _is_retryable_follow_up_error(exc: BaseException) -> bool:
80
+ return isinstance(exc, RetryableError) and exc.retryable
81
+
82
+
83
+ def _follow_up_retry_delay(exc: BaseException) -> float:
84
+ if isinstance(exc, ThrottledError) and exc.retry_after is not None:
85
+ return max(1.0, float(exc.retry_after))
86
+ return _TRANSIENT_FOLLOW_UP_RETRY_SECONDS