workhold-consumer 1.0.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.
- workhold_consumer/__init__.py +95 -0
- workhold_consumer/async_client.py +477 -0
- workhold_consumer/async_supervisor.py +462 -0
- workhold_consumer/client.py +506 -0
- workhold_consumer/supervisor.py +434 -0
- workhold_consumer-1.0.0.dist-info/METADATA +10 -0
- workhold_consumer-1.0.0.dist-info/RECORD +8 -0
- workhold_consumer-1.0.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Public consumer client distribution.
|
|
2
|
+
|
|
3
|
+
Thin role surface over shared ``_workhold_client_core`` primitives.
|
|
4
|
+
Operation ownership: getCapabilities, claimTasks, heartbeatClaim, completeClaim,
|
|
5
|
+
failClaim, acknowledgeClaimCancellation
|
|
6
|
+
(see ``packages/client-operation-ownership.json``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from _workhold_client_core.capabilities import Capabilities
|
|
12
|
+
from _workhold_client_core.codecs import (
|
|
13
|
+
PayloadDecodeError,
|
|
14
|
+
PayloadDecoder,
|
|
15
|
+
PayloadEncoder,
|
|
16
|
+
TypedClaimView,
|
|
17
|
+
TypedTaskView,
|
|
18
|
+
decode_payload,
|
|
19
|
+
encode_payload,
|
|
20
|
+
)
|
|
21
|
+
from _workhold_client_core.errors import (
|
|
22
|
+
AuthenticationError,
|
|
23
|
+
ErrorCode,
|
|
24
|
+
LeaseLostError,
|
|
25
|
+
MalformedResponseError,
|
|
26
|
+
ProtocolError,
|
|
27
|
+
QueueClientError,
|
|
28
|
+
RequestCancelledError,
|
|
29
|
+
TerminalConflictError,
|
|
30
|
+
TimeoutError,
|
|
31
|
+
TransportError,
|
|
32
|
+
)
|
|
33
|
+
from _workhold_client_core.models import (
|
|
34
|
+
AckCancelResult,
|
|
35
|
+
ClaimSummary,
|
|
36
|
+
CompleteResult,
|
|
37
|
+
FailResult,
|
|
38
|
+
HeartbeatResult,
|
|
39
|
+
ProtocolErrorBody,
|
|
40
|
+
Task,
|
|
41
|
+
TaskState,
|
|
42
|
+
)
|
|
43
|
+
from _workhold_client_core.transport import HttpJsonTransport, encode_path_segment
|
|
44
|
+
from workhold_consumer.client import Claim, ConsumerClient
|
|
45
|
+
from workhold_consumer.supervisor import (
|
|
46
|
+
DEFAULT_WAIT_SECONDS,
|
|
47
|
+
HEARTBEAT_JITTER_RATIO,
|
|
48
|
+
CancellationToken,
|
|
49
|
+
ConsumerSupervisor,
|
|
50
|
+
HandlerErrorEvent,
|
|
51
|
+
LeaseLostEvent,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
__version__ = "1.0.0"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"AckCancelResult",
|
|
59
|
+
"AuthenticationError",
|
|
60
|
+
"CancellationToken",
|
|
61
|
+
"Capabilities",
|
|
62
|
+
"Claim",
|
|
63
|
+
"ClaimSummary",
|
|
64
|
+
"CompleteResult",
|
|
65
|
+
"ConsumerClient",
|
|
66
|
+
"ConsumerSupervisor",
|
|
67
|
+
"DEFAULT_WAIT_SECONDS",
|
|
68
|
+
"ErrorCode",
|
|
69
|
+
"FailResult",
|
|
70
|
+
"HandlerErrorEvent",
|
|
71
|
+
"HEARTBEAT_JITTER_RATIO",
|
|
72
|
+
"HeartbeatResult",
|
|
73
|
+
"HttpJsonTransport",
|
|
74
|
+
"LeaseLostError",
|
|
75
|
+
"LeaseLostEvent",
|
|
76
|
+
"MalformedResponseError",
|
|
77
|
+
"PayloadDecodeError",
|
|
78
|
+
"PayloadDecoder",
|
|
79
|
+
"PayloadEncoder",
|
|
80
|
+
"ProtocolError",
|
|
81
|
+
"ProtocolErrorBody",
|
|
82
|
+
"QueueClientError",
|
|
83
|
+
"RequestCancelledError",
|
|
84
|
+
"Task",
|
|
85
|
+
"TaskState",
|
|
86
|
+
"TerminalConflictError",
|
|
87
|
+
"TimeoutError",
|
|
88
|
+
"TransportError",
|
|
89
|
+
"TypedClaimView",
|
|
90
|
+
"TypedTaskView",
|
|
91
|
+
"decode_payload",
|
|
92
|
+
"encode_path_segment",
|
|
93
|
+
"encode_payload",
|
|
94
|
+
"__version__",
|
|
95
|
+
]
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""Async consumer SDK adapter over the OpenAPI worker-authorized lease surface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from _workhold_client_core.async_transport import AsyncTransport
|
|
10
|
+
from _workhold_client_core.capabilities import Capabilities
|
|
11
|
+
from _workhold_client_core.capability_guard import (
|
|
12
|
+
require_delivery_events,
|
|
13
|
+
require_long_polling,
|
|
14
|
+
)
|
|
15
|
+
from _workhold_client_core.codecs import (
|
|
16
|
+
PayloadDecoder,
|
|
17
|
+
PayloadEncoder,
|
|
18
|
+
)
|
|
19
|
+
from _workhold_client_core.errors import (
|
|
20
|
+
LeaseLostError,
|
|
21
|
+
MalformedResponseError,
|
|
22
|
+
ProtocolError,
|
|
23
|
+
TerminalConflictError,
|
|
24
|
+
TimeoutError as ClientTimeoutError,
|
|
25
|
+
)
|
|
26
|
+
from _workhold_client_core.instrumentation import observe_request
|
|
27
|
+
from _workhold_client_core.models import (
|
|
28
|
+
AckCancelResult,
|
|
29
|
+
CompleteResult,
|
|
30
|
+
FailResult,
|
|
31
|
+
HeartbeatResult,
|
|
32
|
+
Task,
|
|
33
|
+
)
|
|
34
|
+
from _workhold_client_core.transport import encode_path_segment
|
|
35
|
+
|
|
36
|
+
from workhold_consumer.client import (
|
|
37
|
+
_CLAIM_TOKEN_HEADER,
|
|
38
|
+
_encode_spawn_items,
|
|
39
|
+
_fingerprint,
|
|
40
|
+
_require_bool,
|
|
41
|
+
_require_int,
|
|
42
|
+
_require_mapping,
|
|
43
|
+
_require_str,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AsyncConsumerClient:
|
|
48
|
+
"""Async consumer operations mirroring :class:`ConsumerClient`.
|
|
49
|
+
|
|
50
|
+
Positive ``wait_seconds`` uses the same capability gate and per-call
|
|
51
|
+
``wait+5`` / ``wait+10`` budgets as sync. ``asyncio.CancelledError``
|
|
52
|
+
propagates unchanged and is never wrapped as timeout/protocol error.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
transport: AsyncTransport,
|
|
58
|
+
*,
|
|
59
|
+
bearer_token: str,
|
|
60
|
+
owns_transport: bool = False,
|
|
61
|
+
) -> None:
|
|
62
|
+
if not bearer_token or not bearer_token.strip():
|
|
63
|
+
raise ValueError("bearer_token is required")
|
|
64
|
+
self._transport = transport
|
|
65
|
+
self._bearer_token = bearer_token
|
|
66
|
+
self._owns_transport = owns_transport
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_url(
|
|
70
|
+
cls,
|
|
71
|
+
base_url: str,
|
|
72
|
+
*,
|
|
73
|
+
bearer_token: str,
|
|
74
|
+
timeout_s: float = 30.0,
|
|
75
|
+
) -> AsyncConsumerClient:
|
|
76
|
+
from _workhold_client_core.async_transport import HttpxAsyncTransport
|
|
77
|
+
|
|
78
|
+
transport = HttpxAsyncTransport(base_url, timeout_s=timeout_s)
|
|
79
|
+
return cls(transport, bearer_token=bearer_token, owns_transport=True)
|
|
80
|
+
|
|
81
|
+
async def __aenter__(self) -> AsyncConsumerClient:
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
async def __aexit__(self, *_exc: object) -> None:
|
|
85
|
+
if self._owns_transport:
|
|
86
|
+
await self._transport.aclose()
|
|
87
|
+
|
|
88
|
+
def __repr__(self) -> str:
|
|
89
|
+
return "AsyncConsumerClient(transport=..., bearer_token=<redacted>)"
|
|
90
|
+
|
|
91
|
+
def _auth_headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
|
|
92
|
+
headers = {"Authorization": f"Bearer {self._bearer_token}"}
|
|
93
|
+
if extra:
|
|
94
|
+
headers.update(extra)
|
|
95
|
+
return headers
|
|
96
|
+
|
|
97
|
+
async def get_capabilities(self) -> Capabilities:
|
|
98
|
+
response = await self._transport.request(
|
|
99
|
+
"GET",
|
|
100
|
+
"/v1/capabilities",
|
|
101
|
+
headers=self._auth_headers(),
|
|
102
|
+
json_body=None,
|
|
103
|
+
)
|
|
104
|
+
return self._parse(Capabilities.parse, response.status_code, response.body)
|
|
105
|
+
|
|
106
|
+
async def claim(
|
|
107
|
+
self,
|
|
108
|
+
*,
|
|
109
|
+
queues: Sequence[str],
|
|
110
|
+
worker_id: str,
|
|
111
|
+
lease_seconds: int,
|
|
112
|
+
max_tasks: int = 1,
|
|
113
|
+
wait_seconds: int = 0,
|
|
114
|
+
capabilities: Capabilities | None = None,
|
|
115
|
+
cancellation: object | None = None,
|
|
116
|
+
payload_decoder: PayloadDecoder[Any] | None = None,
|
|
117
|
+
) -> list[AsyncClaim]:
|
|
118
|
+
"""POST ``/v1/claims`` (OpenAPI ``claimTasks``).
|
|
119
|
+
|
|
120
|
+
MVP locks ``max_tasks`` to exact ``1`` locally before any capability
|
|
121
|
+
fetch or transport call. Positive ``wait_seconds`` still requires
|
|
122
|
+
authenticated live long-polling capabilities (fail-closed). Zero wait
|
|
123
|
+
bypasses the long-poll feature guard. Empty ``tasks: []`` is success
|
|
124
|
+
(including long-poll expiry). ``asyncio.CancelledError`` propagates
|
|
125
|
+
unchanged.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
if isinstance(max_tasks, bool) or type(max_tasks) is not int:
|
|
129
|
+
raise ValueError("max_tasks must be an integer")
|
|
130
|
+
if max_tasks != 1:
|
|
131
|
+
raise ValueError("MVP claim requires max_tasks=1")
|
|
132
|
+
if isinstance(wait_seconds, bool) or type(wait_seconds) is not int:
|
|
133
|
+
raise ValueError("wait_seconds must be an integer")
|
|
134
|
+
if wait_seconds < 0:
|
|
135
|
+
raise ValueError("wait_seconds must be >= 0")
|
|
136
|
+
if not queues:
|
|
137
|
+
raise ValueError("queues must be non-empty")
|
|
138
|
+
if not worker_id:
|
|
139
|
+
raise ValueError("worker_id is required")
|
|
140
|
+
|
|
141
|
+
if wait_seconds > 0:
|
|
142
|
+
caps = (
|
|
143
|
+
capabilities
|
|
144
|
+
if capabilities is not None
|
|
145
|
+
else await self.get_capabilities()
|
|
146
|
+
)
|
|
147
|
+
require_long_polling(caps, wait_seconds)
|
|
148
|
+
|
|
149
|
+
read_timeout_s: float | None = None
|
|
150
|
+
total_timeout_s: float | None = None
|
|
151
|
+
if wait_seconds > 0:
|
|
152
|
+
config = getattr(self._transport, "config", None)
|
|
153
|
+
if config is None:
|
|
154
|
+
raise ValueError(
|
|
155
|
+
"positive wait_seconds requires transport.config for long-poll budgets"
|
|
156
|
+
)
|
|
157
|
+
read_timeout_s, total_timeout_s = config.ensure_long_poll_budgets(wait_seconds)
|
|
158
|
+
|
|
159
|
+
body = {
|
|
160
|
+
"queues": list(queues),
|
|
161
|
+
"max_tasks": 1,
|
|
162
|
+
"lease_seconds": lease_seconds,
|
|
163
|
+
"wait_seconds": wait_seconds,
|
|
164
|
+
"worker_id": worker_id,
|
|
165
|
+
}
|
|
166
|
+
try:
|
|
167
|
+
response = await self._transport.request(
|
|
168
|
+
"POST",
|
|
169
|
+
"/v1/claims",
|
|
170
|
+
headers=self._auth_headers(),
|
|
171
|
+
json_body=body,
|
|
172
|
+
read_timeout_s=read_timeout_s,
|
|
173
|
+
total_timeout_s=total_timeout_s,
|
|
174
|
+
cancellation=cancellation,
|
|
175
|
+
)
|
|
176
|
+
except asyncio.CancelledError:
|
|
177
|
+
observe_request(None, operation="claimTasks", result="cancelled", duration_s=0.0)
|
|
178
|
+
raise
|
|
179
|
+
except ClientTimeoutError:
|
|
180
|
+
observe_request(None, operation="claimTasks", result="timeout", duration_s=0.0)
|
|
181
|
+
raise
|
|
182
|
+
|
|
183
|
+
payload = _require_mapping(response.status_code, response.body)
|
|
184
|
+
try:
|
|
185
|
+
tasks_raw = payload["tasks"]
|
|
186
|
+
server_time = _require_str(payload["server_time"], "server_time")
|
|
187
|
+
recommended = _require_int(
|
|
188
|
+
payload["recommended_heartbeat_seconds"],
|
|
189
|
+
"recommended_heartbeat_seconds",
|
|
190
|
+
)
|
|
191
|
+
if "queue_states" not in payload:
|
|
192
|
+
raise ValueError("claim response missing queue_states")
|
|
193
|
+
if not isinstance(payload["queue_states"], Mapping):
|
|
194
|
+
raise ValueError("claim response queue_states must be an object")
|
|
195
|
+
except KeyError as exc:
|
|
196
|
+
raise MalformedResponseError(
|
|
197
|
+
status_code=response.status_code,
|
|
198
|
+
reason=f"claim response missing field: {exc.args[0]}",
|
|
199
|
+
) from exc
|
|
200
|
+
except ValueError as exc:
|
|
201
|
+
raise MalformedResponseError(
|
|
202
|
+
status_code=response.status_code,
|
|
203
|
+
reason=str(exc),
|
|
204
|
+
) from exc
|
|
205
|
+
|
|
206
|
+
if not isinstance(tasks_raw, list):
|
|
207
|
+
raise MalformedResponseError(
|
|
208
|
+
status_code=response.status_code,
|
|
209
|
+
reason="claim response tasks must be an array",
|
|
210
|
+
)
|
|
211
|
+
if not tasks_raw:
|
|
212
|
+
observe_request(None, operation="claimTasks", result="empty", duration_s=0.0)
|
|
213
|
+
return []
|
|
214
|
+
if len(tasks_raw) > max_tasks:
|
|
215
|
+
raise MalformedResponseError(
|
|
216
|
+
status_code=response.status_code,
|
|
217
|
+
reason=(
|
|
218
|
+
f"claim response returned {len(tasks_raw)} tasks, "
|
|
219
|
+
f"max allowed is {max_tasks}"
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
claims: list[AsyncClaim] = []
|
|
224
|
+
for item in tasks_raw:
|
|
225
|
+
try:
|
|
226
|
+
claims.append(
|
|
227
|
+
AsyncClaim.from_claimed_task(
|
|
228
|
+
self,
|
|
229
|
+
item,
|
|
230
|
+
server_time=server_time,
|
|
231
|
+
recommended_heartbeat_seconds=recommended,
|
|
232
|
+
payload_decoder=payload_decoder,
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
except ValueError as exc:
|
|
236
|
+
raise MalformedResponseError(
|
|
237
|
+
status_code=response.status_code,
|
|
238
|
+
reason=str(exc),
|
|
239
|
+
) from exc
|
|
240
|
+
observe_request(None, operation="claimTasks", result="success", duration_s=0.0)
|
|
241
|
+
return claims
|
|
242
|
+
|
|
243
|
+
@staticmethod
|
|
244
|
+
def _parse(parser: Any, status_code: int, body: object | None) -> Any:
|
|
245
|
+
if body is None:
|
|
246
|
+
raise MalformedResponseError(
|
|
247
|
+
status_code=status_code,
|
|
248
|
+
reason="empty success response body",
|
|
249
|
+
)
|
|
250
|
+
try:
|
|
251
|
+
return parser(body)
|
|
252
|
+
except ValueError as exc:
|
|
253
|
+
raise MalformedResponseError(
|
|
254
|
+
status_code=status_code,
|
|
255
|
+
reason=str(exc),
|
|
256
|
+
) from exc
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class AsyncClaim:
|
|
260
|
+
"""Local fenced-lease handle for one claimed task (async)."""
|
|
261
|
+
|
|
262
|
+
def __init__(
|
|
263
|
+
self,
|
|
264
|
+
client: AsyncConsumerClient,
|
|
265
|
+
*,
|
|
266
|
+
task: Task,
|
|
267
|
+
claim_id: str,
|
|
268
|
+
generation: int,
|
|
269
|
+
claimed_at: str,
|
|
270
|
+
lease_expires_at: str,
|
|
271
|
+
worker_id: str,
|
|
272
|
+
cancel_requested: bool,
|
|
273
|
+
claim_token: str,
|
|
274
|
+
server_time: str,
|
|
275
|
+
recommended_heartbeat_seconds: int,
|
|
276
|
+
payload_decoder: PayloadDecoder[Any] | None = None,
|
|
277
|
+
) -> None:
|
|
278
|
+
self._client = client
|
|
279
|
+
self.task = task
|
|
280
|
+
self.claim_id = claim_id
|
|
281
|
+
self.generation = generation
|
|
282
|
+
self.claimed_at = claimed_at
|
|
283
|
+
self.lease_expires_at = lease_expires_at
|
|
284
|
+
self.worker_id = worker_id
|
|
285
|
+
self.cancel_requested = cancel_requested
|
|
286
|
+
self.server_time = server_time
|
|
287
|
+
self.recommended_heartbeat_seconds = recommended_heartbeat_seconds
|
|
288
|
+
self.payload_decoder = payload_decoder
|
|
289
|
+
self._claim_token = claim_token
|
|
290
|
+
self._lease_lost = False
|
|
291
|
+
self._terminal: tuple[str, str] | None = None
|
|
292
|
+
|
|
293
|
+
@classmethod
|
|
294
|
+
def from_claimed_task(
|
|
295
|
+
cls,
|
|
296
|
+
client: AsyncConsumerClient,
|
|
297
|
+
raw: object,
|
|
298
|
+
*,
|
|
299
|
+
server_time: str,
|
|
300
|
+
recommended_heartbeat_seconds: int,
|
|
301
|
+
payload_decoder: PayloadDecoder[Any] | None = None,
|
|
302
|
+
) -> AsyncClaim:
|
|
303
|
+
if not isinstance(raw, Mapping):
|
|
304
|
+
raise ValueError("claimed task must be an object")
|
|
305
|
+
if "task" not in raw or "claim" not in raw:
|
|
306
|
+
raise ValueError("claimed task requires task and claim")
|
|
307
|
+
claim_raw = raw["claim"]
|
|
308
|
+
if not isinstance(claim_raw, Mapping):
|
|
309
|
+
raise ValueError("claim grant must be an object")
|
|
310
|
+
required = (
|
|
311
|
+
"claim_id",
|
|
312
|
+
"generation",
|
|
313
|
+
"claimed_at",
|
|
314
|
+
"lease_expires_at",
|
|
315
|
+
"worker_id",
|
|
316
|
+
"cancel_requested",
|
|
317
|
+
"claim_token",
|
|
318
|
+
)
|
|
319
|
+
missing = [key for key in required if key not in claim_raw]
|
|
320
|
+
if missing:
|
|
321
|
+
raise ValueError(f"claim grant missing fields: {missing}")
|
|
322
|
+
return cls(
|
|
323
|
+
client,
|
|
324
|
+
task=Task.parse(raw["task"]),
|
|
325
|
+
claim_id=_require_str(claim_raw["claim_id"], "claim.claim_id"),
|
|
326
|
+
generation=_require_int(claim_raw["generation"], "claim.generation"),
|
|
327
|
+
claimed_at=_require_str(claim_raw["claimed_at"], "claim.claimed_at"),
|
|
328
|
+
lease_expires_at=_require_str(
|
|
329
|
+
claim_raw["lease_expires_at"], "claim.lease_expires_at"
|
|
330
|
+
),
|
|
331
|
+
worker_id=_require_str(claim_raw["worker_id"], "claim.worker_id"),
|
|
332
|
+
cancel_requested=_require_bool(
|
|
333
|
+
claim_raw["cancel_requested"], "claim.cancel_requested"
|
|
334
|
+
),
|
|
335
|
+
claim_token=_require_str(claim_raw["claim_token"], "claim.claim_token"),
|
|
336
|
+
server_time=server_time,
|
|
337
|
+
recommended_heartbeat_seconds=recommended_heartbeat_seconds,
|
|
338
|
+
payload_decoder=payload_decoder,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
@property
|
|
342
|
+
def lease_lost(self) -> bool:
|
|
343
|
+
return self._lease_lost
|
|
344
|
+
|
|
345
|
+
@property
|
|
346
|
+
def is_terminal(self) -> bool:
|
|
347
|
+
return self._terminal is not None
|
|
348
|
+
|
|
349
|
+
def __repr__(self) -> str:
|
|
350
|
+
return (
|
|
351
|
+
f"AsyncClaim(claim_id={self.claim_id!r}, generation={self.generation!r}, "
|
|
352
|
+
f"lease_lost={self._lease_lost!r})"
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def __str__(self) -> str:
|
|
356
|
+
return self.__repr__()
|
|
357
|
+
|
|
358
|
+
async def heartbeat(self, *, lease_seconds: int) -> HeartbeatResult:
|
|
359
|
+
self._ensure_usable()
|
|
360
|
+
body = {"generation": self.generation, "lease_seconds": lease_seconds}
|
|
361
|
+
result = await self._request_lease("heartbeat", body, HeartbeatResult.parse)
|
|
362
|
+
self.lease_expires_at = result.claim.lease_expires_at
|
|
363
|
+
self.cancel_requested = result.claim.cancel_requested
|
|
364
|
+
self.server_time = result.server_time
|
|
365
|
+
self.recommended_heartbeat_seconds = result.recommended_heartbeat_seconds
|
|
366
|
+
return result
|
|
367
|
+
|
|
368
|
+
async def complete(
|
|
369
|
+
self,
|
|
370
|
+
*,
|
|
371
|
+
spawn: Sequence[Mapping[str, Any]] | None = None,
|
|
372
|
+
events: Sequence[Mapping[str, Any]] | None = None,
|
|
373
|
+
capabilities: Capabilities | None = None,
|
|
374
|
+
spawn_encoder: PayloadEncoder[Any] | None = None,
|
|
375
|
+
max_payload_bytes: int | None = None,
|
|
376
|
+
) -> CompleteResult:
|
|
377
|
+
self._ensure_usable()
|
|
378
|
+
limit = max_payload_bytes
|
|
379
|
+
if limit is None and capabilities is not None:
|
|
380
|
+
limit = capabilities.payload_runtime_max_bytes
|
|
381
|
+
wire_spawn = _encode_spawn_items(
|
|
382
|
+
spawn, encoder=spawn_encoder, max_payload_bytes=limit
|
|
383
|
+
)
|
|
384
|
+
if events:
|
|
385
|
+
caps = (
|
|
386
|
+
capabilities
|
|
387
|
+
if capabilities is not None
|
|
388
|
+
else await self._client.get_capabilities()
|
|
389
|
+
)
|
|
390
|
+
require_delivery_events(caps, events)
|
|
391
|
+
body: dict[str, Any] = {
|
|
392
|
+
"generation": self.generation,
|
|
393
|
+
"spawn": wire_spawn,
|
|
394
|
+
}
|
|
395
|
+
if events:
|
|
396
|
+
body["events"] = [dict(item) for item in events]
|
|
397
|
+
fingerprint = self._check_terminal("complete", body)
|
|
398
|
+
result = await self._request_lease("complete", body, CompleteResult.parse)
|
|
399
|
+
self._terminal = ("complete", fingerprint)
|
|
400
|
+
return result
|
|
401
|
+
|
|
402
|
+
async def fail(
|
|
403
|
+
self,
|
|
404
|
+
*,
|
|
405
|
+
retryable: bool,
|
|
406
|
+
failure_code: str,
|
|
407
|
+
failure_detail: str | None = None,
|
|
408
|
+
) -> FailResult:
|
|
409
|
+
self._ensure_usable()
|
|
410
|
+
body: dict[str, Any] = {
|
|
411
|
+
"generation": self.generation,
|
|
412
|
+
"retryable": retryable,
|
|
413
|
+
"failure_code": failure_code,
|
|
414
|
+
}
|
|
415
|
+
if failure_detail is not None:
|
|
416
|
+
body["failure_detail"] = failure_detail
|
|
417
|
+
fingerprint = self._check_terminal("fail", body)
|
|
418
|
+
result = await self._request_lease("fail", body, FailResult.parse)
|
|
419
|
+
self._terminal = ("fail", fingerprint)
|
|
420
|
+
return result
|
|
421
|
+
|
|
422
|
+
async def ack_cancel(self) -> AckCancelResult:
|
|
423
|
+
self._ensure_usable()
|
|
424
|
+
body = {"generation": self.generation}
|
|
425
|
+
fingerprint = self._check_terminal("ack_cancel", body)
|
|
426
|
+
result = await self._request_lease("ack-cancel", body, AckCancelResult.parse)
|
|
427
|
+
self._terminal = ("ack_cancel", fingerprint)
|
|
428
|
+
return result
|
|
429
|
+
|
|
430
|
+
def _ensure_usable(self) -> None:
|
|
431
|
+
if self._lease_lost:
|
|
432
|
+
raise LeaseLostError(claim_id=self.claim_id)
|
|
433
|
+
|
|
434
|
+
def _check_terminal(self, kind: str, body: Mapping[str, Any]) -> str:
|
|
435
|
+
fingerprint = _fingerprint(body)
|
|
436
|
+
if self._terminal is None:
|
|
437
|
+
return fingerprint
|
|
438
|
+
prev_kind, prev_fp = self._terminal
|
|
439
|
+
if prev_kind == kind and prev_fp == fingerprint:
|
|
440
|
+
return fingerprint
|
|
441
|
+
raise TerminalConflictError(
|
|
442
|
+
claim_id=self.claim_id,
|
|
443
|
+
reason="changed terminal body or operation",
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
def _lease_headers(self) -> dict[str, str]:
|
|
447
|
+
return self._client._auth_headers({_CLAIM_TOKEN_HEADER: self._claim_token})
|
|
448
|
+
|
|
449
|
+
async def _request_lease(
|
|
450
|
+
self,
|
|
451
|
+
operation: str,
|
|
452
|
+
body: Mapping[str, Any],
|
|
453
|
+
parser: Any,
|
|
454
|
+
) -> Any:
|
|
455
|
+
path = f"/v1/claims/{encode_path_segment(self.claim_id)}:{operation}"
|
|
456
|
+
try:
|
|
457
|
+
response = await self._client._transport.request(
|
|
458
|
+
"POST",
|
|
459
|
+
path,
|
|
460
|
+
headers=self._lease_headers(),
|
|
461
|
+
json_body=dict(body),
|
|
462
|
+
)
|
|
463
|
+
except ProtocolError as exc:
|
|
464
|
+
if exc.code.value == "lease_lost" and self._terminal is None:
|
|
465
|
+
self._lease_lost = True
|
|
466
|
+
raise
|
|
467
|
+
payload = _require_mapping(response.status_code, response.body)
|
|
468
|
+
try:
|
|
469
|
+
return parser(payload)
|
|
470
|
+
except ValueError as exc:
|
|
471
|
+
raise MalformedResponseError(
|
|
472
|
+
status_code=response.status_code,
|
|
473
|
+
reason=str(exc),
|
|
474
|
+
) from exc
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
__all__ = ["AsyncClaim", "AsyncConsumerClient"]
|