queue-max 0.1.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.
- queue_max/__init__.py +62 -0
- queue_max/cli.py +373 -0
- queue_max/contrib/__init__.py +7 -0
- queue_max/contrib/django/__init__.py +61 -0
- queue_max/contrib/django/management/__init__.py +0 -0
- queue_max/contrib/django/management/commands/__init__.py +0 -0
- queue_max/contrib/django/management/commands/queue_purge.py +19 -0
- queue_max/contrib/django/management/commands/queue_stats.py +39 -0
- queue_max/contrib/django/management/commands/queue_worker.py +69 -0
- queue_max/contrib/fastapi/__init__.py +117 -0
- queue_max/contrib/flask/__init__.py +99 -0
- queue_max/core/__init__.py +16 -0
- queue_max/core/circuit_breaker.py +162 -0
- queue_max/core/database.py +253 -0
- queue_max/core/decorator.py +346 -0
- queue_max/core/queue.py +420 -0
- queue_max/core/rate_limiter.py +214 -0
- queue_max/core/worker.py +426 -0
- queue_max/exceptions.py +25 -0
- queue_max/models/__init__.py +5 -0
- queue_max/models/job.py +340 -0
- queue_max/py.typed +0 -0
- queue_max/utils/__init__.py +23 -0
- queue_max/utils/helpers.py +156 -0
- queue_max-0.1.0.dist-info/METADATA +233 -0
- queue_max-0.1.0.dist-info/RECORD +30 -0
- queue_max-0.1.0.dist-info/WHEEL +5 -0
- queue_max-0.1.0.dist-info/entry_points.txt +2 -0
- queue_max-0.1.0.dist-info/licenses/LICENSE +21 -0
- queue_max-0.1.0.dist-info/top_level.txt +1 -0
queue_max/models/job.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Job data model for the Robusta Queue library.
|
|
2
|
+
|
|
3
|
+
Represents a task in the queue with full lifecycle tracking,
|
|
4
|
+
including retry logic, error handling, and metadata.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import traceback
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime, timedelta, timezone
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any, Dict, List, Optional, Union
|
|
13
|
+
from uuid import uuid4
|
|
14
|
+
|
|
15
|
+
class JobStatus(Enum):
|
|
16
|
+
"""Possible job statuses."""
|
|
17
|
+
PENDING = "pending"
|
|
18
|
+
PROCESSING = "processing"
|
|
19
|
+
COMPLETED = "completed"
|
|
20
|
+
FAILED = "failed"
|
|
21
|
+
CANCELLED = "cancelled"
|
|
22
|
+
SCHEDULED = "scheduled"
|
|
23
|
+
|
|
24
|
+
class JobPriority(Enum):
|
|
25
|
+
"""Job priority levels."""
|
|
26
|
+
LOW = 0
|
|
27
|
+
MEDIUM = 1
|
|
28
|
+
HIGH = 2
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_int(cls, value: int) -> "JobPriority":
|
|
32
|
+
"""Convert integer to JobPriority."""
|
|
33
|
+
for priority in cls:
|
|
34
|
+
if priority.value == value:
|
|
35
|
+
return priority
|
|
36
|
+
return cls.MEDIUM
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class JobResult:
|
|
40
|
+
"""Result of a job execution."""
|
|
41
|
+
success: bool
|
|
42
|
+
result: Any = None
|
|
43
|
+
error: Optional[Exception] = None
|
|
44
|
+
execution_time: float = 0.0
|
|
45
|
+
worker_id: Optional[str] = None
|
|
46
|
+
completed_at: Optional[str] = None
|
|
47
|
+
|
|
48
|
+
def _now_iso() -> str:
|
|
49
|
+
"""Get current UTC timestamp in ISO format."""
|
|
50
|
+
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Job:
|
|
54
|
+
"""Represents a job in the queue.
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
id: Unique identifier for the job.
|
|
58
|
+
pagina_id: Optional ID used for consistent sharding.
|
|
59
|
+
payload: JSON-serializable dictionary with job data.
|
|
60
|
+
status: Current status.
|
|
61
|
+
priority: Job priority (0=low, 1=medium, 2=high).
|
|
62
|
+
tentativas: Number of attempts made so far.
|
|
63
|
+
max_tentativas: Maximum number of retry attempts.
|
|
64
|
+
retry_delay: Base delay in seconds for exponential backoff.
|
|
65
|
+
last_error: Last error message.
|
|
66
|
+
error_type: Type/class of the last error.
|
|
67
|
+
error_stack: Stack trace of the last error.
|
|
68
|
+
worker_id: ID of the worker processing this job.
|
|
69
|
+
heartbeat: Last activity timestamp (ISO UTC).
|
|
70
|
+
created_at: Creation timestamp (ISO UTC).
|
|
71
|
+
started_at: When processing started (ISO UTC).
|
|
72
|
+
completed_at: When job completed/failed (ISO UTC).
|
|
73
|
+
next_retry_at: Scheduled next retry timestamp (ISO UTC).
|
|
74
|
+
shard_id: Shard this job belongs to.
|
|
75
|
+
tags: List of tags for categorization.
|
|
76
|
+
metadata: Additional metadata dictionary.
|
|
77
|
+
parent_job_id: Optional parent job ID for dependency chains.
|
|
78
|
+
timeout_seconds: Maximum execution time in seconds.
|
|
79
|
+
progress: Progress percentage (0-100) for long-running jobs.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
id: int
|
|
83
|
+
payload: Dict[str, Any]
|
|
84
|
+
pagina_id: Optional[int] = None
|
|
85
|
+
status: Union[JobStatus, str] = JobStatus.PENDING
|
|
86
|
+
priority: Union[JobPriority, int] = JobPriority.MEDIUM
|
|
87
|
+
tentativas: int = 0
|
|
88
|
+
max_tentativas: int = 3
|
|
89
|
+
retry_delay: int = 60
|
|
90
|
+
last_error: Optional[str] = None
|
|
91
|
+
error_type: Optional[str] = None
|
|
92
|
+
error_stack: Optional[str] = None
|
|
93
|
+
worker_id: Optional[str] = None
|
|
94
|
+
heartbeat: Optional[str] = None
|
|
95
|
+
created_at: Optional[str] = None
|
|
96
|
+
started_at: Optional[str] = None
|
|
97
|
+
completed_at: Optional[str] = None
|
|
98
|
+
next_retry_at: Optional[str] = None
|
|
99
|
+
shard_id: int = 0
|
|
100
|
+
tags: List[str] = field(default_factory=list)
|
|
101
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
102
|
+
parent_job_id: Optional[int] = None
|
|
103
|
+
timeout_seconds: Optional[int] = None
|
|
104
|
+
progress: float = 0.0
|
|
105
|
+
|
|
106
|
+
def __post_init__(self):
|
|
107
|
+
"""Initialize timestamps and normalize enums."""
|
|
108
|
+
if isinstance(self.status, str):
|
|
109
|
+
try:
|
|
110
|
+
self.status = JobStatus(self.status)
|
|
111
|
+
except ValueError:
|
|
112
|
+
self.status = JobStatus.PENDING
|
|
113
|
+
if isinstance(self.priority, int):
|
|
114
|
+
self.priority = JobPriority.from_int(self.priority)
|
|
115
|
+
if self.created_at is None:
|
|
116
|
+
self.created_at = _now_iso()
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def status_str(self) -> str:
|
|
120
|
+
return self.status.value if isinstance(self.status, JobStatus) else str(self.status)
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def priority_int(self) -> int:
|
|
124
|
+
return self.priority.value if isinstance(self.priority, JobPriority) else int(self.priority)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def is_pending(self) -> bool:
|
|
128
|
+
return self.status == JobStatus.PENDING
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def is_processing(self) -> bool:
|
|
132
|
+
return self.status == JobStatus.PROCESSING
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def is_completed(self) -> bool:
|
|
136
|
+
return self.status == JobStatus.COMPLETED
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def is_failed(self) -> bool:
|
|
140
|
+
return self.status == JobStatus.FAILED
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def is_cancelled(self) -> bool:
|
|
144
|
+
return self.status == JobStatus.CANCELLED
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def is_terminal(self) -> bool:
|
|
148
|
+
return self.status in (JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED)
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def can_retry(self) -> bool:
|
|
152
|
+
return self.tentativas < self.max_tentativas and self.status == JobStatus.FAILED
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def remaining_retries(self) -> int:
|
|
156
|
+
return max(0, self.max_tentativas - self.tentativas)
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def age_seconds(self) -> Optional[float]:
|
|
160
|
+
if self.created_at:
|
|
161
|
+
try:
|
|
162
|
+
created = datetime.fromisoformat(self.created_at.replace("Z", "+00:00"))
|
|
163
|
+
return (datetime.now(timezone.utc) - created).total_seconds()
|
|
164
|
+
except (ValueError, TypeError):
|
|
165
|
+
return None
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def processing_time_seconds(self) -> Optional[float]:
|
|
170
|
+
if self.started_at and self.completed_at:
|
|
171
|
+
try:
|
|
172
|
+
start = datetime.fromisoformat(self.started_at.replace("Z", "+00:00"))
|
|
173
|
+
end = datetime.fromisoformat(self.completed_at.replace("Z", "+00:00"))
|
|
174
|
+
return (end - start).total_seconds()
|
|
175
|
+
except (ValueError, TypeError):
|
|
176
|
+
return None
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
def mark_processing(self, worker_id: str) -> None:
|
|
180
|
+
"""Mark job as being processed by a worker."""
|
|
181
|
+
self.status = JobStatus.PROCESSING
|
|
182
|
+
self.worker_id = worker_id
|
|
183
|
+
self.started_at = _now_iso()
|
|
184
|
+
self.heartbeat = self.started_at
|
|
185
|
+
self.progress = 0.0
|
|
186
|
+
|
|
187
|
+
def mark_completed(self, result: Any = None) -> None:
|
|
188
|
+
"""Mark job as completed with optional result."""
|
|
189
|
+
self.status = JobStatus.COMPLETED
|
|
190
|
+
self.completed_at = _now_iso()
|
|
191
|
+
self.progress = 100.0
|
|
192
|
+
self.last_error = None
|
|
193
|
+
self.error_type = None
|
|
194
|
+
self.error_stack = None
|
|
195
|
+
if result is not None:
|
|
196
|
+
self.metadata["result"] = result
|
|
197
|
+
|
|
198
|
+
def mark_failed(self, error: Exception, permanent: bool = False) -> None:
|
|
199
|
+
"""Mark job as failed, scheduling retry if applicable."""
|
|
200
|
+
self.status = JobStatus.FAILED
|
|
201
|
+
self.completed_at = _now_iso()
|
|
202
|
+
self.last_error = str(error)
|
|
203
|
+
self.error_type = type(error).__name__
|
|
204
|
+
self.error_stack = "".join(
|
|
205
|
+
traceback.format_exception(type(error), error, error.__traceback__)
|
|
206
|
+
)
|
|
207
|
+
if not permanent and self.can_retry:
|
|
208
|
+
self.status = JobStatus.PENDING
|
|
209
|
+
self.tentativas += 1
|
|
210
|
+
self._schedule_retry()
|
|
211
|
+
|
|
212
|
+
def mark_cancelled(self) -> None:
|
|
213
|
+
"""Mark job as cancelled."""
|
|
214
|
+
self.status = JobStatus.CANCELLED
|
|
215
|
+
self.completed_at = _now_iso()
|
|
216
|
+
|
|
217
|
+
def update_progress(self, progress: float) -> None:
|
|
218
|
+
"""Update job progress (0-100)."""
|
|
219
|
+
self.progress = max(0, min(100, progress))
|
|
220
|
+
self.heartbeat = _now_iso()
|
|
221
|
+
|
|
222
|
+
def _schedule_retry(self) -> None:
|
|
223
|
+
"""Schedule retry with exponential backoff and jitter."""
|
|
224
|
+
import random
|
|
225
|
+
delay = float(self.retry_delay) * (2 ** (self.tentativas - 1))
|
|
226
|
+
jitter = delay * 0.2
|
|
227
|
+
delay = delay + (random.random() * jitter * 2 - jitter)
|
|
228
|
+
delay = min(delay, 3600)
|
|
229
|
+
next_retry = datetime.now(timezone.utc) + timedelta(seconds=delay)
|
|
230
|
+
self.next_retry_at = next_retry.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
231
|
+
|
|
232
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
233
|
+
"""Convert job to a dictionary for serialization."""
|
|
234
|
+
return {
|
|
235
|
+
"id": self.id,
|
|
236
|
+
"pagina_id": self.pagina_id,
|
|
237
|
+
"payload": self.payload,
|
|
238
|
+
"status": self.status_str,
|
|
239
|
+
"priority": self.priority_int,
|
|
240
|
+
"tentativas": self.tentativas,
|
|
241
|
+
"max_tentativas": self.max_tentativas,
|
|
242
|
+
"retry_delay": self.retry_delay,
|
|
243
|
+
"last_error": self.last_error,
|
|
244
|
+
"error_type": self.error_type,
|
|
245
|
+
"error_stack": self.error_stack,
|
|
246
|
+
"worker_id": self.worker_id,
|
|
247
|
+
"heartbeat": self.heartbeat,
|
|
248
|
+
"created_at": self.created_at,
|
|
249
|
+
"started_at": self.started_at,
|
|
250
|
+
"completed_at": self.completed_at,
|
|
251
|
+
"next_retry_at": self.next_retry_at,
|
|
252
|
+
"shard_id": self.shard_id,
|
|
253
|
+
"tags": self.tags,
|
|
254
|
+
"metadata": self.metadata,
|
|
255
|
+
"parent_job_id": self.parent_job_id,
|
|
256
|
+
"timeout_seconds": self.timeout_seconds,
|
|
257
|
+
"progress": self.progress,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
@classmethod
|
|
261
|
+
def from_row(cls, row: dict, shard_id: int = 0) -> "Job":
|
|
262
|
+
"""Create a Job from a database row dict."""
|
|
263
|
+
raw = row.get("payload")
|
|
264
|
+
if isinstance(raw, str):
|
|
265
|
+
try:
|
|
266
|
+
payload = json.loads(raw)
|
|
267
|
+
except (json.JSONDecodeError, TypeError):
|
|
268
|
+
payload = {"raw": raw}
|
|
269
|
+
else:
|
|
270
|
+
payload = raw or {}
|
|
271
|
+
|
|
272
|
+
tags = row.get("tags", [])
|
|
273
|
+
if isinstance(tags, str):
|
|
274
|
+
try:
|
|
275
|
+
tags = json.loads(tags)
|
|
276
|
+
except (json.JSONDecodeError, TypeError):
|
|
277
|
+
tags = []
|
|
278
|
+
|
|
279
|
+
metadata = row.get("metadata", {})
|
|
280
|
+
if isinstance(metadata, str):
|
|
281
|
+
try:
|
|
282
|
+
metadata = json.loads(metadata)
|
|
283
|
+
except (json.JSONDecodeError, TypeError):
|
|
284
|
+
metadata = {}
|
|
285
|
+
|
|
286
|
+
return cls(
|
|
287
|
+
id=row["id"],
|
|
288
|
+
pagina_id=row.get("pagina_id"),
|
|
289
|
+
payload=payload,
|
|
290
|
+
status=row.get("status", "pending"),
|
|
291
|
+
priority=row.get("priority", 1),
|
|
292
|
+
tentativas=row.get("tentativas", 0),
|
|
293
|
+
max_tentativas=row.get("max_tentativas", 3),
|
|
294
|
+
retry_delay=row.get("retry_delay", 60),
|
|
295
|
+
last_error=row.get("last_error"),
|
|
296
|
+
error_type=row.get("error_type"),
|
|
297
|
+
error_stack=row.get("error_stack"),
|
|
298
|
+
worker_id=row.get("worker_id"),
|
|
299
|
+
heartbeat=row.get("heartbeat"),
|
|
300
|
+
created_at=row.get("created_at"),
|
|
301
|
+
started_at=row.get("started_at"),
|
|
302
|
+
completed_at=row.get("completed_at"),
|
|
303
|
+
next_retry_at=row.get("next_retry_at"),
|
|
304
|
+
shard_id=shard_id,
|
|
305
|
+
tags=tags,
|
|
306
|
+
metadata=metadata,
|
|
307
|
+
parent_job_id=row.get("parent_job_id"),
|
|
308
|
+
timeout_seconds=row.get("timeout_seconds"),
|
|
309
|
+
progress=float(row.get("progress", 0)),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
@classmethod
|
|
313
|
+
def create(
|
|
314
|
+
cls,
|
|
315
|
+
payload: Dict[str, Any],
|
|
316
|
+
pagina_id: Optional[int] = None,
|
|
317
|
+
priority: Union[JobPriority, int] = JobPriority.MEDIUM,
|
|
318
|
+
max_retries: int = 3,
|
|
319
|
+
retry_delay: int = 60,
|
|
320
|
+
tags: Optional[List[str]] = None,
|
|
321
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
322
|
+
parent_job_id: Optional[int] = None,
|
|
323
|
+
timeout_seconds: Optional[int] = None,
|
|
324
|
+
) -> "Job":
|
|
325
|
+
"""Create a new job with default values (id=0 until assigned by DB)."""
|
|
326
|
+
return cls(
|
|
327
|
+
id=0,
|
|
328
|
+
payload=payload,
|
|
329
|
+
pagina_id=pagina_id,
|
|
330
|
+
priority=priority,
|
|
331
|
+
max_tentativas=max_retries,
|
|
332
|
+
retry_delay=retry_delay,
|
|
333
|
+
tags=tags or [],
|
|
334
|
+
metadata=metadata or {},
|
|
335
|
+
parent_job_id=parent_job_id,
|
|
336
|
+
timeout_seconds=timeout_seconds,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
def __repr__(self) -> str:
|
|
340
|
+
return f"Job(id={self.id}, status={self.status_str}, priority={self.priority_int})"
|
queue_max/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Utility modules for Robusta Queue."""
|
|
2
|
+
|
|
3
|
+
from queue_max.utils.helpers import (
|
|
4
|
+
backoff_delay,
|
|
5
|
+
determine_shard,
|
|
6
|
+
get_env_int,
|
|
7
|
+
is_retryable_error,
|
|
8
|
+
now_iso,
|
|
9
|
+
parse_iso,
|
|
10
|
+
validate_payload,
|
|
11
|
+
validate_priority,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"now_iso",
|
|
16
|
+
"parse_iso",
|
|
17
|
+
"validate_payload",
|
|
18
|
+
"validate_priority",
|
|
19
|
+
"get_env_int",
|
|
20
|
+
"backoff_delay",
|
|
21
|
+
"determine_shard",
|
|
22
|
+
"is_retryable_error",
|
|
23
|
+
]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Utility helpers for Robusta Queue."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import random
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def now_iso() -> str:
|
|
11
|
+
"""Return current UTC timestamp in ISO format."""
|
|
12
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_iso(value: Optional[str]) -> Optional[datetime]:
|
|
16
|
+
"""Parse an ISO timestamp string to datetime."""
|
|
17
|
+
if not value:
|
|
18
|
+
return None
|
|
19
|
+
try:
|
|
20
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
21
|
+
except (ValueError, AttributeError):
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def validate_payload(payload: Any) -> Dict[str, Any]:
|
|
26
|
+
"""Validate and sanitize a job payload.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
payload: The payload to validate.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
The validated payload as a dict.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
ValueError: If payload is not a dict, too large, or too deep.
|
|
36
|
+
"""
|
|
37
|
+
if not isinstance(payload, dict):
|
|
38
|
+
raise ValueError(f"Payload must be a dict, got {type(payload).__name__}")
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
raw = json.dumps(payload)
|
|
42
|
+
except (TypeError, ValueError) as e:
|
|
43
|
+
raise ValueError(f"Payload must be JSON-serializable: {e}")
|
|
44
|
+
if len(raw) > 10 * 1024 * 1024:
|
|
45
|
+
raise ValueError(f"Payload too large: {len(raw)} bytes (max 10MB)")
|
|
46
|
+
|
|
47
|
+
def _check_depth(obj, depth=0):
|
|
48
|
+
if depth > 20:
|
|
49
|
+
raise ValueError("Payload nesting exceeds 20 levels")
|
|
50
|
+
if isinstance(obj, dict):
|
|
51
|
+
for v in obj.values():
|
|
52
|
+
_check_depth(v, depth + 1)
|
|
53
|
+
elif isinstance(obj, (list, tuple)):
|
|
54
|
+
for v in obj:
|
|
55
|
+
_check_depth(v, depth + 1)
|
|
56
|
+
|
|
57
|
+
_check_depth(payload)
|
|
58
|
+
return payload
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def validate_priority(priority: int) -> int:
|
|
62
|
+
"""Validate priority value.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
priority: Priority value (0, 1, or 2).
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The validated priority.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
ValueError: If priority is not 0, 1, or 2.
|
|
72
|
+
"""
|
|
73
|
+
if priority not in (0, 1, 2):
|
|
74
|
+
raise ValueError(f"Priority must be 0, 1, or 2, got {priority}")
|
|
75
|
+
return priority
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_env_int(name: str, default: int) -> int:
|
|
79
|
+
"""Get an integer from an environment variable with a fallback default."""
|
|
80
|
+
try:
|
|
81
|
+
return int(os.environ.get(name, default))
|
|
82
|
+
except (ValueError, TypeError):
|
|
83
|
+
return default
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def backoff_delay(tentativa: int, base_delay: int = 60, max_delay: int = 3600) -> float:
|
|
87
|
+
"""Calculate exponential backoff delay with jitter.
|
|
88
|
+
|
|
89
|
+
Formula: delay = base * 2^(tentativa - 1)
|
|
90
|
+
Jitter: +-20%
|
|
91
|
+
Cap: max_delay (default 3600s = 1 hour)
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
tentativa: Current attempt number (1-based).
|
|
95
|
+
base_delay: Base delay in seconds (default: 60).
|
|
96
|
+
max_delay: Maximum delay in seconds (default: 3600).
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Delay in seconds with jitter applied.
|
|
100
|
+
"""
|
|
101
|
+
delay = base_delay * (2 ** (tentativa - 1))
|
|
102
|
+
jitter = delay * 0.2
|
|
103
|
+
delay = delay + random.uniform(-jitter, jitter)
|
|
104
|
+
return min(delay, max_delay)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def determine_shard(
|
|
108
|
+
pagina_id: Optional[int],
|
|
109
|
+
num_shards: int,
|
|
110
|
+
) -> int:
|
|
111
|
+
"""Determine which shard a job should go to.
|
|
112
|
+
|
|
113
|
+
If pagina_id is provided, shard = pagina_id % num_shards for consistency.
|
|
114
|
+
Otherwise, a random shard is chosen.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
pagina_id: Optional ID for consistent sharding.
|
|
118
|
+
num_shards: Total number of shards.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Shard ID (0 to num_shards - 1).
|
|
122
|
+
"""
|
|
123
|
+
if pagina_id is not None:
|
|
124
|
+
return pagina_id % num_shards
|
|
125
|
+
return random.randint(0, num_shards - 1)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def is_retryable_error(error: Exception) -> bool:
|
|
129
|
+
"""Determine if an error should trigger a retry.
|
|
130
|
+
|
|
131
|
+
4xx errors (client) are permanent failures.
|
|
132
|
+
5xx errors (server), 429 (rate limit), timeouts, and connection errors are retryable.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
error: The exception to evaluate.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
True if the job should be retried, False for permanent failure.
|
|
139
|
+
"""
|
|
140
|
+
import re
|
|
141
|
+
|
|
142
|
+
error_str = str(error).lower()
|
|
143
|
+
error_type = type(error).__name__.lower()
|
|
144
|
+
|
|
145
|
+
# 4xx status codes (except 429) are permanent client errors
|
|
146
|
+
http_code_match = re.search(r"\b(4\d\d)\b", error_str)
|
|
147
|
+
if http_code_match and http_code_match.group(1) != "429":
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
# Connection, timeout, rate-limit, and server errors are retryable
|
|
151
|
+
retryable_keywords = ["timeout", "connection", "temporary", "retryable", "500", "502", "503", "429"]
|
|
152
|
+
for keyword in retryable_keywords:
|
|
153
|
+
if keyword in error_str or keyword in error_type:
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
return True
|