workhold-admin 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.
@@ -0,0 +1,1071 @@
1
+ """Async admin/observer/break-glass client surfaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from datetime import datetime
7
+ from typing import Any
8
+ from urllib.parse import urlencode
9
+
10
+ from _workhold_client_core.async_transport import AsyncTransport
11
+ from _workhold_client_core.capabilities import Capabilities
12
+ from _workhold_client_core.errors import MalformedResponseError
13
+ from _workhold_client_core.models import Task
14
+ from _workhold_client_core.transport import encode_path_segment
15
+
16
+ from workhold_admin.admin import (
17
+ _wire_config_version,
18
+ _wire_policy_version,
19
+ _wire_queue_state,
20
+ )
21
+ from workhold_admin.break_glass import (
22
+ _DEFAULT_EXTEND_SECONDS,
23
+ _DEFAULT_FAILURE_CODE,
24
+ _DEFAULT_REGISTRY,
25
+ _DEFAULT_REPLAY_FACTOR,
26
+ _DEFAULT_REPLAY_TTL_SECONDS,
27
+ _ack_body,
28
+ _wire_registry,
29
+ )
30
+ from workhold_admin.models import (
31
+ AdminMutationResult,
32
+ AttemptPage,
33
+ AuditPage,
34
+ BreakGlassCounterResult,
35
+ BreakGlassMutationResult,
36
+ BreakGlassReplayLimitResult,
37
+ BulkExecuteResult,
38
+ BulkPreviewResult,
39
+ ConfigVersion,
40
+ DeadLetterPage,
41
+ DeadLetterReplayResult,
42
+ MaintenanceRunResult,
43
+ MaintenanceStatus,
44
+ PAGE_LIMIT_DEFAULT,
45
+ PolicyVersion,
46
+ Queue,
47
+ QueuePage,
48
+ QueueState,
49
+ RetryPolicyDraft,
50
+ StatsSnapshot,
51
+ TaskPage,
52
+ format_datetime,
53
+ validate_acknowledge_duplicate_window,
54
+ validate_batch_limit,
55
+ validate_bulk_filters,
56
+ validate_confirmation_token,
57
+ validate_cursor,
58
+ validate_event_id,
59
+ validate_extend_seconds,
60
+ validate_failure_code,
61
+ validate_idempotency_key,
62
+ validate_page_limit,
63
+ validate_partition_name,
64
+ validate_queue_name,
65
+ validate_reason,
66
+ validate_registry_entry_id,
67
+ validate_replay_factor,
68
+ validate_replay_ttl_seconds,
69
+ validate_start_index,
70
+ validate_task_id,
71
+ validate_time_range,
72
+ )
73
+ from workhold_admin.observer import _page_params, _page_query
74
+
75
+
76
+ class AsyncObserverClient:
77
+ """Async observer reads mirroring :class:`ObserverClient`."""
78
+
79
+ def __init__(
80
+ self,
81
+ transport: AsyncTransport,
82
+ *,
83
+ bearer_token: str,
84
+ admin_transport: AsyncTransport,
85
+ owns_transport: bool = False,
86
+ owns_admin_transport: bool = False,
87
+ ) -> None:
88
+ if not bearer_token or not bearer_token.strip():
89
+ raise ValueError("bearer_token is required")
90
+ self._transport = transport
91
+ self._admin_transport = admin_transport
92
+ self._bearer_token = bearer_token
93
+ self._owns_transport = owns_transport
94
+ self._owns_admin_transport = owns_admin_transport
95
+
96
+ @classmethod
97
+ def from_urls(
98
+ cls,
99
+ public_base_url: str,
100
+ admin_base_url: str,
101
+ *,
102
+ bearer_token: str,
103
+ timeout_s: float = 30.0,
104
+ ) -> AsyncObserverClient:
105
+ from _workhold_client_core.async_transport import HttpxAsyncTransport
106
+
107
+ public = HttpxAsyncTransport(public_base_url, timeout_s=timeout_s)
108
+ admin = HttpxAsyncTransport(admin_base_url, timeout_s=timeout_s)
109
+ return cls(
110
+ public,
111
+ bearer_token=bearer_token,
112
+ admin_transport=admin,
113
+ owns_transport=True,
114
+ owns_admin_transport=True,
115
+ )
116
+
117
+ async def __aenter__(self) -> AsyncObserverClient:
118
+ return self
119
+
120
+ async def __aexit__(self, *_exc: object) -> None:
121
+ if self._owns_transport:
122
+ await self._transport.aclose()
123
+ if self._owns_admin_transport:
124
+ await self._admin_transport.aclose()
125
+
126
+ def __repr__(self) -> str:
127
+ return (
128
+ "AsyncObserverClient(transport=..., admin_transport=..., "
129
+ "bearer_token=<redacted>)"
130
+ )
131
+
132
+ def _auth_headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
133
+ headers = {"Authorization": f"Bearer {self._bearer_token}"}
134
+ if extra:
135
+ headers.update(extra)
136
+ return headers
137
+
138
+ async def get_capabilities(self) -> Capabilities:
139
+ response = await self._transport.request(
140
+ "GET",
141
+ "/v1/capabilities",
142
+ headers=self._auth_headers(),
143
+ json_body=None,
144
+ )
145
+ return self._parse(Capabilities.parse, response.status_code, response.body)
146
+
147
+ async def get_task(self, task_id: str) -> Task:
148
+ wire_task_id = validate_task_id(task_id)
149
+ path = f"/v1/tasks/{encode_path_segment(wire_task_id)}"
150
+ response = await self._transport.request(
151
+ "GET",
152
+ path,
153
+ headers=self._auth_headers(),
154
+ json_body=None,
155
+ )
156
+ return self._parse(Task.parse, response.status_code, response.body)
157
+
158
+ async def list_task_attempts(
159
+ self,
160
+ task_id: str,
161
+ *,
162
+ cursor: str | None = None,
163
+ limit: int = PAGE_LIMIT_DEFAULT,
164
+ ) -> AttemptPage:
165
+ wire_task_id = validate_task_id(task_id)
166
+ wire_limit = validate_page_limit(limit)
167
+ wire_cursor = validate_cursor(cursor)
168
+ query = _page_query(limit=wire_limit, cursor=wire_cursor)
169
+ path = f"/v1/tasks/{encode_path_segment(wire_task_id)}/attempts{query}"
170
+ response = await self._transport.request(
171
+ "GET",
172
+ path,
173
+ headers=self._auth_headers(),
174
+ json_body=None,
175
+ )
176
+ return self._parse(AttemptPage.parse, response.status_code, response.body)
177
+
178
+ async def get_queue(self, queue_name: str) -> Queue:
179
+ wire_name = validate_queue_name(queue_name)
180
+ path = f"/admin/v1/queues/{encode_path_segment(wire_name)}"
181
+ response = await self._admin_transport.request(
182
+ "GET",
183
+ path,
184
+ headers=self._auth_headers(),
185
+ json_body=None,
186
+ )
187
+ return self._parse(Queue.parse, response.status_code, response.body)
188
+
189
+ async def get_stats(self) -> StatsSnapshot:
190
+ """GET ``/admin/v1/stats`` (OpenAPI ``getStats``; parameterless)."""
191
+
192
+ response = await self._admin_transport.request(
193
+ "GET",
194
+ "/admin/v1/stats",
195
+ headers=self._auth_headers(),
196
+ json_body=None,
197
+ )
198
+ return self._parse(StatsSnapshot.parse, response.status_code, response.body)
199
+
200
+ async def get_maintenance_status(self) -> MaintenanceStatus:
201
+ response = await self._admin_transport.request(
202
+ "GET",
203
+ "/admin/v1/maintenance",
204
+ headers=self._auth_headers(),
205
+ json_body=None,
206
+ )
207
+ return self._parse(MaintenanceStatus.parse, response.status_code, response.body)
208
+
209
+ async def list_inspection_tasks(
210
+ self,
211
+ queue_name: str,
212
+ *,
213
+ cursor: str | None = None,
214
+ limit: int = PAGE_LIMIT_DEFAULT,
215
+ ) -> TaskPage:
216
+ wire_name = validate_queue_name(queue_name)
217
+ wire_limit = validate_page_limit(limit)
218
+ wire_cursor = validate_cursor(cursor)
219
+ params: dict[str, str] = {"queue_name": wire_name}
220
+ params.update(_page_params(limit=wire_limit, cursor=wire_cursor))
221
+ path = f"/admin/v1/tasks?{urlencode(params)}"
222
+ response = await self._admin_transport.request(
223
+ "GET",
224
+ path,
225
+ headers=self._auth_headers(),
226
+ json_body=None,
227
+ )
228
+ return self._parse(TaskPage.parse, response.status_code, response.body)
229
+
230
+ async def list_inspection_attempts(
231
+ self,
232
+ task_id: str,
233
+ *,
234
+ time_from: datetime,
235
+ time_to: datetime,
236
+ cursor: str | None = None,
237
+ limit: int = PAGE_LIMIT_DEFAULT,
238
+ ) -> AttemptPage:
239
+ wire_task_id = validate_task_id(task_id)
240
+ validate_time_range(time_from, time_to)
241
+ wire_limit = validate_page_limit(limit)
242
+ wire_cursor = validate_cursor(cursor)
243
+ params: dict[str, str] = {
244
+ "task_id": wire_task_id,
245
+ "from": format_datetime(time_from),
246
+ "to": format_datetime(time_to),
247
+ }
248
+ params.update(_page_params(limit=wire_limit, cursor=wire_cursor))
249
+ path = f"/admin/v1/attempts?{urlencode(params)}"
250
+ response = await self._admin_transport.request(
251
+ "GET",
252
+ path,
253
+ headers=self._auth_headers(),
254
+ json_body=None,
255
+ )
256
+ return self._parse(AttemptPage.parse, response.status_code, response.body)
257
+
258
+ async def list_dead_letters(
259
+ self,
260
+ queue_name: str,
261
+ *,
262
+ time_from: datetime,
263
+ time_to: datetime,
264
+ cursor: str | None = None,
265
+ limit: int = PAGE_LIMIT_DEFAULT,
266
+ ) -> DeadLetterPage:
267
+ wire_name = validate_queue_name(queue_name)
268
+ validate_time_range(time_from, time_to)
269
+ wire_limit = validate_page_limit(limit)
270
+ wire_cursor = validate_cursor(cursor)
271
+ params: dict[str, str] = {
272
+ "queue_name": wire_name,
273
+ "from": format_datetime(time_from),
274
+ "to": format_datetime(time_to),
275
+ }
276
+ params.update(_page_params(limit=wire_limit, cursor=wire_cursor))
277
+ path = f"/admin/v1/dead-letters?{urlencode(params)}"
278
+ response = await self._admin_transport.request(
279
+ "GET",
280
+ path,
281
+ headers=self._auth_headers(),
282
+ json_body=None,
283
+ )
284
+ return self._parse(DeadLetterPage.parse, response.status_code, response.body)
285
+
286
+ @staticmethod
287
+ def _parse(parser: Any, status_code: int, body: object | None) -> Any:
288
+ if body is None:
289
+ raise MalformedResponseError(
290
+ status_code=status_code,
291
+ reason="empty success response body",
292
+ )
293
+ try:
294
+ return parser(body)
295
+ except ValueError as exc:
296
+ raise MalformedResponseError(
297
+ status_code=status_code,
298
+ reason=str(exc),
299
+ ) from exc
300
+
301
+
302
+ class AsyncAdminClient:
303
+ """Async admin mutations mirroring :class:`AdminClient`.
304
+
305
+ Single-transport form uses the same transport for admin-plane calls.
306
+ Dual-transport form routes admin ops through ``admin_transport`` and
307
+ composes :class:`AsyncObserverClient` for maintenance status reads.
308
+ """
309
+
310
+ def __init__(
311
+ self,
312
+ transport: AsyncTransport,
313
+ *,
314
+ bearer_token: str,
315
+ admin_transport: AsyncTransport | None = None,
316
+ owns_transport: bool = False,
317
+ owns_admin_transport: bool = False,
318
+ ) -> None:
319
+ if not bearer_token or not bearer_token.strip():
320
+ raise ValueError("bearer_token is required")
321
+ resolved_admin = transport if admin_transport is None else admin_transport
322
+ self._observer = AsyncObserverClient(
323
+ transport,
324
+ bearer_token=bearer_token,
325
+ admin_transport=resolved_admin,
326
+ )
327
+ self._transport = transport
328
+ self._admin_transport = resolved_admin
329
+ self._bearer_token = bearer_token
330
+ self._owns_transport = owns_transport
331
+ self._owns_admin_transport = owns_admin_transport and admin_transport is not None
332
+
333
+ @classmethod
334
+ def from_url(
335
+ cls,
336
+ admin_base_url: str,
337
+ *,
338
+ bearer_token: str,
339
+ timeout_s: float = 30.0,
340
+ ) -> AsyncAdminClient:
341
+ """Build a client for collocated public+admin deployments.
342
+
343
+ Uses one owned transport for both planes. This does **not** support
344
+ separate public and admin base URLs; use :meth:`from_urls` when the
345
+ planes are split.
346
+ """
347
+
348
+ from _workhold_client_core.async_transport import HttpxAsyncTransport
349
+
350
+ transport = HttpxAsyncTransport(admin_base_url, timeout_s=timeout_s)
351
+ return cls(transport, bearer_token=bearer_token, owns_transport=True)
352
+
353
+ @classmethod
354
+ def from_urls(
355
+ cls,
356
+ public_base_url: str,
357
+ admin_base_url: str,
358
+ *,
359
+ bearer_token: str,
360
+ timeout_s: float = 30.0,
361
+ ) -> AsyncAdminClient:
362
+ """Build a dual-plane client with distinct owned public and admin transports.
363
+
364
+ Public-plane ops (for example ``get_capabilities``) use
365
+ ``public_base_url``; admin-plane ops use ``admin_base_url``. Even when
366
+ the URLs are equal, two transport instances are created and each is
367
+ closed once on :meth:`aclose`.
368
+ """
369
+
370
+ from _workhold_client_core.async_transport import HttpxAsyncTransport
371
+
372
+ public = HttpxAsyncTransport(public_base_url, timeout_s=timeout_s)
373
+ admin = HttpxAsyncTransport(admin_base_url, timeout_s=timeout_s)
374
+ return cls(
375
+ public,
376
+ bearer_token=bearer_token,
377
+ admin_transport=admin,
378
+ owns_transport=True,
379
+ owns_admin_transport=True,
380
+ )
381
+
382
+ async def __aenter__(self) -> AsyncAdminClient:
383
+ return self
384
+
385
+ async def __aexit__(self, *_exc: object) -> None:
386
+ await self.aclose()
387
+
388
+ async def aclose(self) -> None:
389
+ """Close each client-owned transport at most once; leave caller-owned alone.
390
+
391
+ Idempotent: a second call or ``aclose`` followed by ``__aexit__`` does
392
+ not close again. If public and admin refer to the same owned object,
393
+ that object is closed once.
394
+ """
395
+
396
+ to_close: list[AsyncTransport] = []
397
+ if self._owns_transport:
398
+ to_close.append(self._transport)
399
+ if self._owns_admin_transport and not any(
400
+ transport is self._admin_transport for transport in to_close
401
+ ):
402
+ to_close.append(self._admin_transport)
403
+ self._owns_transport = False
404
+ self._owns_admin_transport = False
405
+ for transport in to_close:
406
+ await transport.aclose()
407
+
408
+ def __repr__(self) -> str:
409
+ return (
410
+ "AsyncAdminClient(transport=..., admin_transport=..., "
411
+ "bearer_token=<redacted>)"
412
+ )
413
+
414
+ def _auth_headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
415
+ headers = {"Authorization": f"Bearer {self._bearer_token}"}
416
+ if extra:
417
+ headers.update(extra)
418
+ return headers
419
+
420
+ async def list_queues(
421
+ self,
422
+ *,
423
+ cursor: str | None = None,
424
+ limit: int = PAGE_LIMIT_DEFAULT,
425
+ ) -> QueuePage:
426
+ wire_limit = validate_page_limit(limit)
427
+ wire_cursor = validate_cursor(cursor)
428
+ query = _page_query(limit=wire_limit, cursor=wire_cursor)
429
+ response = await self._admin_transport.request(
430
+ "GET",
431
+ f"/admin/v1/queues{query}",
432
+ headers=self._auth_headers(),
433
+ json_body=None,
434
+ )
435
+ return self._parse(QueuePage.parse, response.status_code, response.body)
436
+
437
+ async def create_queue(
438
+ self,
439
+ name: str,
440
+ *,
441
+ initial_policy: RetryPolicyDraft,
442
+ idempotency_key: str,
443
+ ) -> AdminMutationResult:
444
+ wire_name = validate_queue_name(name)
445
+ wire_key = validate_idempotency_key(idempotency_key)
446
+ body = {
447
+ "name": wire_name,
448
+ "initial_policy": initial_policy.to_wire(),
449
+ }
450
+ response = await self._admin_transport.request(
451
+ "POST",
452
+ "/admin/v1/queues",
453
+ headers=self._auth_headers({"Idempotency-Key": wire_key}),
454
+ json_body=body,
455
+ )
456
+ return self._parse(AdminMutationResult.parse, response.status_code, response.body)
457
+
458
+ async def get_queue(self, queue_name: str) -> Queue:
459
+ wire_name = validate_queue_name(queue_name)
460
+ path = f"/admin/v1/queues/{encode_path_segment(wire_name)}"
461
+ response = await self._admin_transport.request(
462
+ "GET",
463
+ path,
464
+ headers=self._auth_headers(),
465
+ json_body=None,
466
+ )
467
+ return self._parse(Queue.parse, response.status_code, response.body)
468
+
469
+ async def create_queue_policy(
470
+ self,
471
+ queue_name: str,
472
+ policy: RetryPolicyDraft,
473
+ *,
474
+ idempotency_key: str,
475
+ ) -> AdminMutationResult:
476
+ wire_name = validate_queue_name(queue_name)
477
+ wire_key = validate_idempotency_key(idempotency_key)
478
+ path = f"/admin/v1/queues/{encode_path_segment(wire_name)}/policies"
479
+ response = await self._admin_transport.request(
480
+ "POST",
481
+ path,
482
+ headers=self._auth_headers({"Idempotency-Key": wire_key}),
483
+ json_body=policy.to_wire(),
484
+ )
485
+ return self._parse(AdminMutationResult.parse, response.status_code, response.body)
486
+
487
+ async def activate_queue_policy(
488
+ self,
489
+ queue_name: str,
490
+ policy_version: PolicyVersion | int,
491
+ *,
492
+ expected_config_version: ConfigVersion | int,
493
+ idempotency_key: str,
494
+ ) -> AdminMutationResult:
495
+ wire_name = validate_queue_name(queue_name)
496
+ wire_key = validate_idempotency_key(idempotency_key)
497
+ wire_policy_version = _wire_policy_version(policy_version)
498
+ wire_config_version = _wire_config_version(expected_config_version)
499
+ path = (
500
+ f"/admin/v1/queues/{encode_path_segment(wire_name)}/policies/"
501
+ f"{wire_policy_version}:activate"
502
+ )
503
+ body = {"expected_config_version": wire_config_version}
504
+ response = await self._admin_transport.request(
505
+ "POST",
506
+ path,
507
+ headers=self._auth_headers({"Idempotency-Key": wire_key}),
508
+ json_body=body,
509
+ )
510
+ return self._parse(AdminMutationResult.parse, response.status_code, response.body)
511
+
512
+ async def set_queue_state(
513
+ self,
514
+ queue_name: str,
515
+ state: QueueState | str,
516
+ *,
517
+ expected_config_version: ConfigVersion | int,
518
+ idempotency_key: str,
519
+ ) -> AdminMutationResult:
520
+ wire_name = validate_queue_name(queue_name)
521
+ wire_key = validate_idempotency_key(idempotency_key)
522
+ wire_config_version = _wire_config_version(expected_config_version)
523
+ wire_state = _wire_queue_state(state)
524
+ path = f"/admin/v1/queues/{encode_path_segment(wire_name)}:set-state"
525
+ body = {
526
+ "expected_config_version": wire_config_version,
527
+ "state": wire_state,
528
+ }
529
+ response = await self._admin_transport.request(
530
+ "POST",
531
+ path,
532
+ headers=self._auth_headers({"Idempotency-Key": wire_key}),
533
+ json_body=body,
534
+ )
535
+ return self._parse(AdminMutationResult.parse, response.status_code, response.body)
536
+
537
+ async def get_maintenance_status(self) -> MaintenanceStatus:
538
+ """GET ``/admin/v1/maintenance`` (OpenAPI ``getMaintenanceStatus``)."""
539
+
540
+ return await self._observer.get_maintenance_status()
541
+
542
+ async def get_capabilities(self) -> Capabilities:
543
+ """GET ``/v1/capabilities`` (OpenAPI ``getCapabilities``; admin-authorized)."""
544
+
545
+ return await self._observer.get_capabilities()
546
+
547
+ async def get_stats(self) -> StatsSnapshot:
548
+ """GET ``/admin/v1/stats`` (OpenAPI ``getStats``; parameterless)."""
549
+
550
+ return await self._observer.get_stats()
551
+
552
+ async def list_inspection_tasks(
553
+ self,
554
+ queue_name: str,
555
+ *,
556
+ cursor: str | None = None,
557
+ limit: int = PAGE_LIMIT_DEFAULT,
558
+ ) -> TaskPage:
559
+ """GET ``/admin/v1/tasks`` (OpenAPI ``listInspectionTasks``)."""
560
+
561
+ return await self._observer.list_inspection_tasks(
562
+ queue_name, cursor=cursor, limit=limit
563
+ )
564
+
565
+ async def list_inspection_attempts(
566
+ self,
567
+ task_id: str,
568
+ *,
569
+ time_from: datetime,
570
+ time_to: datetime,
571
+ cursor: str | None = None,
572
+ limit: int = PAGE_LIMIT_DEFAULT,
573
+ ) -> AttemptPage:
574
+ """GET ``/admin/v1/attempts`` (OpenAPI ``listInspectionAttempts``)."""
575
+
576
+ return await self._observer.list_inspection_attempts(
577
+ task_id,
578
+ time_from=time_from,
579
+ time_to=time_to,
580
+ cursor=cursor,
581
+ limit=limit,
582
+ )
583
+
584
+ async def list_dead_letters(
585
+ self,
586
+ queue_name: str,
587
+ *,
588
+ time_from: datetime,
589
+ time_to: datetime,
590
+ cursor: str | None = None,
591
+ limit: int = PAGE_LIMIT_DEFAULT,
592
+ ) -> DeadLetterPage:
593
+ """GET ``/admin/v1/dead-letters`` (OpenAPI ``listDeadLetters``)."""
594
+
595
+ return await self._observer.list_dead_letters(
596
+ queue_name,
597
+ time_from=time_from,
598
+ time_to=time_to,
599
+ cursor=cursor,
600
+ limit=limit,
601
+ )
602
+
603
+ async def list_admin_audit(
604
+ self,
605
+ *,
606
+ time_from: datetime,
607
+ time_to: datetime,
608
+ queue_name: str | None = None,
609
+ cursor: str | None = None,
610
+ limit: int = PAGE_LIMIT_DEFAULT,
611
+ ) -> AuditPage:
612
+ validate_time_range(time_from, time_to)
613
+ wire_limit = validate_page_limit(limit)
614
+ wire_cursor = validate_cursor(cursor)
615
+ params: dict[str, str] = {
616
+ "from": format_datetime(time_from),
617
+ "to": format_datetime(time_to),
618
+ }
619
+ if queue_name is not None:
620
+ params["queue_name"] = validate_queue_name(queue_name)
621
+ params.update(_page_params(limit=wire_limit, cursor=wire_cursor))
622
+ path = f"/admin/v1/audit?{urlencode(params)}"
623
+ response = await self._admin_transport.request(
624
+ "GET",
625
+ path,
626
+ headers=self._auth_headers(),
627
+ json_body=None,
628
+ )
629
+ return self._parse(AuditPage.parse, response.status_code, response.body)
630
+
631
+ async def run_maintenance(self, *, idempotency_key: str) -> MaintenanceRunResult:
632
+ wire_key = validate_idempotency_key(idempotency_key)
633
+ response = await self._admin_transport.request(
634
+ "POST",
635
+ "/admin/v1/maintenance:run",
636
+ headers=self._auth_headers({"Idempotency-Key": wire_key}),
637
+ json_body=None,
638
+ )
639
+ return self._parse(MaintenanceRunResult.parse, response.status_code, response.body)
640
+
641
+ async def replay_dead_letter(
642
+ self,
643
+ queue_name: str,
644
+ task_id: str,
645
+ *,
646
+ idempotency_key: str,
647
+ reason: str,
648
+ ) -> DeadLetterReplayResult:
649
+ wire_queue = validate_queue_name(queue_name)
650
+ wire_task_id = validate_task_id(task_id)
651
+ wire_key = validate_idempotency_key(idempotency_key)
652
+ wire_reason = validate_reason(reason)
653
+ path = (
654
+ f"/admin/v1/queues/{encode_path_segment(wire_queue)}"
655
+ f"/dead-letters/{encode_path_segment(wire_task_id)}:replay"
656
+ )
657
+ response = await self._admin_transport.request(
658
+ "POST",
659
+ path,
660
+ headers=self._auth_headers({"Idempotency-Key": wire_key}),
661
+ json_body={"reason": wire_reason},
662
+ )
663
+ return self._parse(
664
+ DeadLetterReplayResult.parse, response.status_code, response.body
665
+ )
666
+
667
+ async def preview_bulk_replay(
668
+ self,
669
+ queue_name: str,
670
+ *,
671
+ filters: Mapping[str, str] | None = None,
672
+ ) -> BulkPreviewResult:
673
+ return await self._preview_bulk(
674
+ queue_name,
675
+ operation_path="bulk:preview-replay",
676
+ filters=filters,
677
+ )
678
+
679
+ async def execute_bulk_replay(
680
+ self,
681
+ queue_name: str,
682
+ *,
683
+ preview: BulkPreviewResult,
684
+ idempotency_key: str,
685
+ reason: str,
686
+ filters: Mapping[str, str],
687
+ start_index: int = 0,
688
+ batch_limit: int | None = None,
689
+ ) -> BulkExecuteResult:
690
+ return await self._execute_bulk(
691
+ queue_name,
692
+ operation_path="bulk:execute-replay",
693
+ expected_operation="bulk_replay",
694
+ preview=preview,
695
+ idempotency_key=idempotency_key,
696
+ reason=reason,
697
+ filters=filters,
698
+ start_index=start_index,
699
+ batch_limit=batch_limit,
700
+ )
701
+
702
+ async def preview_bulk_cancel(
703
+ self,
704
+ queue_name: str,
705
+ *,
706
+ filters: Mapping[str, str] | None = None,
707
+ ) -> BulkPreviewResult:
708
+ return await self._preview_bulk(
709
+ queue_name,
710
+ operation_path="bulk:preview-cancel",
711
+ filters=filters,
712
+ )
713
+
714
+ async def execute_bulk_cancel(
715
+ self,
716
+ queue_name: str,
717
+ *,
718
+ preview: BulkPreviewResult,
719
+ reason: str,
720
+ filters: Mapping[str, str],
721
+ start_index: int = 0,
722
+ batch_limit: int | None = None,
723
+ ) -> BulkExecuteResult:
724
+ return await self._execute_bulk(
725
+ queue_name,
726
+ operation_path="bulk:execute-cancel",
727
+ expected_operation="bulk_cancel",
728
+ preview=preview,
729
+ idempotency_key=None,
730
+ reason=reason,
731
+ filters=filters,
732
+ start_index=start_index,
733
+ batch_limit=batch_limit,
734
+ )
735
+
736
+ async def _preview_bulk(
737
+ self,
738
+ queue_name: str,
739
+ *,
740
+ operation_path: str,
741
+ filters: Mapping[str, str] | None,
742
+ ) -> BulkPreviewResult:
743
+ wire_queue = validate_queue_name(queue_name)
744
+ body: dict[str, Any] = {}
745
+ if filters is not None:
746
+ body["filters"] = validate_bulk_filters(filters)
747
+ path = f"/admin/v1/queues/{encode_path_segment(wire_queue)}/{operation_path}"
748
+ response = await self._admin_transport.request(
749
+ "POST",
750
+ path,
751
+ headers=self._auth_headers(),
752
+ json_body=body,
753
+ )
754
+ result = self._parse(BulkPreviewResult.parse, response.status_code, response.body)
755
+ if result.queue != wire_queue:
756
+ raise ValueError("preview.queue does not match queue_name")
757
+ return result
758
+
759
+ async def _execute_bulk(
760
+ self,
761
+ queue_name: str,
762
+ *,
763
+ operation_path: str,
764
+ expected_operation: str,
765
+ preview: BulkPreviewResult,
766
+ idempotency_key: str | None,
767
+ reason: str,
768
+ filters: Mapping[str, str],
769
+ start_index: int,
770
+ batch_limit: int | None,
771
+ ) -> BulkExecuteResult:
772
+ wire_queue = validate_queue_name(queue_name)
773
+ self._assert_execute_preview_binding(preview, wire_queue, expected_operation)
774
+ wire_reason = validate_reason(reason)
775
+ wire_filters = validate_bulk_filters(filters)
776
+ wire_start = validate_start_index(start_index)
777
+ wire_batch_limit = validate_batch_limit(batch_limit)
778
+ wire_token = validate_confirmation_token(preview.confirmation_token)
779
+ body: dict[str, Any] = {
780
+ "confirmation_token": wire_token,
781
+ "filters": wire_filters,
782
+ "reason": wire_reason,
783
+ "start_index": wire_start,
784
+ }
785
+ if wire_batch_limit is not None:
786
+ body["batch_limit"] = wire_batch_limit
787
+ extra_headers: dict[str, str] = {}
788
+ if idempotency_key is not None:
789
+ extra_headers["Idempotency-Key"] = validate_idempotency_key(idempotency_key)
790
+ path = f"/admin/v1/queues/{encode_path_segment(wire_queue)}/{operation_path}"
791
+ response = await self._admin_transport.request(
792
+ "POST",
793
+ path,
794
+ headers=self._auth_headers(extra_headers or None),
795
+ json_body=body,
796
+ )
797
+ return self._parse(BulkExecuteResult.parse, response.status_code, response.body)
798
+
799
+ @staticmethod
800
+ def _assert_execute_preview_binding(
801
+ preview: BulkPreviewResult,
802
+ queue_name: str,
803
+ expected_operation: str,
804
+ ) -> None:
805
+ if preview.queue != queue_name:
806
+ raise ValueError("preview.queue does not match queue_name")
807
+ if preview.operation.value != expected_operation:
808
+ raise ValueError(
809
+ f"preview.operation must be {expected_operation!r}, "
810
+ f"got {preview.operation.value!r}"
811
+ )
812
+
813
+ @staticmethod
814
+ def _parse(parser: Any, status_code: int, body: object | None) -> Any:
815
+ if body is None:
816
+ raise MalformedResponseError(
817
+ status_code=status_code,
818
+ reason="empty success response body",
819
+ )
820
+ try:
821
+ return parser(body)
822
+ except ValueError as exc:
823
+ raise MalformedResponseError(
824
+ status_code=status_code,
825
+ reason=str(exc),
826
+ ) from exc
827
+
828
+
829
+ class AsyncBreakGlassClient:
830
+ """Async break-glass emergency mutations mirroring :class:`BreakGlassClient`."""
831
+
832
+ def __init_subclass__(cls, **kwargs: Any) -> None:
833
+ raise TypeError(f"{cls.__name__} cannot be subclassed")
834
+
835
+ def __init__(
836
+ self,
837
+ transport: AsyncTransport,
838
+ *,
839
+ bearer_token: str,
840
+ owns_transport: bool = False,
841
+ ) -> None:
842
+ if not bearer_token or not bearer_token.strip():
843
+ raise ValueError("bearer_token is required")
844
+ self._transport = transport
845
+ self._bearer_token = bearer_token
846
+ self._owns_transport = owns_transport
847
+
848
+ @classmethod
849
+ def from_url(
850
+ cls,
851
+ admin_base_url: str,
852
+ *,
853
+ bearer_token: str,
854
+ timeout_s: float = 30.0,
855
+ ) -> AsyncBreakGlassClient:
856
+ from _workhold_client_core.async_transport import HttpxAsyncTransport
857
+
858
+ transport = HttpxAsyncTransport(admin_base_url, timeout_s=timeout_s)
859
+ return cls(transport, bearer_token=bearer_token, owns_transport=True)
860
+
861
+ async def __aenter__(self) -> AsyncBreakGlassClient:
862
+ return self
863
+
864
+ async def __aexit__(self, *_exc: object) -> None:
865
+ if self._owns_transport:
866
+ await self._transport.aclose()
867
+
868
+ def __repr__(self) -> str:
869
+ return "AsyncBreakGlassClient(transport=..., bearer_token=<redacted>)"
870
+
871
+ def _auth_headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
872
+ headers = {"Authorization": f"Bearer {self._bearer_token}"}
873
+ if extra:
874
+ headers.update(extra)
875
+ return headers
876
+
877
+ async def force_lease_expiry(
878
+ self,
879
+ queue_name: str,
880
+ task_id: str,
881
+ *,
882
+ reason: str,
883
+ incident_reference: str,
884
+ risk_acknowledged: bool,
885
+ ) -> BreakGlassMutationResult:
886
+ wire_name = validate_queue_name(queue_name)
887
+ wire_task_id = validate_task_id(task_id)
888
+ body = _ack_body(
889
+ reason=reason,
890
+ incident_reference=incident_reference,
891
+ risk_acknowledged=risk_acknowledged,
892
+ extra={"task_id": wire_task_id},
893
+ )
894
+ path = (
895
+ f"/admin/v1/queues/{encode_path_segment(wire_name)}/tasks/"
896
+ f"{encode_path_segment(wire_task_id)}:force-lease-expiry"
897
+ )
898
+ return await self._post(path, body, BreakGlassMutationResult.parse)
899
+
900
+ async def force_delivery_reclaim(
901
+ self,
902
+ queue_name: str,
903
+ event_id: str,
904
+ *,
905
+ reason: str,
906
+ incident_reference: str,
907
+ risk_acknowledged: bool,
908
+ ) -> BreakGlassMutationResult:
909
+ wire_name = validate_queue_name(queue_name)
910
+ wire_event_id = validate_event_id(event_id)
911
+ body = _ack_body(
912
+ reason=reason,
913
+ incident_reference=incident_reference,
914
+ risk_acknowledged=risk_acknowledged,
915
+ )
916
+ path = (
917
+ f"/admin/v1/queues/{encode_path_segment(wire_name)}/delivery-events/"
918
+ f"{encode_path_segment(wire_event_id)}:force-reclaim"
919
+ )
920
+ return await self._post(path, body, BreakGlassMutationResult.parse)
921
+
922
+ async def force_delivery_dead_letter(
923
+ self,
924
+ queue_name: str,
925
+ event_id: str,
926
+ *,
927
+ reason: str,
928
+ incident_reference: str,
929
+ risk_acknowledged: bool,
930
+ failure_code: str = _DEFAULT_FAILURE_CODE,
931
+ ) -> BreakGlassMutationResult:
932
+ wire_name = validate_queue_name(queue_name)
933
+ wire_event_id = validate_event_id(event_id)
934
+ wire_failure_code = validate_failure_code(failure_code)
935
+ body = _ack_body(
936
+ reason=reason,
937
+ incident_reference=incident_reference,
938
+ risk_acknowledged=risk_acknowledged,
939
+ extra={"failure_code": wire_failure_code},
940
+ )
941
+ path = (
942
+ f"/admin/v1/queues/{encode_path_segment(wire_name)}/delivery-events/"
943
+ f"{encode_path_segment(wire_event_id)}:force-dead-letter"
944
+ )
945
+ return await self._post(path, body, BreakGlassMutationResult.parse)
946
+
947
+ async def reconcile_counters(
948
+ self,
949
+ queue_name: str,
950
+ *,
951
+ reason: str,
952
+ incident_reference: str,
953
+ risk_acknowledged: bool,
954
+ ) -> BreakGlassCounterResult:
955
+ wire_name = validate_queue_name(queue_name)
956
+ body = _ack_body(
957
+ reason=reason,
958
+ incident_reference=incident_reference,
959
+ risk_acknowledged=risk_acknowledged,
960
+ )
961
+ path = f"/admin/v1/queues/{encode_path_segment(wire_name)}:reconcile-counters"
962
+ return await self._post(path, body, BreakGlassCounterResult.parse)
963
+
964
+ async def raise_replay_limit(
965
+ self,
966
+ queue_name: str,
967
+ *,
968
+ reason: str,
969
+ incident_reference: str,
970
+ risk_acknowledged: bool,
971
+ factor: float = _DEFAULT_REPLAY_FACTOR,
972
+ ttl_seconds: int = _DEFAULT_REPLAY_TTL_SECONDS,
973
+ ) -> BreakGlassReplayLimitResult:
974
+ wire_name = validate_queue_name(queue_name)
975
+ wire_factor = validate_replay_factor(factor)
976
+ wire_ttl = validate_replay_ttl_seconds(ttl_seconds)
977
+ body = _ack_body(
978
+ reason=reason,
979
+ incident_reference=incident_reference,
980
+ risk_acknowledged=risk_acknowledged,
981
+ extra={"factor": wire_factor, "ttl_seconds": wire_ttl},
982
+ )
983
+ path = f"/admin/v1/queues/{encode_path_segment(wire_name)}:raise-replay-limit"
984
+ return await self._post(path, body, BreakGlassReplayLimitResult.parse)
985
+
986
+ async def drop_expired_partition(
987
+ self,
988
+ partition_name: str,
989
+ *,
990
+ reason: str,
991
+ incident_reference: str,
992
+ risk_acknowledged: bool,
993
+ ) -> BreakGlassMutationResult:
994
+ wire_partition = validate_partition_name(partition_name)
995
+ body = _ack_body(
996
+ reason=reason,
997
+ incident_reference=incident_reference,
998
+ risk_acknowledged=risk_acknowledged,
999
+ )
1000
+ path = (
1001
+ f"/admin/v1/partitions/{encode_path_segment(wire_partition)}:force-drop"
1002
+ )
1003
+ return await self._post(path, body, BreakGlassMutationResult.parse)
1004
+
1005
+ async def repair_registry_entry(
1006
+ self,
1007
+ queue_name: str,
1008
+ *,
1009
+ entry_id: int,
1010
+ reason: str,
1011
+ incident_reference: str,
1012
+ risk_acknowledged: bool,
1013
+ acknowledge_duplicate_window: bool,
1014
+ extend_seconds: int = _DEFAULT_EXTEND_SECONDS,
1015
+ registry: str = _DEFAULT_REGISTRY,
1016
+ ) -> BreakGlassMutationResult:
1017
+ wire_name = validate_queue_name(queue_name)
1018
+ wire_entry_id = validate_registry_entry_id(entry_id)
1019
+ wire_extend = validate_extend_seconds(extend_seconds)
1020
+ wire_registry = _wire_registry(registry)
1021
+ body = _ack_body(
1022
+ reason=reason,
1023
+ incident_reference=incident_reference,
1024
+ risk_acknowledged=risk_acknowledged,
1025
+ extra={
1026
+ "entry_id": wire_entry_id,
1027
+ "acknowledge_duplicate_window": validate_acknowledge_duplicate_window(
1028
+ acknowledge_duplicate_window
1029
+ ),
1030
+ "extend_seconds": wire_extend,
1031
+ "registry": wire_registry,
1032
+ },
1033
+ )
1034
+ path = f"/admin/v1/queues/{encode_path_segment(wire_name)}/registry:repair"
1035
+ return await self._post(path, body, BreakGlassMutationResult.parse)
1036
+
1037
+ async def _post(
1038
+ self,
1039
+ path: str,
1040
+ body: dict[str, Any],
1041
+ parser: Any,
1042
+ ) -> Any:
1043
+ response = await self._transport.request(
1044
+ "POST",
1045
+ path,
1046
+ headers=self._auth_headers(),
1047
+ json_body=body,
1048
+ )
1049
+ return self._parse(parser, response.status_code, response.body)
1050
+
1051
+ @staticmethod
1052
+ def _parse(parser: Any, status_code: int, body: object | None) -> Any:
1053
+ if body is None:
1054
+ raise MalformedResponseError(
1055
+ status_code=status_code,
1056
+ reason="empty success response body",
1057
+ )
1058
+ try:
1059
+ return parser(body)
1060
+ except ValueError as exc:
1061
+ raise MalformedResponseError(
1062
+ status_code=status_code,
1063
+ reason=str(exc),
1064
+ ) from exc
1065
+
1066
+
1067
+ __all__ = [
1068
+ "AsyncAdminClient",
1069
+ "AsyncBreakGlassClient",
1070
+ "AsyncObserverClient",
1071
+ ]