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.
- workhold_admin/__init__.py +140 -0
- workhold_admin/admin.py +573 -0
- workhold_admin/async_client.py +1071 -0
- workhold_admin/async_pagination.py +453 -0
- workhold_admin/break_glass.py +291 -0
- workhold_admin/models.py +1516 -0
- workhold_admin/observer.py +250 -0
- workhold_admin/pagination.py +478 -0
- workhold_admin-1.0.0.dist-info/METADATA +10 -0
- workhold_admin-1.0.0.dist-info/RECORD +11 -0
- workhold_admin-1.0.0.dist-info/WHEEL +4 -0
workhold_admin/models.py
ADDED
|
@@ -0,0 +1,1516 @@
|
|
|
1
|
+
"""Admin/observer domain models with OpenAPI bounds and tolerant parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from typing import Any, Final
|
|
11
|
+
|
|
12
|
+
from _workhold_client_core.models import Task
|
|
13
|
+
|
|
14
|
+
PAGE_LIMIT_MIN: Final[int] = 1
|
|
15
|
+
PAGE_LIMIT_MAX: Final[int] = 100
|
|
16
|
+
PAGE_LIMIT_DEFAULT: Final[int] = 50
|
|
17
|
+
CURSOR_MAX_LENGTH: Final[int] = 512
|
|
18
|
+
QUEUE_NAME_MAX_LENGTH: Final[int] = 128
|
|
19
|
+
IDEMPOTENCY_KEY_MIN_LENGTH: Final[int] = 1
|
|
20
|
+
IDEMPOTENCY_KEY_MAX_LENGTH: Final[int] = 256
|
|
21
|
+
REASON_MAX_LENGTH: Final[int] = 512
|
|
22
|
+
CONFIRMATION_TOKEN_MAX_LENGTH: Final[int] = 24576
|
|
23
|
+
BULK_CANDIDATE_MAX: Final[int] = 100
|
|
24
|
+
BULK_BATCH_MAX: Final[int] = 25
|
|
25
|
+
BULK_SAMPLE_MAX: Final[int] = 5
|
|
26
|
+
POLICY_VERSION_MIN: Final[int] = 1
|
|
27
|
+
CONFIG_VERSION_MIN: Final[int] = 1
|
|
28
|
+
RETRY_DELAY_SECONDS_MAX: Final[int] = 86400
|
|
29
|
+
MAX_ATTEMPTS_MIN: Final[int] = 1
|
|
30
|
+
BREAK_GLASS_REASON_MAX_LENGTH: Final[int] = 512
|
|
31
|
+
INCIDENT_REFERENCE_MAX_LENGTH: Final[int] = 128
|
|
32
|
+
PARTITION_NAME_MAX_LENGTH: Final[int] = 128
|
|
33
|
+
REPLAY_FACTOR_MIN: Final[float] = 1.0
|
|
34
|
+
REPLAY_FACTOR_MAX: Final[float] = 10.0
|
|
35
|
+
REPLAY_TTL_SECONDS_MIN: Final[int] = 1
|
|
36
|
+
REPLAY_TTL_SECONDS_MAX: Final[int] = 3600
|
|
37
|
+
REGISTRY_ENTRY_ID_MIN: Final[int] = 1
|
|
38
|
+
EXTEND_SECONDS_MIN: Final[int] = 1
|
|
39
|
+
EXTEND_SECONDS_MAX: Final[int] = 2592000
|
|
40
|
+
FAILURE_CODE_MAX_LENGTH: Final[int] = 128
|
|
41
|
+
|
|
42
|
+
_QUEUE_NAME_RE: Final[re.Pattern[str]] = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
|
|
43
|
+
_PARTITION_NAME_RE: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9_]*_[0-9]{8}$")
|
|
44
|
+
|
|
45
|
+
KNOWN_QUEUE_STATES: Final[frozenset[str]] = frozenset({"active", "paused", "draining"})
|
|
46
|
+
KNOWN_BACKOFF_STRATEGIES: Final[frozenset[str]] = frozenset({"fixed"})
|
|
47
|
+
KNOWN_ATTEMPT_OUTCOMES: Final[frozenset[str]] = frozenset(
|
|
48
|
+
{
|
|
49
|
+
"active",
|
|
50
|
+
"succeeded",
|
|
51
|
+
"retry_scheduled",
|
|
52
|
+
"dead_lettered",
|
|
53
|
+
"expired",
|
|
54
|
+
"cancelled",
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
KNOWN_MAINTENANCE_OUTCOMES: Final[frozenset[str]] = frozenset(
|
|
58
|
+
{"succeeded", "failed", "skipped_lock"}
|
|
59
|
+
)
|
|
60
|
+
KNOWN_STATS_FRESHNESS: Final[frozenset[str]] = frozenset(
|
|
61
|
+
{"fresh", "stale", "unavailable"}
|
|
62
|
+
)
|
|
63
|
+
KNOWN_STATS_AVAILABILITY: Final[frozenset[str]] = frozenset({"available", "unavailable"})
|
|
64
|
+
KNOWN_STATS_TELEMETRY_SOURCE: Final[frozenset[str]] = frozenset({"process_telemetry"})
|
|
65
|
+
|
|
66
|
+
KNOWN_AUDIT_OPERATIONS: Final[frozenset[str]] = frozenset(
|
|
67
|
+
{
|
|
68
|
+
"create_queue",
|
|
69
|
+
"create_policy",
|
|
70
|
+
"activate_policy",
|
|
71
|
+
"set_queue_state",
|
|
72
|
+
"run_maintenance",
|
|
73
|
+
"replay_dead_letter",
|
|
74
|
+
"bulk_replay",
|
|
75
|
+
"bulk_cancel",
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
KNOWN_BULK_OPERATIONS: Final[frozenset[str]] = frozenset({"bulk_replay", "bulk_cancel"})
|
|
79
|
+
_FORBIDDEN_BULK_FILTER_KEYS: Final[frozenset[str]] = frozenset(
|
|
80
|
+
{
|
|
81
|
+
"q",
|
|
82
|
+
"query",
|
|
83
|
+
"search",
|
|
84
|
+
"text",
|
|
85
|
+
"payload",
|
|
86
|
+
"payload_field",
|
|
87
|
+
"business_key",
|
|
88
|
+
"worker_id",
|
|
89
|
+
"sql",
|
|
90
|
+
"offset",
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
_ALLOWED_BULK_FILTER_KEYS: Final[frozenset[str]] = frozenset(
|
|
94
|
+
{
|
|
95
|
+
"from",
|
|
96
|
+
"to",
|
|
97
|
+
"failure_code",
|
|
98
|
+
"state",
|
|
99
|
+
"states",
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
_FORBIDDEN_BREAK_GLASS_SECRET_KEY_RE: Final[re.Pattern[str]] = re.compile(
|
|
103
|
+
r"claim.*token|lease_token",
|
|
104
|
+
re.IGNORECASE,
|
|
105
|
+
)
|
|
106
|
+
KNOWN_BULK_ITEM_OUTCOMES: Final[frozenset[str]] = frozenset(
|
|
107
|
+
{
|
|
108
|
+
"succeeded",
|
|
109
|
+
"replayed",
|
|
110
|
+
"skipped",
|
|
111
|
+
"failed",
|
|
112
|
+
"cancelled",
|
|
113
|
+
"cancel_requested",
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True, slots=True)
|
|
119
|
+
class QueueState:
|
|
120
|
+
value: str
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def is_unknown(self) -> bool:
|
|
124
|
+
return self.value not in KNOWN_QUEUE_STATES
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def parse(cls, raw: object) -> QueueState:
|
|
128
|
+
if not isinstance(raw, str) or not raw:
|
|
129
|
+
raise ValueError("queue.state must be a non-empty string")
|
|
130
|
+
return cls(raw)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True, slots=True)
|
|
134
|
+
class BackoffStrategy:
|
|
135
|
+
value: str
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def is_unknown(self) -> bool:
|
|
139
|
+
return self.value not in KNOWN_BACKOFF_STRATEGIES
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def parse(cls, raw: object) -> BackoffStrategy:
|
|
143
|
+
if not isinstance(raw, str) or not raw:
|
|
144
|
+
raise ValueError("backoff_strategy must be a non-empty string")
|
|
145
|
+
return cls(raw)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True, slots=True)
|
|
149
|
+
class AttemptOutcome:
|
|
150
|
+
value: str
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def is_unknown(self) -> bool:
|
|
154
|
+
return self.value not in KNOWN_ATTEMPT_OUTCOMES
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def parse(cls, raw: object) -> AttemptOutcome:
|
|
158
|
+
if not isinstance(raw, str) or not raw:
|
|
159
|
+
raise ValueError("attempt.outcome must be a non-empty string")
|
|
160
|
+
return cls(raw)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(frozen=True, slots=True)
|
|
164
|
+
class AuditOperation:
|
|
165
|
+
value: str
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def is_unknown(self) -> bool:
|
|
169
|
+
return self.value not in KNOWN_AUDIT_OPERATIONS
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
def parse(cls, raw: object) -> AuditOperation:
|
|
173
|
+
if not isinstance(raw, str) or not raw:
|
|
174
|
+
raise ValueError("audit.operation must be a non-empty string")
|
|
175
|
+
return cls(raw)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(frozen=True, slots=True)
|
|
179
|
+
class MaintenanceOutcome:
|
|
180
|
+
value: str
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def is_unknown(self) -> bool:
|
|
184
|
+
return self.value not in KNOWN_MAINTENANCE_OUTCOMES
|
|
185
|
+
|
|
186
|
+
@classmethod
|
|
187
|
+
def parse(cls, raw: object) -> MaintenanceOutcome:
|
|
188
|
+
if not isinstance(raw, str) or not raw:
|
|
189
|
+
raise ValueError("maintenance.outcome must be a non-empty string")
|
|
190
|
+
return cls(raw)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True, slots=True)
|
|
194
|
+
class StatsFreshness:
|
|
195
|
+
value: str
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def is_unknown(self) -> bool:
|
|
199
|
+
return self.value not in KNOWN_STATS_FRESHNESS
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def parse(cls, raw: object) -> StatsFreshness:
|
|
203
|
+
if not isinstance(raw, str) or not raw:
|
|
204
|
+
raise ValueError("freshness must be a non-empty string")
|
|
205
|
+
return cls(raw)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass(frozen=True, slots=True)
|
|
209
|
+
class StatsAvailability:
|
|
210
|
+
value: str
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def is_unknown(self) -> bool:
|
|
214
|
+
return self.value not in KNOWN_STATS_AVAILABILITY
|
|
215
|
+
|
|
216
|
+
@classmethod
|
|
217
|
+
def parse(cls, raw: object) -> StatsAvailability:
|
|
218
|
+
if not isinstance(raw, str) or not raw:
|
|
219
|
+
raise ValueError("availability must be a non-empty string")
|
|
220
|
+
return cls(raw)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass(frozen=True, slots=True)
|
|
224
|
+
class StatsTelemetrySource:
|
|
225
|
+
value: str
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def is_unknown(self) -> bool:
|
|
229
|
+
return self.value not in KNOWN_STATS_TELEMETRY_SOURCE
|
|
230
|
+
|
|
231
|
+
@classmethod
|
|
232
|
+
def parse(cls, raw: object) -> StatsTelemetrySource:
|
|
233
|
+
if not isinstance(raw, str) or not raw:
|
|
234
|
+
raise ValueError("telemetry.source must be a non-empty string")
|
|
235
|
+
return cls(raw)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass(frozen=True, slots=True)
|
|
239
|
+
class ConfigVersion:
|
|
240
|
+
"""Optimistic concurrency pin for admin mutations (OpenAPI ``expected_config_version``)."""
|
|
241
|
+
|
|
242
|
+
value: int
|
|
243
|
+
|
|
244
|
+
def to_wire(self) -> int:
|
|
245
|
+
return self.value
|
|
246
|
+
|
|
247
|
+
@classmethod
|
|
248
|
+
def parse(cls, raw: object) -> ConfigVersion:
|
|
249
|
+
return cls(value=_require_config_version(raw, "expected_config_version"))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass(frozen=True, slots=True)
|
|
253
|
+
class PolicyVersion:
|
|
254
|
+
"""Immutable retry-policy version number (OpenAPI path ``policy_version``)."""
|
|
255
|
+
|
|
256
|
+
value: int
|
|
257
|
+
|
|
258
|
+
def to_wire(self) -> int:
|
|
259
|
+
return self.value
|
|
260
|
+
|
|
261
|
+
@classmethod
|
|
262
|
+
def parse(cls, raw: object) -> PolicyVersion:
|
|
263
|
+
return cls(value=_require_policy_version(raw, "policy_version"))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@dataclass(frozen=True, slots=True)
|
|
267
|
+
class RetryPolicyDraft:
|
|
268
|
+
"""Retry-policy content for create queue / create policy (OpenAPI draft schemas)."""
|
|
269
|
+
|
|
270
|
+
enabled: bool
|
|
271
|
+
max_attempts: int
|
|
272
|
+
backoff_strategy: BackoffStrategy
|
|
273
|
+
retry_delay_seconds: int
|
|
274
|
+
|
|
275
|
+
def to_wire(self) -> dict[str, Any]:
|
|
276
|
+
if self.backoff_strategy.value != "fixed":
|
|
277
|
+
raise ValueError("backoff_strategy must be fixed for draft mutations")
|
|
278
|
+
return {
|
|
279
|
+
"enabled": self.enabled,
|
|
280
|
+
"max_attempts": _require_max_attempts(self.max_attempts),
|
|
281
|
+
"backoff_strategy": self.backoff_strategy.value,
|
|
282
|
+
"retry_delay_seconds": _require_retry_delay_seconds(self.retry_delay_seconds),
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
@classmethod
|
|
286
|
+
def parse(cls, raw: object) -> RetryPolicyDraft:
|
|
287
|
+
if not isinstance(raw, Mapping):
|
|
288
|
+
raise ValueError("retry policy draft must be an object")
|
|
289
|
+
required = (
|
|
290
|
+
"enabled",
|
|
291
|
+
"max_attempts",
|
|
292
|
+
"backoff_strategy",
|
|
293
|
+
"retry_delay_seconds",
|
|
294
|
+
)
|
|
295
|
+
missing = [key for key in required if key not in raw]
|
|
296
|
+
if missing:
|
|
297
|
+
raise ValueError(f"retry policy draft missing fields: {missing}")
|
|
298
|
+
strategy = BackoffStrategy.parse(raw["backoff_strategy"])
|
|
299
|
+
if strategy.value != "fixed":
|
|
300
|
+
raise ValueError("backoff_strategy must be fixed for draft mutations")
|
|
301
|
+
return cls(
|
|
302
|
+
enabled=_require_bool(raw["enabled"], "enabled"),
|
|
303
|
+
max_attempts=_require_max_attempts(raw["max_attempts"]),
|
|
304
|
+
backoff_strategy=strategy,
|
|
305
|
+
retry_delay_seconds=_require_retry_delay_seconds(raw["retry_delay_seconds"]),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@dataclass(frozen=True, slots=True)
|
|
310
|
+
class RetryPolicy:
|
|
311
|
+
version: int
|
|
312
|
+
enabled: bool
|
|
313
|
+
max_attempts: int
|
|
314
|
+
backoff_strategy: BackoffStrategy
|
|
315
|
+
retry_delay_seconds: int
|
|
316
|
+
created_at: str
|
|
317
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
318
|
+
|
|
319
|
+
@classmethod
|
|
320
|
+
def parse(cls, raw: object) -> RetryPolicy:
|
|
321
|
+
if not isinstance(raw, Mapping):
|
|
322
|
+
raise ValueError("active_policy must be an object")
|
|
323
|
+
required = (
|
|
324
|
+
"version",
|
|
325
|
+
"enabled",
|
|
326
|
+
"max_attempts",
|
|
327
|
+
"backoff_strategy",
|
|
328
|
+
"retry_delay_seconds",
|
|
329
|
+
"created_at",
|
|
330
|
+
)
|
|
331
|
+
missing = [key for key in required if key not in raw]
|
|
332
|
+
if missing:
|
|
333
|
+
raise ValueError(f"active_policy missing fields: {missing}")
|
|
334
|
+
known = set(required)
|
|
335
|
+
extra = {k: v for k, v in raw.items() if k not in known}
|
|
336
|
+
return cls(
|
|
337
|
+
version=_require_int(raw["version"], "active_policy.version"),
|
|
338
|
+
enabled=_require_bool(raw["enabled"], "active_policy.enabled"),
|
|
339
|
+
max_attempts=_require_int(raw["max_attempts"], "active_policy.max_attempts"),
|
|
340
|
+
backoff_strategy=BackoffStrategy.parse(raw["backoff_strategy"]),
|
|
341
|
+
retry_delay_seconds=_require_int(
|
|
342
|
+
raw["retry_delay_seconds"], "active_policy.retry_delay_seconds"
|
|
343
|
+
),
|
|
344
|
+
created_at=_require_str(raw["created_at"], "active_policy.created_at"),
|
|
345
|
+
extra=dict(extra),
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@dataclass(frozen=True, slots=True)
|
|
350
|
+
class Queue:
|
|
351
|
+
queue_id: str
|
|
352
|
+
name: str
|
|
353
|
+
state: QueueState
|
|
354
|
+
config_version: int
|
|
355
|
+
active_policy: RetryPolicy
|
|
356
|
+
created_at: str
|
|
357
|
+
updated_at: str
|
|
358
|
+
active_depth: int | None = None
|
|
359
|
+
ready_count: int | None = None
|
|
360
|
+
delayed_count: int | None = None
|
|
361
|
+
leased_count: int | None = None
|
|
362
|
+
drain_complete: bool | None = None
|
|
363
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
364
|
+
|
|
365
|
+
@classmethod
|
|
366
|
+
def parse(cls, raw: object) -> Queue:
|
|
367
|
+
if not isinstance(raw, Mapping):
|
|
368
|
+
raise ValueError("queue must be an object")
|
|
369
|
+
required = (
|
|
370
|
+
"queue_id",
|
|
371
|
+
"name",
|
|
372
|
+
"state",
|
|
373
|
+
"config_version",
|
|
374
|
+
"active_policy",
|
|
375
|
+
"created_at",
|
|
376
|
+
"updated_at",
|
|
377
|
+
)
|
|
378
|
+
missing = [key for key in required if key not in raw]
|
|
379
|
+
if missing:
|
|
380
|
+
raise ValueError(f"queue missing required fields: {missing}")
|
|
381
|
+
known = set(required) | {
|
|
382
|
+
"active_depth",
|
|
383
|
+
"ready_count",
|
|
384
|
+
"delayed_count",
|
|
385
|
+
"leased_count",
|
|
386
|
+
"drain_complete",
|
|
387
|
+
}
|
|
388
|
+
extra = {k: v for k, v in raw.items() if k not in known}
|
|
389
|
+
return cls(
|
|
390
|
+
queue_id=_require_str(raw["queue_id"], "queue_id"),
|
|
391
|
+
name=_require_str(raw["name"], "name"),
|
|
392
|
+
state=QueueState.parse(raw["state"]),
|
|
393
|
+
config_version=_require_int(raw["config_version"], "config_version"),
|
|
394
|
+
active_policy=RetryPolicy.parse(raw["active_policy"]),
|
|
395
|
+
created_at=_require_str(raw["created_at"], "created_at"),
|
|
396
|
+
updated_at=_require_str(raw["updated_at"], "updated_at"),
|
|
397
|
+
active_depth=_optional_int(raw.get("active_depth"), "active_depth"),
|
|
398
|
+
ready_count=_optional_int(raw.get("ready_count"), "ready_count"),
|
|
399
|
+
delayed_count=_optional_int(raw.get("delayed_count"), "delayed_count"),
|
|
400
|
+
leased_count=_optional_int(raw.get("leased_count"), "leased_count"),
|
|
401
|
+
drain_complete=_optional_bool(raw.get("drain_complete"), "drain_complete"),
|
|
402
|
+
extra=dict(extra),
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@dataclass(frozen=True, slots=True)
|
|
407
|
+
class Attempt:
|
|
408
|
+
attempt_id: int
|
|
409
|
+
task_id: str
|
|
410
|
+
claim_id: str
|
|
411
|
+
generation: int
|
|
412
|
+
claimed_at: str
|
|
413
|
+
worker_id: str
|
|
414
|
+
lease_expires_at: str
|
|
415
|
+
outcome: AttemptOutcome
|
|
416
|
+
ended_at: str | None = None
|
|
417
|
+
failure_code: str | None = None
|
|
418
|
+
failure_detail: str | None = None
|
|
419
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
420
|
+
|
|
421
|
+
@classmethod
|
|
422
|
+
def parse(cls, raw: object) -> Attempt:
|
|
423
|
+
if not isinstance(raw, Mapping):
|
|
424
|
+
raise ValueError("attempt must be an object")
|
|
425
|
+
required = (
|
|
426
|
+
"attempt_id",
|
|
427
|
+
"task_id",
|
|
428
|
+
"claim_id",
|
|
429
|
+
"generation",
|
|
430
|
+
"claimed_at",
|
|
431
|
+
"worker_id",
|
|
432
|
+
"lease_expires_at",
|
|
433
|
+
"outcome",
|
|
434
|
+
)
|
|
435
|
+
missing = [key for key in required if key not in raw]
|
|
436
|
+
if missing:
|
|
437
|
+
raise ValueError(f"attempt missing required fields: {missing}")
|
|
438
|
+
known = set(required) | {"ended_at", "failure_code", "failure_detail"}
|
|
439
|
+
extra = {k: v for k, v in raw.items() if k not in known}
|
|
440
|
+
return cls(
|
|
441
|
+
attempt_id=_require_int(raw["attempt_id"], "attempt_id"),
|
|
442
|
+
task_id=_require_str(raw["task_id"], "task_id"),
|
|
443
|
+
claim_id=_require_str(raw["claim_id"], "claim_id"),
|
|
444
|
+
generation=_require_int(raw["generation"], "generation"),
|
|
445
|
+
claimed_at=_require_str(raw["claimed_at"], "claimed_at"),
|
|
446
|
+
worker_id=_require_str(raw["worker_id"], "worker_id"),
|
|
447
|
+
lease_expires_at=_require_str(raw["lease_expires_at"], "lease_expires_at"),
|
|
448
|
+
outcome=AttemptOutcome.parse(raw["outcome"]),
|
|
449
|
+
ended_at=_optional_str(raw.get("ended_at"), "ended_at"),
|
|
450
|
+
failure_code=_optional_str(raw.get("failure_code"), "failure_code"),
|
|
451
|
+
failure_detail=_optional_str(raw.get("failure_detail"), "failure_detail"),
|
|
452
|
+
extra=dict(extra),
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
@dataclass(frozen=True, slots=True)
|
|
457
|
+
class QueuePage:
|
|
458
|
+
items: tuple[Queue, ...]
|
|
459
|
+
next_cursor: str | None = None
|
|
460
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
461
|
+
|
|
462
|
+
@classmethod
|
|
463
|
+
def parse(cls, raw: object) -> QueuePage:
|
|
464
|
+
if not isinstance(raw, Mapping):
|
|
465
|
+
raise ValueError("queue page must be an object")
|
|
466
|
+
if "items" not in raw:
|
|
467
|
+
raise ValueError("queue page missing items")
|
|
468
|
+
items_raw = raw["items"]
|
|
469
|
+
if not isinstance(items_raw, list):
|
|
470
|
+
raise ValueError("queue page items must be an array")
|
|
471
|
+
known = {"items", "next_cursor"}
|
|
472
|
+
extra = {k: v for k, v in raw.items() if k not in known}
|
|
473
|
+
return cls(
|
|
474
|
+
items=tuple(Queue.parse(item) for item in items_raw),
|
|
475
|
+
next_cursor=_optional_str(raw.get("next_cursor"), "next_cursor"),
|
|
476
|
+
extra=dict(extra),
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
@dataclass(frozen=True, slots=True)
|
|
481
|
+
class AdminMutationResult:
|
|
482
|
+
queue: Queue
|
|
483
|
+
replayed: bool
|
|
484
|
+
admin_replay_expires_at: str
|
|
485
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
486
|
+
|
|
487
|
+
@classmethod
|
|
488
|
+
def parse(cls, raw: object) -> AdminMutationResult:
|
|
489
|
+
if not isinstance(raw, Mapping):
|
|
490
|
+
raise ValueError("admin mutation result must be an object")
|
|
491
|
+
required = ("queue", "replayed", "admin_replay_expires_at")
|
|
492
|
+
missing = [key for key in required if key not in raw]
|
|
493
|
+
if missing:
|
|
494
|
+
raise ValueError(f"admin mutation result missing fields: {missing}")
|
|
495
|
+
known = set(required)
|
|
496
|
+
extra = {k: v for k, v in raw.items() if k not in known}
|
|
497
|
+
return cls(
|
|
498
|
+
queue=Queue.parse(raw["queue"]),
|
|
499
|
+
replayed=_require_bool(raw["replayed"], "replayed"),
|
|
500
|
+
admin_replay_expires_at=_require_str(
|
|
501
|
+
raw["admin_replay_expires_at"], "admin_replay_expires_at"
|
|
502
|
+
),
|
|
503
|
+
extra=dict(extra),
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
@dataclass(frozen=True, slots=True)
|
|
508
|
+
class AttemptPage:
|
|
509
|
+
items: tuple[Attempt, ...]
|
|
510
|
+
next_cursor: str | None = None
|
|
511
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
512
|
+
|
|
513
|
+
@classmethod
|
|
514
|
+
def parse(cls, raw: object) -> AttemptPage:
|
|
515
|
+
if not isinstance(raw, Mapping):
|
|
516
|
+
raise ValueError("attempt page must be an object")
|
|
517
|
+
if "items" not in raw:
|
|
518
|
+
raise ValueError("attempt page missing items")
|
|
519
|
+
items_raw = raw["items"]
|
|
520
|
+
if not isinstance(items_raw, list):
|
|
521
|
+
raise ValueError("attempt page items must be an array")
|
|
522
|
+
known = {"items", "next_cursor"}
|
|
523
|
+
extra = {k: v for k, v in raw.items() if k not in known}
|
|
524
|
+
return cls(
|
|
525
|
+
items=tuple(Attempt.parse(item) for item in items_raw),
|
|
526
|
+
next_cursor=_optional_str(raw.get("next_cursor"), "next_cursor"),
|
|
527
|
+
extra=dict(extra),
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
@dataclass(frozen=True, slots=True)
|
|
532
|
+
class TaskPage:
|
|
533
|
+
items: tuple[Task, ...]
|
|
534
|
+
next_cursor: str | None = None
|
|
535
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
536
|
+
|
|
537
|
+
@classmethod
|
|
538
|
+
def parse(cls, raw: object) -> TaskPage:
|
|
539
|
+
if not isinstance(raw, Mapping):
|
|
540
|
+
raise ValueError("task page must be an object")
|
|
541
|
+
if "items" not in raw:
|
|
542
|
+
raise ValueError("task page missing items")
|
|
543
|
+
items_raw = raw["items"]
|
|
544
|
+
if not isinstance(items_raw, list):
|
|
545
|
+
raise ValueError("task page items must be an array")
|
|
546
|
+
known = {"items", "next_cursor"}
|
|
547
|
+
extra = {k: v for k, v in raw.items() if k not in known}
|
|
548
|
+
return cls(
|
|
549
|
+
items=tuple(Task.parse(item) for item in items_raw),
|
|
550
|
+
next_cursor=_optional_str(raw.get("next_cursor"), "next_cursor"),
|
|
551
|
+
extra=dict(extra),
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@dataclass(frozen=True, slots=True)
|
|
556
|
+
class DeadLetterPage:
|
|
557
|
+
items: tuple[Task, ...]
|
|
558
|
+
next_cursor: str | None = None
|
|
559
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
560
|
+
|
|
561
|
+
@classmethod
|
|
562
|
+
def parse(cls, raw: object) -> DeadLetterPage:
|
|
563
|
+
if not isinstance(raw, Mapping):
|
|
564
|
+
raise ValueError("dead letter page must be an object")
|
|
565
|
+
if "items" not in raw:
|
|
566
|
+
raise ValueError("dead letter page missing items")
|
|
567
|
+
items_raw = raw["items"]
|
|
568
|
+
if not isinstance(items_raw, list):
|
|
569
|
+
raise ValueError("dead letter page items must be an array")
|
|
570
|
+
known = {"items", "next_cursor"}
|
|
571
|
+
extra = {k: v for k, v in raw.items() if k not in known}
|
|
572
|
+
return cls(
|
|
573
|
+
items=tuple(Task.parse(item) for item in items_raw),
|
|
574
|
+
next_cursor=_optional_str(raw.get("next_cursor"), "next_cursor"),
|
|
575
|
+
extra=dict(extra),
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
@dataclass(frozen=True, slots=True)
|
|
580
|
+
class AuditRecord:
|
|
581
|
+
audit_id: int
|
|
582
|
+
audit_at: str
|
|
583
|
+
actor_id: str
|
|
584
|
+
operation: AuditOperation
|
|
585
|
+
request_id: str
|
|
586
|
+
queue_id: str | None = None
|
|
587
|
+
previous_config_version: int | None = None
|
|
588
|
+
new_config_version: int | None = None
|
|
589
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
590
|
+
|
|
591
|
+
@classmethod
|
|
592
|
+
def parse(cls, raw: object) -> AuditRecord:
|
|
593
|
+
if not isinstance(raw, Mapping):
|
|
594
|
+
raise ValueError("audit record must be an object")
|
|
595
|
+
required = ("audit_id", "audit_at", "actor_id", "operation", "request_id")
|
|
596
|
+
missing = [key for key in required if key not in raw]
|
|
597
|
+
if missing:
|
|
598
|
+
raise ValueError(f"audit record missing required fields: {missing}")
|
|
599
|
+
known = set(required) | {
|
|
600
|
+
"queue_id",
|
|
601
|
+
"previous_config_version",
|
|
602
|
+
"new_config_version",
|
|
603
|
+
}
|
|
604
|
+
extra = {k: v for k, v in raw.items() if k not in known}
|
|
605
|
+
return cls(
|
|
606
|
+
audit_id=_require_int(raw["audit_id"], "audit_id"),
|
|
607
|
+
audit_at=_require_str(raw["audit_at"], "audit_at"),
|
|
608
|
+
actor_id=_require_str(raw["actor_id"], "actor_id"),
|
|
609
|
+
operation=AuditOperation.parse(raw["operation"]),
|
|
610
|
+
request_id=_require_str(raw["request_id"], "request_id"),
|
|
611
|
+
queue_id=_optional_str(raw.get("queue_id"), "queue_id"),
|
|
612
|
+
previous_config_version=_optional_int(
|
|
613
|
+
raw.get("previous_config_version"), "previous_config_version"
|
|
614
|
+
),
|
|
615
|
+
new_config_version=_optional_int(
|
|
616
|
+
raw.get("new_config_version"), "new_config_version"
|
|
617
|
+
),
|
|
618
|
+
extra=dict(extra),
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
@dataclass(frozen=True, slots=True)
|
|
623
|
+
class AuditPage:
|
|
624
|
+
items: tuple[AuditRecord, ...]
|
|
625
|
+
next_cursor: str | None = None
|
|
626
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
627
|
+
|
|
628
|
+
@classmethod
|
|
629
|
+
def parse(cls, raw: object) -> AuditPage:
|
|
630
|
+
if not isinstance(raw, Mapping):
|
|
631
|
+
raise ValueError("audit page must be an object")
|
|
632
|
+
if "items" not in raw:
|
|
633
|
+
raise ValueError("audit page missing items")
|
|
634
|
+
items_raw = raw["items"]
|
|
635
|
+
if not isinstance(items_raw, list):
|
|
636
|
+
raise ValueError("audit page items must be an array")
|
|
637
|
+
known = {"items", "next_cursor"}
|
|
638
|
+
extra = {k: v for k, v in raw.items() if k not in known}
|
|
639
|
+
return cls(
|
|
640
|
+
items=tuple(AuditRecord.parse(item) for item in items_raw),
|
|
641
|
+
next_cursor=_optional_str(raw.get("next_cursor"), "next_cursor"),
|
|
642
|
+
extra=dict(extra),
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
@dataclass(frozen=True, slots=True)
|
|
647
|
+
class MaintenanceStatus:
|
|
648
|
+
updated_at: str
|
|
649
|
+
last_started_at: str | None = None
|
|
650
|
+
last_succeeded_at: str | None = None
|
|
651
|
+
premade_through: str | None = None
|
|
652
|
+
retained_from: str | None = None
|
|
653
|
+
last_error_code: str | None = None
|
|
654
|
+
outcome: MaintenanceOutcome | None = None
|
|
655
|
+
maintenance_run_id: str | None = None
|
|
656
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
657
|
+
|
|
658
|
+
@classmethod
|
|
659
|
+
def parse(cls, raw: object) -> MaintenanceStatus:
|
|
660
|
+
if not isinstance(raw, Mapping):
|
|
661
|
+
raise ValueError("maintenance status must be an object")
|
|
662
|
+
if "updated_at" not in raw:
|
|
663
|
+
raise ValueError("maintenance status missing updated_at")
|
|
664
|
+
known = {
|
|
665
|
+
"updated_at",
|
|
666
|
+
"last_started_at",
|
|
667
|
+
"last_succeeded_at",
|
|
668
|
+
"premade_through",
|
|
669
|
+
"retained_from",
|
|
670
|
+
"last_error_code",
|
|
671
|
+
"outcome",
|
|
672
|
+
"maintenance_run_id",
|
|
673
|
+
}
|
|
674
|
+
extra = {k: v for k, v in raw.items() if k not in known}
|
|
675
|
+
outcome_raw = raw.get("outcome")
|
|
676
|
+
return cls(
|
|
677
|
+
updated_at=_require_str(raw["updated_at"], "updated_at"),
|
|
678
|
+
last_started_at=_optional_str(raw.get("last_started_at"), "last_started_at"),
|
|
679
|
+
last_succeeded_at=_optional_str(
|
|
680
|
+
raw.get("last_succeeded_at"), "last_succeeded_at"
|
|
681
|
+
),
|
|
682
|
+
premade_through=_optional_str(raw.get("premade_through"), "premade_through"),
|
|
683
|
+
retained_from=_optional_str(raw.get("retained_from"), "retained_from"),
|
|
684
|
+
last_error_code=_optional_str(raw.get("last_error_code"), "last_error_code"),
|
|
685
|
+
outcome=(
|
|
686
|
+
None if outcome_raw is None else MaintenanceOutcome.parse(outcome_raw)
|
|
687
|
+
),
|
|
688
|
+
maintenance_run_id=_optional_str(
|
|
689
|
+
raw.get("maintenance_run_id"), "maintenance_run_id"
|
|
690
|
+
),
|
|
691
|
+
extra=dict(extra),
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
@dataclass(frozen=True, slots=True)
|
|
696
|
+
class MaintenanceRunResult:
|
|
697
|
+
status: MaintenanceStatus
|
|
698
|
+
replayed: bool
|
|
699
|
+
admin_replay_expires_at: str
|
|
700
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
701
|
+
|
|
702
|
+
@classmethod
|
|
703
|
+
def parse(cls, raw: object) -> MaintenanceRunResult:
|
|
704
|
+
if not isinstance(raw, Mapping):
|
|
705
|
+
raise ValueError("maintenance run result must be an object")
|
|
706
|
+
required = ("status", "replayed", "admin_replay_expires_at")
|
|
707
|
+
missing = [key for key in required if key not in raw]
|
|
708
|
+
if missing:
|
|
709
|
+
raise ValueError(f"maintenance run result missing fields: {missing}")
|
|
710
|
+
known = set(required)
|
|
711
|
+
extra = {k: v for k, v in raw.items() if k not in known}
|
|
712
|
+
return cls(
|
|
713
|
+
status=MaintenanceStatus.parse(raw["status"]),
|
|
714
|
+
replayed=_require_bool(raw["replayed"], "replayed"),
|
|
715
|
+
admin_replay_expires_at=_require_str(
|
|
716
|
+
raw["admin_replay_expires_at"], "admin_replay_expires_at"
|
|
717
|
+
),
|
|
718
|
+
extra=dict(extra),
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
@dataclass(frozen=True, slots=True)
|
|
723
|
+
class StatsQueueAggregate:
|
|
724
|
+
name: str
|
|
725
|
+
ready_depth: int
|
|
726
|
+
delayed_depth: int
|
|
727
|
+
leased_depth: int
|
|
728
|
+
as_of: str
|
|
729
|
+
freshness: StatsFreshness
|
|
730
|
+
oldest_ready_age_seconds: float | None = None
|
|
731
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
732
|
+
|
|
733
|
+
@classmethod
|
|
734
|
+
def parse(cls, raw: object) -> StatsQueueAggregate:
|
|
735
|
+
if not isinstance(raw, Mapping):
|
|
736
|
+
raise ValueError("stats queue aggregate must be an object")
|
|
737
|
+
required = (
|
|
738
|
+
"name",
|
|
739
|
+
"ready_depth",
|
|
740
|
+
"delayed_depth",
|
|
741
|
+
"leased_depth",
|
|
742
|
+
"as_of",
|
|
743
|
+
"freshness",
|
|
744
|
+
)
|
|
745
|
+
missing = [key for key in required if key not in raw]
|
|
746
|
+
if missing:
|
|
747
|
+
raise ValueError(f"stats queue aggregate missing fields: {missing}")
|
|
748
|
+
known = set(required) | {"oldest_ready_age_seconds"}
|
|
749
|
+
extra = {k: v for k, v in raw.items() if k not in known}
|
|
750
|
+
age = raw.get("oldest_ready_age_seconds")
|
|
751
|
+
if age is not None and not isinstance(age, (int, float)):
|
|
752
|
+
raise ValueError("oldest_ready_age_seconds must be a number or null")
|
|
753
|
+
return cls(
|
|
754
|
+
name=_require_str(raw["name"], "name"),
|
|
755
|
+
ready_depth=_require_int(raw["ready_depth"], "ready_depth"),
|
|
756
|
+
delayed_depth=_require_int(raw["delayed_depth"], "delayed_depth"),
|
|
757
|
+
leased_depth=_require_int(raw["leased_depth"], "leased_depth"),
|
|
758
|
+
as_of=_require_str(raw["as_of"], "as_of"),
|
|
759
|
+
freshness=StatsFreshness.parse(raw["freshness"]),
|
|
760
|
+
oldest_ready_age_seconds=float(age) if age is not None else None,
|
|
761
|
+
extra=dict(extra),
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
@dataclass(frozen=True, slots=True)
|
|
766
|
+
class StatsTelemetrySummary:
|
|
767
|
+
availability: StatsAvailability
|
|
768
|
+
source: StatsTelemetrySource
|
|
769
|
+
total: int | None = None
|
|
770
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
771
|
+
|
|
772
|
+
@classmethod
|
|
773
|
+
def parse(cls, raw: object) -> StatsTelemetrySummary:
|
|
774
|
+
if not isinstance(raw, Mapping):
|
|
775
|
+
raise ValueError("stats telemetry summary must be an object")
|
|
776
|
+
if "availability" not in raw or "source" not in raw:
|
|
777
|
+
raise ValueError("stats telemetry summary requires availability and source")
|
|
778
|
+
known = {"availability", "source", "total"}
|
|
779
|
+
extra = {k: v for k, v in raw.items() if k not in known}
|
|
780
|
+
total = raw.get("total")
|
|
781
|
+
if total is not None:
|
|
782
|
+
total = _require_int(total, "total")
|
|
783
|
+
return cls(
|
|
784
|
+
availability=StatsAvailability.parse(raw["availability"]),
|
|
785
|
+
source=StatsTelemetrySource.parse(raw["source"]),
|
|
786
|
+
total=total,
|
|
787
|
+
extra=dict(extra),
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
@dataclass(frozen=True, slots=True)
|
|
792
|
+
class StatsMaintenanceSummary:
|
|
793
|
+
availability: StatsAvailability
|
|
794
|
+
last_started_at: str | None = None
|
|
795
|
+
last_succeeded_at: str | None = None
|
|
796
|
+
premade_through: str | None = None
|
|
797
|
+
retained_from: str | None = None
|
|
798
|
+
last_error_code: str | None = None
|
|
799
|
+
updated_at: str | None = None
|
|
800
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
801
|
+
|
|
802
|
+
@classmethod
|
|
803
|
+
def parse(cls, raw: object) -> StatsMaintenanceSummary:
|
|
804
|
+
if not isinstance(raw, Mapping):
|
|
805
|
+
raise ValueError("stats maintenance summary must be an object")
|
|
806
|
+
if "availability" not in raw:
|
|
807
|
+
raise ValueError("stats maintenance summary missing availability")
|
|
808
|
+
known = {
|
|
809
|
+
"availability",
|
|
810
|
+
"last_started_at",
|
|
811
|
+
"last_succeeded_at",
|
|
812
|
+
"premade_through",
|
|
813
|
+
"retained_from",
|
|
814
|
+
"last_error_code",
|
|
815
|
+
"updated_at",
|
|
816
|
+
}
|
|
817
|
+
extra = {k: v for k, v in raw.items() if k not in known}
|
|
818
|
+
return cls(
|
|
819
|
+
availability=StatsAvailability.parse(raw["availability"]),
|
|
820
|
+
last_started_at=_optional_str(raw.get("last_started_at"), "last_started_at"),
|
|
821
|
+
last_succeeded_at=_optional_str(
|
|
822
|
+
raw.get("last_succeeded_at"), "last_succeeded_at"
|
|
823
|
+
),
|
|
824
|
+
premade_through=_optional_str(raw.get("premade_through"), "premade_through"),
|
|
825
|
+
retained_from=_optional_str(raw.get("retained_from"), "retained_from"),
|
|
826
|
+
last_error_code=_optional_str(raw.get("last_error_code"), "last_error_code"),
|
|
827
|
+
updated_at=_optional_str(raw.get("updated_at"), "updated_at"),
|
|
828
|
+
extra=dict(extra),
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
@dataclass(frozen=True, slots=True)
|
|
833
|
+
class StatsSnapshot:
|
|
834
|
+
as_of: str
|
|
835
|
+
generated_at: str
|
|
836
|
+
age_seconds: float
|
|
837
|
+
freshness: StatsFreshness
|
|
838
|
+
queues: tuple[StatsQueueAggregate, ...]
|
|
839
|
+
retry: StatsTelemetrySummary
|
|
840
|
+
dead_letter: StatsTelemetrySummary
|
|
841
|
+
maintenance: StatsMaintenanceSummary
|
|
842
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
843
|
+
|
|
844
|
+
@classmethod
|
|
845
|
+
def parse(cls, raw: object) -> StatsSnapshot:
|
|
846
|
+
if not isinstance(raw, Mapping):
|
|
847
|
+
raise ValueError("stats snapshot must be an object")
|
|
848
|
+
required = (
|
|
849
|
+
"as_of",
|
|
850
|
+
"generated_at",
|
|
851
|
+
"age_seconds",
|
|
852
|
+
"freshness",
|
|
853
|
+
"queues",
|
|
854
|
+
"retry",
|
|
855
|
+
"dead_letter",
|
|
856
|
+
"maintenance",
|
|
857
|
+
)
|
|
858
|
+
missing = [key for key in required if key not in raw]
|
|
859
|
+
if missing:
|
|
860
|
+
raise ValueError(f"stats snapshot missing fields: {missing}")
|
|
861
|
+
queues_raw = raw["queues"]
|
|
862
|
+
if not isinstance(queues_raw, list):
|
|
863
|
+
raise ValueError("stats snapshot queues must be an array")
|
|
864
|
+
age = raw["age_seconds"]
|
|
865
|
+
if not isinstance(age, (int, float)):
|
|
866
|
+
raise ValueError("age_seconds must be a number")
|
|
867
|
+
known = set(required)
|
|
868
|
+
extra = {k: v for k, v in raw.items() if k not in known}
|
|
869
|
+
return cls(
|
|
870
|
+
as_of=_require_str(raw["as_of"], "as_of"),
|
|
871
|
+
generated_at=_require_str(raw["generated_at"], "generated_at"),
|
|
872
|
+
age_seconds=float(age),
|
|
873
|
+
freshness=StatsFreshness.parse(raw["freshness"]),
|
|
874
|
+
queues=tuple(StatsQueueAggregate.parse(item) for item in queues_raw),
|
|
875
|
+
retry=StatsTelemetrySummary.parse(raw["retry"]),
|
|
876
|
+
dead_letter=StatsTelemetrySummary.parse(raw["dead_letter"]),
|
|
877
|
+
maintenance=StatsMaintenanceSummary.parse(raw["maintenance"]),
|
|
878
|
+
extra=dict(extra),
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
@dataclass(frozen=True, slots=True)
|
|
883
|
+
class BulkOperation:
|
|
884
|
+
value: str
|
|
885
|
+
|
|
886
|
+
@property
|
|
887
|
+
def is_unknown(self) -> bool:
|
|
888
|
+
return self.value not in KNOWN_BULK_OPERATIONS
|
|
889
|
+
|
|
890
|
+
@classmethod
|
|
891
|
+
def parse(cls, raw: object) -> BulkOperation:
|
|
892
|
+
if not isinstance(raw, str) or not raw:
|
|
893
|
+
raise ValueError("operation must be a non-empty string")
|
|
894
|
+
return cls(raw)
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
@dataclass(frozen=True, slots=True)
|
|
898
|
+
class BulkItemOutcomeKind:
|
|
899
|
+
value: str
|
|
900
|
+
|
|
901
|
+
@property
|
|
902
|
+
def is_unknown(self) -> bool:
|
|
903
|
+
return self.value not in KNOWN_BULK_ITEM_OUTCOMES
|
|
904
|
+
|
|
905
|
+
@classmethod
|
|
906
|
+
def parse(cls, raw: object) -> BulkItemOutcomeKind:
|
|
907
|
+
if not isinstance(raw, str) or not raw:
|
|
908
|
+
raise ValueError("outcome must be a non-empty string")
|
|
909
|
+
return cls(raw)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
@dataclass(frozen=True, slots=True)
|
|
913
|
+
class DeadLetterReplayResult:
|
|
914
|
+
"""Single dead-letter replay outcome.
|
|
915
|
+
|
|
916
|
+
Replay is at-least-once: ``replayed`` may be true on idempotent retry and
|
|
917
|
+
external side effects may repeat. ``source_task_id`` preserves immutable
|
|
918
|
+
terminal lineage; the new ``task_id`` is a distinct ready task.
|
|
919
|
+
"""
|
|
920
|
+
|
|
921
|
+
task_id: str
|
|
922
|
+
source_task_id: str
|
|
923
|
+
queue: str
|
|
924
|
+
policy_version: int
|
|
925
|
+
replayed: bool
|
|
926
|
+
warning: str
|
|
927
|
+
admin_replay_expires_at: str | None = None
|
|
928
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
929
|
+
|
|
930
|
+
@classmethod
|
|
931
|
+
def parse(cls, raw: object) -> DeadLetterReplayResult:
|
|
932
|
+
if not isinstance(raw, Mapping):
|
|
933
|
+
raise ValueError("dead letter replay result must be an object")
|
|
934
|
+
required = (
|
|
935
|
+
"task_id",
|
|
936
|
+
"source_task_id",
|
|
937
|
+
"queue",
|
|
938
|
+
"policy_version",
|
|
939
|
+
"replayed",
|
|
940
|
+
"warning",
|
|
941
|
+
)
|
|
942
|
+
missing = [key for key in required if key not in raw]
|
|
943
|
+
if missing:
|
|
944
|
+
raise ValueError(f"dead letter replay result missing fields: {missing}")
|
|
945
|
+
known = set(required) | {"admin_replay_expires_at"}
|
|
946
|
+
extra = {k: v for k, v in raw.items() if k not in known}
|
|
947
|
+
return cls(
|
|
948
|
+
task_id=_require_str(raw["task_id"], "task_id"),
|
|
949
|
+
source_task_id=_require_str(raw["source_task_id"], "source_task_id"),
|
|
950
|
+
queue=_require_str(raw["queue"], "queue"),
|
|
951
|
+
policy_version=_require_policy_version(raw["policy_version"], "policy_version"),
|
|
952
|
+
replayed=_require_bool(raw["replayed"], "replayed"),
|
|
953
|
+
warning=_require_str(raw["warning"], "warning"),
|
|
954
|
+
admin_replay_expires_at=_optional_str(
|
|
955
|
+
raw.get("admin_replay_expires_at"), "admin_replay_expires_at"
|
|
956
|
+
),
|
|
957
|
+
extra=dict(extra),
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
@dataclass(frozen=True, slots=True)
|
|
962
|
+
class BulkPreviewResult:
|
|
963
|
+
"""Dry-run bulk command preview with principal-bound confirmation token."""
|
|
964
|
+
|
|
965
|
+
operation: BulkOperation
|
|
966
|
+
queue: str
|
|
967
|
+
candidate_count: int
|
|
968
|
+
truncated: bool
|
|
969
|
+
sample_task_ids: tuple[str, ...]
|
|
970
|
+
confirmation_token: str
|
|
971
|
+
confirmation_expires_at: str
|
|
972
|
+
max_batch: int
|
|
973
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
974
|
+
|
|
975
|
+
@classmethod
|
|
976
|
+
def parse(cls, raw: object) -> BulkPreviewResult:
|
|
977
|
+
if not isinstance(raw, Mapping):
|
|
978
|
+
raise ValueError("bulk preview result must be an object")
|
|
979
|
+
required = (
|
|
980
|
+
"operation",
|
|
981
|
+
"queue",
|
|
982
|
+
"candidate_count",
|
|
983
|
+
"truncated",
|
|
984
|
+
"sample_task_ids",
|
|
985
|
+
"confirmation_token",
|
|
986
|
+
"confirmation_expires_at",
|
|
987
|
+
"max_batch",
|
|
988
|
+
)
|
|
989
|
+
missing = [key for key in required if key not in raw]
|
|
990
|
+
if missing:
|
|
991
|
+
raise ValueError(f"bulk preview result missing fields: {missing}")
|
|
992
|
+
sample_raw = raw["sample_task_ids"]
|
|
993
|
+
if not isinstance(sample_raw, list):
|
|
994
|
+
raise ValueError("sample_task_ids must be an array")
|
|
995
|
+
if len(sample_raw) > BULK_SAMPLE_MAX:
|
|
996
|
+
raise ValueError("sample_task_ids exceeds 5 items")
|
|
997
|
+
candidate_count = _require_int(raw["candidate_count"], "candidate_count")
|
|
998
|
+
if candidate_count < 0 or candidate_count > BULK_CANDIDATE_MAX:
|
|
999
|
+
raise ValueError("candidate_count must be between 0 and 100 inclusive")
|
|
1000
|
+
max_batch = _require_int(raw["max_batch"], "max_batch")
|
|
1001
|
+
if max_batch < 1 or max_batch > BULK_BATCH_MAX:
|
|
1002
|
+
raise ValueError("max_batch must be between 1 and 25 inclusive")
|
|
1003
|
+
known = set(required)
|
|
1004
|
+
extra = {k: v for k, v in raw.items() if k not in known}
|
|
1005
|
+
return cls(
|
|
1006
|
+
operation=BulkOperation.parse(raw["operation"]),
|
|
1007
|
+
queue=_require_str(raw["queue"], "queue"),
|
|
1008
|
+
candidate_count=candidate_count,
|
|
1009
|
+
truncated=_require_bool(raw["truncated"], "truncated"),
|
|
1010
|
+
sample_task_ids=tuple(_require_str(item, "sample_task_ids[]") for item in sample_raw),
|
|
1011
|
+
confirmation_token=validate_confirmation_token(
|
|
1012
|
+
_require_str(raw["confirmation_token"], "confirmation_token")
|
|
1013
|
+
),
|
|
1014
|
+
confirmation_expires_at=_require_str(
|
|
1015
|
+
raw["confirmation_expires_at"], "confirmation_expires_at"
|
|
1016
|
+
),
|
|
1017
|
+
max_batch=max_batch,
|
|
1018
|
+
extra=dict(extra),
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
def __repr__(self) -> str:
|
|
1022
|
+
return (
|
|
1023
|
+
f"BulkPreviewResult(operation={self.operation!r}, queue={self.queue!r}, "
|
|
1024
|
+
f"candidate_count={self.candidate_count}, truncated={self.truncated}, "
|
|
1025
|
+
f"sample_task_ids={self.sample_task_ids!r}, "
|
|
1026
|
+
f"confirmation_token=<redacted>, "
|
|
1027
|
+
f"confirmation_expires_at={self.confirmation_expires_at!r}, "
|
|
1028
|
+
f"max_batch={self.max_batch})"
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
def __str__(self) -> str:
|
|
1032
|
+
return self.__repr__()
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
@dataclass(frozen=True, slots=True)
|
|
1036
|
+
class BulkItemOutcome:
|
|
1037
|
+
task_id: str
|
|
1038
|
+
outcome: BulkItemOutcomeKind
|
|
1039
|
+
code: str | None = None
|
|
1040
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
1041
|
+
|
|
1042
|
+
@classmethod
|
|
1043
|
+
def parse(cls, raw: object) -> BulkItemOutcome:
|
|
1044
|
+
if not isinstance(raw, Mapping):
|
|
1045
|
+
raise ValueError("bulk item outcome must be an object")
|
|
1046
|
+
if "task_id" not in raw or "outcome" not in raw:
|
|
1047
|
+
raise ValueError("bulk item outcome requires task_id and outcome")
|
|
1048
|
+
known = {"task_id", "outcome", "code"}
|
|
1049
|
+
extra = {k: v for k, v in raw.items() if k not in known}
|
|
1050
|
+
return cls(
|
|
1051
|
+
task_id=_require_str(raw["task_id"], "task_id"),
|
|
1052
|
+
outcome=BulkItemOutcomeKind.parse(raw["outcome"]),
|
|
1053
|
+
code=_optional_str(raw.get("code"), "code"),
|
|
1054
|
+
extra=dict(extra),
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
@dataclass(frozen=True, slots=True)
|
|
1059
|
+
class BulkExecuteResult:
|
|
1060
|
+
"""Bounded bulk execute summary with explicit partial/retry semantics."""
|
|
1061
|
+
|
|
1062
|
+
operation: BulkOperation
|
|
1063
|
+
queue: str
|
|
1064
|
+
candidate_count: int
|
|
1065
|
+
start_index: int
|
|
1066
|
+
processed: int
|
|
1067
|
+
succeeded: int
|
|
1068
|
+
skipped: int
|
|
1069
|
+
failed: int
|
|
1070
|
+
partial: bool
|
|
1071
|
+
outcomes: tuple[BulkItemOutcome, ...]
|
|
1072
|
+
next_start_index: int | None = None
|
|
1073
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
1074
|
+
|
|
1075
|
+
@classmethod
|
|
1076
|
+
def parse(cls, raw: object) -> BulkExecuteResult:
|
|
1077
|
+
if not isinstance(raw, Mapping):
|
|
1078
|
+
raise ValueError("bulk execute result must be an object")
|
|
1079
|
+
required = (
|
|
1080
|
+
"operation",
|
|
1081
|
+
"queue",
|
|
1082
|
+
"candidate_count",
|
|
1083
|
+
"start_index",
|
|
1084
|
+
"processed",
|
|
1085
|
+
"succeeded",
|
|
1086
|
+
"skipped",
|
|
1087
|
+
"failed",
|
|
1088
|
+
"partial",
|
|
1089
|
+
"outcomes",
|
|
1090
|
+
)
|
|
1091
|
+
missing = [key for key in required if key not in raw]
|
|
1092
|
+
if missing:
|
|
1093
|
+
raise ValueError(f"bulk execute result missing fields: {missing}")
|
|
1094
|
+
outcomes_raw = raw["outcomes"]
|
|
1095
|
+
if not isinstance(outcomes_raw, list):
|
|
1096
|
+
raise ValueError("outcomes must be an array")
|
|
1097
|
+
if len(outcomes_raw) > BULK_BATCH_MAX:
|
|
1098
|
+
raise ValueError("outcomes exceeds 25 items")
|
|
1099
|
+
candidate_count = _require_int(raw["candidate_count"], "candidate_count")
|
|
1100
|
+
if candidate_count < 0 or candidate_count > BULK_CANDIDATE_MAX:
|
|
1101
|
+
raise ValueError("candidate_count must be between 0 and 100 inclusive")
|
|
1102
|
+
processed = _require_int(raw["processed"], "processed")
|
|
1103
|
+
if processed < 0 or processed > BULK_BATCH_MAX:
|
|
1104
|
+
raise ValueError("processed must be between 0 and 25 inclusive")
|
|
1105
|
+
start_index = _require_int(raw["start_index"], "start_index")
|
|
1106
|
+
if start_index < 0:
|
|
1107
|
+
raise ValueError("start_index must be non-negative")
|
|
1108
|
+
known = set(required) | {"next_start_index"}
|
|
1109
|
+
extra = {k: v for k, v in raw.items() if k not in known}
|
|
1110
|
+
return cls(
|
|
1111
|
+
operation=BulkOperation.parse(raw["operation"]),
|
|
1112
|
+
queue=_require_str(raw["queue"], "queue"),
|
|
1113
|
+
candidate_count=candidate_count,
|
|
1114
|
+
start_index=start_index,
|
|
1115
|
+
processed=processed,
|
|
1116
|
+
succeeded=_require_int(raw["succeeded"], "succeeded"),
|
|
1117
|
+
skipped=_require_int(raw["skipped"], "skipped"),
|
|
1118
|
+
failed=_require_int(raw["failed"], "failed"),
|
|
1119
|
+
partial=_require_bool(raw["partial"], "partial"),
|
|
1120
|
+
outcomes=tuple(BulkItemOutcome.parse(item) for item in outcomes_raw),
|
|
1121
|
+
next_start_index=_optional_int(raw.get("next_start_index"), "next_start_index"),
|
|
1122
|
+
extra=dict(extra),
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
@dataclass(frozen=True, slots=True)
|
|
1127
|
+
class BreakGlassMutationResult:
|
|
1128
|
+
operation: str
|
|
1129
|
+
target_id: str
|
|
1130
|
+
outcome: str
|
|
1131
|
+
queue: str | None = None
|
|
1132
|
+
generation: int | None = None
|
|
1133
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
1134
|
+
|
|
1135
|
+
@classmethod
|
|
1136
|
+
def parse(cls, raw: object) -> BreakGlassMutationResult:
|
|
1137
|
+
if not isinstance(raw, Mapping):
|
|
1138
|
+
raise ValueError("break-glass mutation result must be an object")
|
|
1139
|
+
_reject_break_glass_secret_keys(raw)
|
|
1140
|
+
required = ("operation", "target_id", "outcome")
|
|
1141
|
+
missing = [key for key in required if key not in raw]
|
|
1142
|
+
if missing:
|
|
1143
|
+
raise ValueError(f"break-glass mutation result missing fields: {missing}")
|
|
1144
|
+
known = set(required) | {"queue", "generation"}
|
|
1145
|
+
extra = {k: v for k, v in raw.items() if k not in known}
|
|
1146
|
+
generation = raw.get("generation")
|
|
1147
|
+
if generation is not None:
|
|
1148
|
+
generation = _require_int(generation, "generation")
|
|
1149
|
+
if generation < 0:
|
|
1150
|
+
raise ValueError("generation must be at least 0")
|
|
1151
|
+
return cls(
|
|
1152
|
+
operation=_require_str(raw["operation"], "operation"),
|
|
1153
|
+
target_id=_require_str(raw["target_id"], "target_id"),
|
|
1154
|
+
outcome=_require_str(raw["outcome"], "outcome"),
|
|
1155
|
+
queue=_optional_str(raw.get("queue"), "queue"),
|
|
1156
|
+
generation=generation,
|
|
1157
|
+
extra=dict(extra),
|
|
1158
|
+
)
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
@dataclass(frozen=True, slots=True)
|
|
1162
|
+
class BreakGlassCounterResult:
|
|
1163
|
+
operation: str
|
|
1164
|
+
queue: str
|
|
1165
|
+
target_id: str
|
|
1166
|
+
outcome: str
|
|
1167
|
+
delayed_count: int
|
|
1168
|
+
ready_count: int
|
|
1169
|
+
leased_count: int
|
|
1170
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
1171
|
+
|
|
1172
|
+
@classmethod
|
|
1173
|
+
def parse(cls, raw: object) -> BreakGlassCounterResult:
|
|
1174
|
+
if not isinstance(raw, Mapping):
|
|
1175
|
+
raise ValueError("break-glass counter result must be an object")
|
|
1176
|
+
_reject_break_glass_secret_keys(raw)
|
|
1177
|
+
required = (
|
|
1178
|
+
"operation",
|
|
1179
|
+
"queue",
|
|
1180
|
+
"target_id",
|
|
1181
|
+
"outcome",
|
|
1182
|
+
"delayed_count",
|
|
1183
|
+
"ready_count",
|
|
1184
|
+
"leased_count",
|
|
1185
|
+
)
|
|
1186
|
+
missing = [key for key in required if key not in raw]
|
|
1187
|
+
if missing:
|
|
1188
|
+
raise ValueError(f"break-glass counter result missing fields: {missing}")
|
|
1189
|
+
known = set(required)
|
|
1190
|
+
extra = {k: v for k, v in raw.items() if k not in known}
|
|
1191
|
+
return cls(
|
|
1192
|
+
operation=_require_str(raw["operation"], "operation"),
|
|
1193
|
+
queue=_require_str(raw["queue"], "queue"),
|
|
1194
|
+
target_id=_require_str(raw["target_id"], "target_id"),
|
|
1195
|
+
outcome=_require_str(raw["outcome"], "outcome"),
|
|
1196
|
+
delayed_count=_require_int(raw["delayed_count"], "delayed_count"),
|
|
1197
|
+
ready_count=_require_int(raw["ready_count"], "ready_count"),
|
|
1198
|
+
leased_count=_require_int(raw["leased_count"], "leased_count"),
|
|
1199
|
+
extra=dict(extra),
|
|
1200
|
+
)
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
@dataclass(frozen=True, slots=True)
|
|
1204
|
+
class BreakGlassReplayLimitResult:
|
|
1205
|
+
operation: str
|
|
1206
|
+
queue: str
|
|
1207
|
+
target_id: str
|
|
1208
|
+
outcome: str
|
|
1209
|
+
effective_rps: float
|
|
1210
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
1211
|
+
|
|
1212
|
+
@classmethod
|
|
1213
|
+
def parse(cls, raw: object) -> BreakGlassReplayLimitResult:
|
|
1214
|
+
if not isinstance(raw, Mapping):
|
|
1215
|
+
raise ValueError("break-glass replay limit result must be an object")
|
|
1216
|
+
_reject_break_glass_secret_keys(raw)
|
|
1217
|
+
required = ("operation", "queue", "target_id", "outcome", "effective_rps")
|
|
1218
|
+
missing = [key for key in required if key not in raw]
|
|
1219
|
+
if missing:
|
|
1220
|
+
raise ValueError(f"break-glass replay limit result missing fields: {missing}")
|
|
1221
|
+
effective = raw["effective_rps"]
|
|
1222
|
+
if not isinstance(effective, (int, float)):
|
|
1223
|
+
raise ValueError("effective_rps must be a number")
|
|
1224
|
+
if effective < 0:
|
|
1225
|
+
raise ValueError("effective_rps must be at least 0")
|
|
1226
|
+
known = set(required)
|
|
1227
|
+
extra = {k: v for k, v in raw.items() if k not in known}
|
|
1228
|
+
return cls(
|
|
1229
|
+
operation=_require_str(raw["operation"], "operation"),
|
|
1230
|
+
queue=_require_str(raw["queue"], "queue"),
|
|
1231
|
+
target_id=_require_str(raw["target_id"], "target_id"),
|
|
1232
|
+
outcome=_require_str(raw["outcome"], "outcome"),
|
|
1233
|
+
effective_rps=float(effective),
|
|
1234
|
+
extra=dict(extra),
|
|
1235
|
+
)
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def validate_break_glass_reason(reason: str) -> str:
|
|
1239
|
+
if not isinstance(reason, str) or not reason:
|
|
1240
|
+
raise ValueError("reason must be a non-empty string")
|
|
1241
|
+
if len(reason) > BREAK_GLASS_REASON_MAX_LENGTH:
|
|
1242
|
+
raise ValueError("reason exceeds 512 characters")
|
|
1243
|
+
return reason
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def validate_incident_reference(incident_reference: str) -> str:
|
|
1247
|
+
if not isinstance(incident_reference, str) or not incident_reference:
|
|
1248
|
+
raise ValueError("incident_reference must be a non-empty string")
|
|
1249
|
+
if len(incident_reference) > INCIDENT_REFERENCE_MAX_LENGTH:
|
|
1250
|
+
raise ValueError("incident_reference exceeds 128 characters")
|
|
1251
|
+
return incident_reference
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
def validate_risk_acknowledged(value: bool) -> bool:
|
|
1255
|
+
if value is not True:
|
|
1256
|
+
raise ValueError("risk_acknowledged must be true")
|
|
1257
|
+
return True
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
def validate_acknowledge_duplicate_window(value: bool) -> bool:
|
|
1261
|
+
if value is not True:
|
|
1262
|
+
raise ValueError("acknowledge_duplicate_window must be true")
|
|
1263
|
+
return True
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
def validate_event_id(event_id: str) -> str:
|
|
1267
|
+
return validate_task_id(event_id)
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
def validate_partition_name(partition_name: str) -> str:
|
|
1271
|
+
if not isinstance(partition_name, str) or not partition_name:
|
|
1272
|
+
raise ValueError("partition_name must be a non-empty string")
|
|
1273
|
+
if len(partition_name) > PARTITION_NAME_MAX_LENGTH:
|
|
1274
|
+
raise ValueError("partition_name exceeds 128 characters")
|
|
1275
|
+
if not _PARTITION_NAME_RE.match(partition_name):
|
|
1276
|
+
raise ValueError("partition_name must match OpenAPI pattern")
|
|
1277
|
+
return partition_name
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
def validate_replay_factor(factor: float) -> float:
|
|
1281
|
+
if isinstance(factor, bool) or not isinstance(factor, (int, float)):
|
|
1282
|
+
raise ValueError("factor must be a number")
|
|
1283
|
+
numeric = float(factor)
|
|
1284
|
+
if numeric < REPLAY_FACTOR_MIN or numeric > REPLAY_FACTOR_MAX:
|
|
1285
|
+
raise ValueError("factor must be between 1.0 and 10.0 inclusive")
|
|
1286
|
+
return numeric
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def validate_replay_ttl_seconds(ttl_seconds: int) -> int:
|
|
1290
|
+
if isinstance(ttl_seconds, bool) or not isinstance(ttl_seconds, int):
|
|
1291
|
+
raise ValueError("ttl_seconds must be an integer")
|
|
1292
|
+
if ttl_seconds < REPLAY_TTL_SECONDS_MIN or ttl_seconds > REPLAY_TTL_SECONDS_MAX:
|
|
1293
|
+
raise ValueError("ttl_seconds must be between 1 and 3600 inclusive")
|
|
1294
|
+
return ttl_seconds
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
def validate_registry_entry_id(entry_id: int) -> int:
|
|
1298
|
+
if isinstance(entry_id, bool) or not isinstance(entry_id, int):
|
|
1299
|
+
raise ValueError("entry_id must be an integer")
|
|
1300
|
+
if entry_id < REGISTRY_ENTRY_ID_MIN:
|
|
1301
|
+
raise ValueError("entry_id must be at least 1")
|
|
1302
|
+
return entry_id
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def validate_extend_seconds(extend_seconds: int) -> int:
|
|
1306
|
+
if isinstance(extend_seconds, bool) or not isinstance(extend_seconds, int):
|
|
1307
|
+
raise ValueError("extend_seconds must be an integer")
|
|
1308
|
+
if extend_seconds < EXTEND_SECONDS_MIN or extend_seconds > EXTEND_SECONDS_MAX:
|
|
1309
|
+
raise ValueError("extend_seconds must be between 1 and 2592000 inclusive")
|
|
1310
|
+
return extend_seconds
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def validate_failure_code(failure_code: str) -> str:
|
|
1314
|
+
if not isinstance(failure_code, str) or not failure_code:
|
|
1315
|
+
raise ValueError("failure_code must be a non-empty string")
|
|
1316
|
+
if len(failure_code) > FAILURE_CODE_MAX_LENGTH:
|
|
1317
|
+
raise ValueError("failure_code exceeds 128 characters")
|
|
1318
|
+
return failure_code
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
def validate_idempotency_key(key: str) -> str:
|
|
1322
|
+
if not isinstance(key, str) or not key:
|
|
1323
|
+
raise ValueError("idempotency_key must be a non-empty string")
|
|
1324
|
+
if len(key) < IDEMPOTENCY_KEY_MIN_LENGTH or len(key) > IDEMPOTENCY_KEY_MAX_LENGTH:
|
|
1325
|
+
raise ValueError("idempotency_key must be between 1 and 256 characters inclusive")
|
|
1326
|
+
return key
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def validate_reason(reason: str) -> str:
|
|
1330
|
+
if not isinstance(reason, str) or not reason:
|
|
1331
|
+
raise ValueError("reason must be a non-empty string")
|
|
1332
|
+
if len(reason) > REASON_MAX_LENGTH:
|
|
1333
|
+
raise ValueError("reason exceeds 512 characters")
|
|
1334
|
+
return reason
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def validate_confirmation_token(token: str) -> str:
|
|
1338
|
+
if not isinstance(token, str) or not token:
|
|
1339
|
+
raise ValueError("confirmation_token must be a non-empty string")
|
|
1340
|
+
if len(token) > CONFIRMATION_TOKEN_MAX_LENGTH:
|
|
1341
|
+
raise ValueError("confirmation_token exceeds 24576 characters")
|
|
1342
|
+
return token
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
def _reject_break_glass_secret_keys(raw: Mapping[str, Any]) -> None:
|
|
1346
|
+
for key in raw:
|
|
1347
|
+
key_str = str(key)
|
|
1348
|
+
if _FORBIDDEN_BREAK_GLASS_SECRET_KEY_RE.search(key_str):
|
|
1349
|
+
raise ValueError(f"break-glass result must not contain {key_str!r}")
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def validate_bulk_filters(filters: Mapping[str, str]) -> dict[str, str]:
|
|
1353
|
+
if not isinstance(filters, Mapping):
|
|
1354
|
+
raise ValueError("filters must be a mapping")
|
|
1355
|
+
normalized: dict[str, str] = {}
|
|
1356
|
+
for raw_key, raw_val in filters.items():
|
|
1357
|
+
if not isinstance(raw_key, str) or not raw_key.strip():
|
|
1358
|
+
raise ValueError("filter keys must be non-empty strings")
|
|
1359
|
+
key = raw_key.strip().lower()
|
|
1360
|
+
if key in _FORBIDDEN_BULK_FILTER_KEYS:
|
|
1361
|
+
raise ValueError(f"filter {key!r} is not allowed")
|
|
1362
|
+
if key not in _ALLOWED_BULK_FILTER_KEYS:
|
|
1363
|
+
raise ValueError(f"unknown filter {key!r}")
|
|
1364
|
+
if not isinstance(raw_val, str) or not raw_val.strip():
|
|
1365
|
+
raise ValueError("filter values must be non-empty strings")
|
|
1366
|
+
stripped = raw_val.strip()
|
|
1367
|
+
if len(stripped) > 128:
|
|
1368
|
+
raise ValueError("filter values must be at most 128 characters")
|
|
1369
|
+
normalized[key] = stripped
|
|
1370
|
+
return dict(sorted(normalized.items()))
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
def validate_batch_limit(batch_limit: int | None) -> int | None:
|
|
1374
|
+
if batch_limit is None:
|
|
1375
|
+
return None
|
|
1376
|
+
if isinstance(batch_limit, bool) or not isinstance(batch_limit, int):
|
|
1377
|
+
raise ValueError("batch_limit must be an integer or None")
|
|
1378
|
+
if batch_limit < 1 or batch_limit > BULK_BATCH_MAX:
|
|
1379
|
+
raise ValueError("batch_limit must be between 1 and 25 inclusive")
|
|
1380
|
+
return batch_limit
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
def validate_start_index(start_index: int) -> int:
|
|
1384
|
+
if isinstance(start_index, bool) or not isinstance(start_index, int):
|
|
1385
|
+
raise ValueError("start_index must be an integer")
|
|
1386
|
+
if start_index < 0:
|
|
1387
|
+
raise ValueError("start_index must be non-negative")
|
|
1388
|
+
return start_index
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
def validate_known_queue_state(state: QueueState) -> QueueState:
|
|
1392
|
+
if state.is_unknown:
|
|
1393
|
+
raise ValueError("state must be active, paused, or draining")
|
|
1394
|
+
return state
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
def validate_queue_name(name: str) -> str:
|
|
1398
|
+
if not isinstance(name, str) or not name:
|
|
1399
|
+
raise ValueError("queue_name must be a non-empty string")
|
|
1400
|
+
if len(name) > QUEUE_NAME_MAX_LENGTH:
|
|
1401
|
+
raise ValueError("queue_name exceeds 128 characters")
|
|
1402
|
+
if not _QUEUE_NAME_RE.match(name):
|
|
1403
|
+
raise ValueError("queue_name must match OpenAPI pattern")
|
|
1404
|
+
return name
|
|
1405
|
+
|
|
1406
|
+
|
|
1407
|
+
def validate_task_id(task_id: str) -> str:
|
|
1408
|
+
if not isinstance(task_id, str) or not task_id:
|
|
1409
|
+
raise ValueError("task_id must be a non-empty string")
|
|
1410
|
+
try:
|
|
1411
|
+
uuid.UUID(task_id)
|
|
1412
|
+
except ValueError as exc:
|
|
1413
|
+
raise ValueError("task_id must be a UUID") from exc
|
|
1414
|
+
return task_id
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
def validate_page_limit(limit: int) -> int:
|
|
1418
|
+
if isinstance(limit, bool) or not isinstance(limit, int):
|
|
1419
|
+
raise ValueError("limit must be an integer")
|
|
1420
|
+
if limit < PAGE_LIMIT_MIN or limit > PAGE_LIMIT_MAX:
|
|
1421
|
+
raise ValueError("limit must be between 1 and 100 inclusive")
|
|
1422
|
+
return limit
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
def validate_cursor(cursor: str | None) -> str | None:
|
|
1426
|
+
if cursor is None:
|
|
1427
|
+
return None
|
|
1428
|
+
if not isinstance(cursor, str):
|
|
1429
|
+
raise ValueError("cursor must be a string or None")
|
|
1430
|
+
if len(cursor) > CURSOR_MAX_LENGTH:
|
|
1431
|
+
raise ValueError("cursor exceeds 512 characters")
|
|
1432
|
+
return cursor
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
def validate_time_range(time_from: datetime, time_to: datetime) -> tuple[datetime, datetime]:
|
|
1436
|
+
if time_from.tzinfo is None or time_from.utcoffset() is None:
|
|
1437
|
+
raise ValueError("from must be timezone-aware")
|
|
1438
|
+
if time_to.tzinfo is None or time_to.utcoffset() is None:
|
|
1439
|
+
raise ValueError("to must be timezone-aware")
|
|
1440
|
+
if time_from > time_to:
|
|
1441
|
+
raise ValueError("from must be no later than to")
|
|
1442
|
+
return time_from, time_to
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
def format_datetime(value: datetime) -> str:
|
|
1446
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
1447
|
+
raise ValueError("datetime must be timezone-aware")
|
|
1448
|
+
utc = value.astimezone(UTC)
|
|
1449
|
+
text = utc.isoformat()
|
|
1450
|
+
if text.endswith("+00:00"):
|
|
1451
|
+
return text[:-6] + "Z"
|
|
1452
|
+
return text
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
def _require_str(value: object, name: str) -> str:
|
|
1456
|
+
if not isinstance(value, str):
|
|
1457
|
+
raise ValueError(f"{name} must be a string")
|
|
1458
|
+
return value
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
def _optional_str(value: object, name: str) -> str | None:
|
|
1462
|
+
if value is None:
|
|
1463
|
+
return None
|
|
1464
|
+
return _require_str(value, name)
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
def _require_int(value: object, name: str) -> int:
|
|
1468
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
1469
|
+
raise ValueError(f"{name} must be an integer")
|
|
1470
|
+
return value
|
|
1471
|
+
|
|
1472
|
+
|
|
1473
|
+
def _optional_int(value: object, name: str) -> int | None:
|
|
1474
|
+
if value is None:
|
|
1475
|
+
return None
|
|
1476
|
+
return _require_int(value, name)
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
def _require_bool(value: object, name: str) -> bool:
|
|
1480
|
+
if not isinstance(value, bool):
|
|
1481
|
+
raise ValueError(f"{name} must be a boolean")
|
|
1482
|
+
return value
|
|
1483
|
+
|
|
1484
|
+
|
|
1485
|
+
def _optional_bool(value: object, name: str) -> bool | None:
|
|
1486
|
+
if value is None:
|
|
1487
|
+
return None
|
|
1488
|
+
return _require_bool(value, name)
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
def _require_config_version(value: object, name: str) -> int:
|
|
1492
|
+
parsed = _require_int(value, name)
|
|
1493
|
+
if parsed < CONFIG_VERSION_MIN:
|
|
1494
|
+
raise ValueError(f"{name} must be at least 1")
|
|
1495
|
+
return parsed
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
def _require_policy_version(value: object, name: str) -> int:
|
|
1499
|
+
parsed = _require_int(value, name)
|
|
1500
|
+
if parsed < POLICY_VERSION_MIN:
|
|
1501
|
+
raise ValueError(f"{name} must be at least 1")
|
|
1502
|
+
return parsed
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
def _require_max_attempts(value: object) -> int:
|
|
1506
|
+
parsed = _require_int(value, "max_attempts")
|
|
1507
|
+
if parsed < MAX_ATTEMPTS_MIN:
|
|
1508
|
+
raise ValueError("max_attempts must be at least 1")
|
|
1509
|
+
return parsed
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
def _require_retry_delay_seconds(value: object) -> int:
|
|
1513
|
+
parsed = _require_int(value, "retry_delay_seconds")
|
|
1514
|
+
if parsed < 0 or parsed > RETRY_DELAY_SECONDS_MAX:
|
|
1515
|
+
raise ValueError("retry_delay_seconds must be between 0 and 86400 inclusive")
|
|
1516
|
+
return parsed
|