django-lambda-tasks 0.4.10__tar.gz → 0.4.11__tar.gz
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.
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/steering/product.md +16 -33
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/PKG-INFO +21 -47
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/README.md +20 -46
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/lambda_tasks/decorators.py +13 -53
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/lambda_tasks/models.py +9 -2
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/pyproject.toml +1 -1
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/tests/test_decorator.py +7 -98
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/tests/test_decorators.py +76 -57
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/tests/test_deferred_enqueue.py +5 -5
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/tests/test_kwargs_only.py +1 -1
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/tests/test_models.py +51 -279
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.github/workflows/ci.yml +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.github/workflows/release.yml +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.gitignore +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/async-local-execution/.config.kiro +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/async-local-execution/design.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/async-local-execution/requirements.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/async-local-execution/tasks.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/deferred-task-enqueue/.config.kiro +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/deferred-task-enqueue/design.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/deferred-task-enqueue/requirements.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/deferred-task-enqueue/tasks.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/eager-mode-example-app/.config.kiro +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/eager-mode-example-app/design.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/eager-mode-example-app/requirements.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/eager-mode-example-app/tasks.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/ignore-errors-decorator-option/.config.kiro +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/ignore-errors-decorator-option/design.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/ignore-errors-decorator-option/requirements.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/ignore-errors-decorator-option/tasks.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/import-string-task-resolution/.config.kiro +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/import-string-task-resolution/design.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/import-string-task-resolution/requirements.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/import-string-task-resolution/tasks.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/retry-delay/.config.kiro +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/retry-delay/design.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/retry-delay/requirements.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/retry-delay/tasks.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/rse-background-tasks/.config.kiro +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/rse-background-tasks/design.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/rse-background-tasks/requirements.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/rse-background-tasks/tasks.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/rse-background-tasks-bugfix/.config.kiro +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/rse-background-tasks-bugfix/bugfix.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/rse-background-tasks-bugfix/design.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/rse-background-tasks-bugfix/tasks.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/singleton-task/.config.kiro +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/singleton-task/design.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/singleton-task/requirements.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/singleton-task/tasks.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/ssm-environment-loader/.config.kiro +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/ssm-environment-loader/design.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/ssm-environment-loader/requirements.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/ssm-environment-loader/tasks.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/task-retry/.config.kiro +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/task-retry/design.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/task-retry/requirements.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/specs/task-retry/tasks.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/steering/structure.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.kiro/steering/tech.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.pre-commit-config.yaml +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/.vscode/settings.json +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/example/README.md +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/example/example_app/__init__.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/example/example_app/apps.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/example/example_app/tasks.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/example/example_app/urls.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/example/example_app/views.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/example/example_project/__init__.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/example/example_project/settings.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/example/example_project/urls.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/example/example_project/wsgi.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/example/manage.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/lambda_tasks/__init__.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/lambda_tasks/admin.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/lambda_tasks/apps.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/lambda_tasks/environment_loader.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/lambda_tasks/handler.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/lambda_tasks/local_executor.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/lambda_tasks/logging.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/lambda_tasks/migrations/0001_initial.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/lambda_tasks/migrations/0002_alter_taskrecord_status.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/lambda_tasks/migrations/__init__.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/lambda_tasks/secret_loader.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/lambda_tasks/settings.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/lambda_tasks/tasks.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/lambda_tasks/timeouts.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/tests/conftest.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/tests/settings.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/tests/test_admin.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/tests/test_environment_loader.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/tests/test_handler.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/tests/test_local_executor.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/tests/test_logging.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/tests/test_memory_limit.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/tests/test_secret_loader.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/tests/test_serializer.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/tests/test_settings.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/tests/test_tasks.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/tests/test_timeout_validation.py +0 -0
- {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.4.11}/tests/test_timeouts.py +0 -0
|
@@ -24,10 +24,9 @@ Key modules:
|
|
|
24
24
|
|
|
25
25
|
```python
|
|
26
26
|
# Always kwargs-only — positional args raise TypeError at decoration time
|
|
27
|
-
@lambda_task(
|
|
28
|
-
ignore_errors=(SomeExpectedException,),
|
|
27
|
+
@lambda_task(retry_delay=30, soft_timeout=60, hard_timeout=120, queue="default",
|
|
29
28
|
retry_on=(TransientError,),
|
|
30
|
-
singleton=True)
|
|
29
|
+
singleton=True, retry_singleton=True)
|
|
31
30
|
def my_task(*, user_id: int, action: str) -> None:
|
|
32
31
|
...
|
|
33
32
|
```
|
|
@@ -39,34 +38,14 @@ def my_task(*, user_id: int, action: str) -> None:
|
|
|
39
38
|
|
|
40
39
|
## Enqueuing
|
|
41
40
|
|
|
42
|
-
`execute_on_commit()`
|
|
41
|
+
`execute_on_commit()` defaults to delay 0. Pass `_delay=<seconds>` at call time to set the SQS delay for that specific enqueue:
|
|
43
42
|
|
|
44
43
|
```python
|
|
45
|
-
my_task.execute_on_commit(user_id=1, action="x") #
|
|
46
|
-
my_task.execute_on_commit(user_id=1, action="x", _delay=60) #
|
|
44
|
+
my_task.execute_on_commit(user_id=1, action="x") # delay 0
|
|
45
|
+
my_task.execute_on_commit(user_id=1, action="x", _delay=60) # 60s delay
|
|
47
46
|
```
|
|
48
47
|
|
|
49
|
-
The `_delay` override is validated against the
|
|
50
|
-
|
|
51
|
-
## ignore_errors
|
|
52
|
-
|
|
53
|
-
Pass a tuple of exception types to `ignore_errors` on `@lambda_task`. If the task raises an instance of any of those types (or a subclass), the executor treats it as a non-fatal outcome:
|
|
54
|
-
|
|
55
|
-
- `TaskRecord.status` is set to `SUCCESS`
|
|
56
|
-
- The exception traceback is saved to `TaskRecord.traceback` for observability
|
|
57
|
-
- Task-side ORM writes inside the `transaction.atomic()` block are still rolled back
|
|
58
|
-
- The `TaskRecord` update itself is committed outside the atomic block
|
|
59
|
-
|
|
60
|
-
Exceptions not listed in `ignore_errors` continue to produce `FAILED` with a rollback. Omitting `ignore_errors` (or passing `()`) preserves the existing behaviour.
|
|
61
|
-
|
|
62
|
-
```python
|
|
63
|
-
@lambda_task(ignore_errors=(RecordNotFound,))
|
|
64
|
-
def sync_user(*, user_id: int) -> None:
|
|
65
|
-
# RecordNotFound → SUCCESS + traceback recorded; anything else → FAILED
|
|
66
|
-
...
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
`ignore_errors` is validated at decoration time — passing a non-exception type raises `TypeError` immediately. It is stored on `LambdaTaskWrapper` and read by the executor at execution time; it is never serialised into the SQS message.
|
|
48
|
+
The `_delay` override is validated against the range `[0, 900]`. It only affects the SQS `DelaySeconds` — it has no effect in eager or async-local mode.
|
|
70
49
|
|
|
71
50
|
## retry_on
|
|
72
51
|
|
|
@@ -75,8 +54,6 @@ Pass a tuple of exception types to `retry_on` on `@lambda_task`. If the task rai
|
|
|
75
54
|
- `TaskRecord.status` is set to `RETRYING` and the traceback is recorded
|
|
76
55
|
- The retry is a new invocation — the current record is terminal at `RETRYING`
|
|
77
56
|
- Retries continue until `n_retries` reaches `LAMBDA_TASKS_MAX_RETRIES`, at which point `MaxRetriesExceededError` is raised and the record is saved as `FAILED`
|
|
78
|
-
- `ignore_errors` is checked first — a type in both `ignore_errors` and `retry_on` is treated as ignored (SUCCESS), not retried
|
|
79
|
-
- `retry_on` and `ignore_errors` must not overlap (exact match or subclass relationship); overlapping raises `TypeError` at decoration time
|
|
80
57
|
|
|
81
58
|
```python
|
|
82
59
|
@lambda_task(retry_on=(RateLimitError, ConnectionError))
|
|
@@ -97,7 +74,8 @@ Pass `singleton=True` on `@lambda_task` to prevent concurrent execution of the s
|
|
|
97
74
|
|
|
98
75
|
- Lock key format: `lambda_tasks.singleton_lock.{task_name}`
|
|
99
76
|
- The lock is acquired with `blocking_timeout=0` (fail immediately if held) and `timeout=hard_timeout` (auto-expire if the worker crashes)
|
|
100
|
-
- If the lock cannot be acquired (`LockError`), the executor treats it as a retryable exception — same code path as `retry_on`. The `TaskRecord` is set to `RETRYING`, the traceback is recorded, and the task is re-enqueued with `n_retries + 1`
|
|
77
|
+
- If the lock cannot be acquired (`LockError`) and `retry_singleton=True` (default), the executor treats it as a retryable exception — same code path as `retry_on`. The `TaskRecord` is set to `RETRYING`, the traceback is recorded, and the task is re-enqueued with `n_retries + 1`
|
|
78
|
+
- If `retry_singleton=False`, lock contention is treated as a successful no-op — `TaskRecord` is set to `SUCCESS` with the traceback recorded, and no retry is enqueued
|
|
101
79
|
- If `n_retries` has reached `LAMBDA_TASKS_MAX_RETRIES`, `MaxRetriesExceededError` is raised and the record is saved as `FAILED`
|
|
102
80
|
- The cache backend used for locks is controlled by `LAMBDA_TASKS_SINGLETON_CACHE` (default `"default"`)
|
|
103
81
|
|
|
@@ -106,9 +84,14 @@ Pass `singleton=True` on `@lambda_task` to prevent concurrent execution of the s
|
|
|
106
84
|
def sync_inventory(*, warehouse_id: int) -> None:
|
|
107
85
|
# Only one instance runs at a time; LockError → RETRYING + re-enqueued
|
|
108
86
|
...
|
|
87
|
+
|
|
88
|
+
@lambda_task(singleton=True, retry_singleton=False)
|
|
89
|
+
def sync_inventory(*, warehouse_id: int) -> None:
|
|
90
|
+
# Only one instance runs at a time; LockError → SUCCESS + traceback (no retry)
|
|
91
|
+
...
|
|
109
92
|
```
|
|
110
93
|
|
|
111
|
-
`singleton`
|
|
94
|
+
`singleton` and `retry_singleton` are stored on `LambdaTaskWrapper` and read by the executor at execution time; they are never serialised into the SQS message.
|
|
112
95
|
|
|
113
96
|
## SQS Message Schema (`SQSLambdaTaskMessage`)
|
|
114
97
|
|
|
@@ -130,8 +113,8 @@ class SQSLambdaTaskMessage(BaseModel):
|
|
|
130
113
|
4. If `wrapper.singleton` is `True`, acquires a Redis lock via `caches[SINGLETON_CACHE].lock(lock_key)` wrapping the atomic block; if `False`, no lock is acquired
|
|
131
114
|
5. Runs task inside `transaction.atomic()` + `TimeoutContext`
|
|
132
115
|
6. On success: updates record to `SUCCESS` with result and `end_time`
|
|
133
|
-
7. On
|
|
134
|
-
8. On retryable exception (type matches `retry_on` or `LockError` for singleton tasks
|
|
116
|
+
7. On `LockError` when `singleton=True` and `retry_singleton=False`: rolls back task-side writes, commits record as `SUCCESS` with traceback and `end_time`
|
|
117
|
+
8. On retryable exception (type matches `retry_on` or `LockError` for singleton tasks with `retry_singleton=True`, `n_retries < MAX_RETRIES`): rolls back task-side writes, enqueues retry via `execute_on_commit` with `n_retries + 1`, commits record as `RETRYING` with traceback and `end_time`
|
|
135
118
|
9. On retryable exception with `n_retries >= MAX_RETRIES`: commits record as `FAILED` with traceback, raises `MaxRetriesExceededError`
|
|
136
119
|
10. On any other exception: rolls back atomic block, updates record to `FAILED` with traceback and `end_time`
|
|
137
120
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-lambda-tasks
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.11
|
|
4
4
|
Summary: Run async tasks in a lambda function
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Requires-Dist: awslambdaric
|
|
@@ -74,7 +74,7 @@ def register(request):
|
|
|
74
74
|
You can override the delay for a specific enqueue by passing `_delay`:
|
|
75
75
|
|
|
76
76
|
```python
|
|
77
|
-
# Delay this particular invocation by 60 seconds
|
|
77
|
+
# Delay this particular invocation by 60 seconds
|
|
78
78
|
send_welcome_email.execute_on_commit(user_id=user.id, template="welcome", _delay=60)
|
|
79
79
|
```
|
|
80
80
|
|
|
@@ -207,10 +207,10 @@ Key characteristics:
|
|
|
207
207
|
soft_timeout=60, # seconds — overrides global default for this task
|
|
208
208
|
hard_timeout=90, # seconds — overrides global default for this task
|
|
209
209
|
queue="default", # named queue from LAMBDA_TASKS_QUEUES
|
|
210
|
-
ignore_errors=(), # exception types treated as non-fatal
|
|
211
210
|
retry_on=(), # exception types that trigger automatic retry
|
|
212
211
|
retry_delay=0, # base retry delay in seconds (jitter always added, capped at 900)
|
|
213
212
|
singleton=False, # prevent concurrent execution via Redis lock
|
|
213
|
+
retry_singleton=True, # retry on LockError for singleton tasks (or treat as success if False)
|
|
214
214
|
)
|
|
215
215
|
def my_task(*, arg: str) -> None:
|
|
216
216
|
...
|
|
@@ -218,32 +218,31 @@ def my_task(*, arg: str) -> None:
|
|
|
218
218
|
|
|
219
219
|
| Parameter | Type | Default | Description |
|
|
220
220
|
|---|---|---|---|
|
|
221
|
-
| `delay` | `int` | `0` | Seconds to delay the SQS message before it becomes visible to consumers (max 900). |
|
|
222
221
|
| `soft_timeout` | `int \| None` | `None` (uses global default) | Per-task soft timeout in seconds (max 900). |
|
|
223
222
|
| `hard_timeout` | `int \| None` | `None` (uses global default) | Per-task hard timeout in seconds (max 900). |
|
|
224
223
|
| `queue` | `str` | `"default"` | Named queue to route this task to. |
|
|
225
|
-
| `ignore_errors` | `tuple[type[BaseException], ...]` | `()` | Exception types to treat as non-fatal (see [Ignored exceptions](#ignored-exceptions)). |
|
|
226
224
|
| `retry_on` | `tuple[type[BaseException], ...]` | `()` | Exception types that trigger an automatic retry (see [Automatic retries](#automatic-retries)). |
|
|
227
225
|
| `retry_delay` | `int` | `0` | Base delay in seconds when enqueuing a retry. Jitter (1–5s) is always added; result capped at 900. Requires `retry_on` to be non-empty. |
|
|
228
226
|
| `singleton` | `bool` | `False` | Prevent concurrent execution via a Redis lock (see [Singleton tasks](#singleton-tasks)). |
|
|
227
|
+
| `retry_singleton` | `bool` | `True` | When `True`, `LockError` on a singleton task triggers a retry. When `False`, lock contention is treated as a successful no-op (traceback recorded). |
|
|
229
228
|
|
|
230
229
|
### Per-call delay override
|
|
231
230
|
|
|
232
|
-
|
|
231
|
+
By default, `execute_on_commit()` uses a delay of 0 seconds. To set the SQS `DelaySeconds` for a specific call, pass `_delay` to `execute_on_commit()` or `serialize()`:
|
|
233
232
|
|
|
234
233
|
```python
|
|
235
|
-
@lambda_task
|
|
234
|
+
@lambda_task
|
|
236
235
|
def notify_user(*, user_id: int) -> None:
|
|
237
236
|
...
|
|
238
237
|
|
|
239
|
-
#
|
|
238
|
+
# Default (0 seconds delay)
|
|
240
239
|
notify_user.execute_on_commit(user_id=1)
|
|
241
240
|
|
|
242
|
-
#
|
|
241
|
+
# Delay this specific invocation by 120 seconds
|
|
243
242
|
notify_user.execute_on_commit(user_id=1, _delay=120)
|
|
244
243
|
```
|
|
245
244
|
|
|
246
|
-
`_delay` is validated against the
|
|
245
|
+
`_delay` is validated against the range `[0, 900]`. It only affects the SQS `DelaySeconds` — it has no effect in eager or async-local mode.
|
|
247
246
|
|
|
248
247
|
---
|
|
249
248
|
|
|
@@ -386,35 +385,6 @@ fields @timestamp, @message
|
|
|
386
385
|
|
|
387
386
|
---
|
|
388
387
|
|
|
389
|
-
## Ignored exceptions
|
|
390
|
-
|
|
391
|
-
Pass a tuple of exception types to `ignore_errors` on `@lambda_task`. If the task raises an instance of any listed type (or a subclass), the executor treats it as a non-fatal outcome:
|
|
392
|
-
|
|
393
|
-
- `TaskRecord.status` is set to `SUCCESS`
|
|
394
|
-
- The exception traceback is saved to `TaskRecord.traceback` for observability
|
|
395
|
-
- Task-side ORM writes inside the `transaction.atomic()` block are still rolled back
|
|
396
|
-
- The `TaskRecord` update is committed outside the atomic block
|
|
397
|
-
|
|
398
|
-
Exceptions not in `ignore_errors` continue to produce `FAILED` with a rollback. The default (`()`) preserves existing behaviour.
|
|
399
|
-
|
|
400
|
-
```python
|
|
401
|
-
from lambda_tasks.decorators import lambda_task
|
|
402
|
-
|
|
403
|
-
class RecordNotFound(Exception):
|
|
404
|
-
pass
|
|
405
|
-
|
|
406
|
-
@lambda_task(ignore_errors=(RecordNotFound,))
|
|
407
|
-
def sync_user(*, user_id: int) -> None:
|
|
408
|
-
user = fetch_user(user_id) # raises RecordNotFound if already deleted
|
|
409
|
-
update_profile(user)
|
|
410
|
-
```
|
|
411
|
-
|
|
412
|
-
If `sync_user` raises `RecordNotFound`, the `TaskRecord` will have `status=SUCCESS` and the traceback recorded in `traceback`. Any ORM writes made before the exception are rolled back.
|
|
413
|
-
|
|
414
|
-
`ignore_errors` is validated at decoration time — passing a non-exception type raises `TypeError` immediately. It is stored on `LambdaTaskWrapper` and read by the executor at execution time; it is never serialised into the SQS message.
|
|
415
|
-
|
|
416
|
-
---
|
|
417
|
-
|
|
418
388
|
## Automatic retries
|
|
419
389
|
|
|
420
390
|
Pass a tuple of exception types to `retry_on` on `@lambda_task`. If the task raises an instance of any listed type (or a subclass), the executor re-enqueues the task with an incremented retry counter:
|
|
@@ -422,8 +392,6 @@ Pass a tuple of exception types to `retry_on` on `@lambda_task`. If the task rai
|
|
|
422
392
|
- `TaskRecord.status` is set to `RETRYING` and the traceback is recorded
|
|
423
393
|
- The retry is a new invocation — the current record is terminal at `RETRYING`
|
|
424
394
|
- Retries continue until `n_retries` reaches `LAMBDA_TASKS_MAX_RETRIES` (default `2880`), at which point `MaxRetriesExceededError` is raised and the record is saved as `FAILED`
|
|
425
|
-
- `ignore_errors` is checked first — a type in both `ignore_errors` and `retry_on` is treated as ignored (SUCCESS), not retried
|
|
426
|
-
- `retry_on` and `ignore_errors` must not overlap; overlapping raises `TypeError` at decoration time
|
|
427
395
|
|
|
428
396
|
```python
|
|
429
397
|
from lambda_tasks.decorators import lambda_task
|
|
@@ -450,19 +418,27 @@ from lambda_tasks.decorators import lambda_task
|
|
|
450
418
|
|
|
451
419
|
@lambda_task(singleton=True)
|
|
452
420
|
def sync_inventory(*, warehouse_id: int) -> None:
|
|
453
|
-
# Only one instance runs at a time
|
|
421
|
+
# Only one instance runs at a time; LockError → RETRYING + re-enqueued
|
|
454
422
|
...
|
|
455
423
|
```
|
|
456
424
|
|
|
457
425
|
- Lock key format: `lambda_tasks.singleton_lock.{task_name}`
|
|
458
426
|
- The lock is acquired with `blocking_timeout=0` (fail immediately if held) and `timeout=hard_timeout` (auto-expire if the worker crashes)
|
|
459
|
-
- If the lock cannot be acquired (`LockError`), the task is retried
|
|
427
|
+
- If the lock cannot be acquired (`LockError`) and `retry_singleton=True` (the default), the task is retried — `TaskRecord` is set to `RETRYING`, the traceback is recorded, and the task is re-enqueued with `n_retries + 1`
|
|
428
|
+
- If `retry_singleton=False`, lock contention is treated as a successful no-op — `TaskRecord` is set to `SUCCESS` with the traceback recorded, and no retry is enqueued
|
|
460
429
|
- If `n_retries` reaches `LAMBDA_TASKS_MAX_RETRIES`, `MaxRetriesExceededError` is raised and the record is saved as `FAILED`
|
|
461
430
|
- The cache backend used for locks is controlled by `LAMBDA_TASKS_SINGLETON_CACHE` (default `"default"`)
|
|
462
431
|
|
|
463
|
-
`LockError` is
|
|
432
|
+
`LockError` is handled automatically for singleton tasks — do not include it in `retry_on` (doing so raises `TypeError` at decoration time).
|
|
433
|
+
|
|
434
|
+
`singleton` and `retry_singleton` are stored on `LambdaTaskWrapper` and read by the executor at execution time; they are never serialised into the SQS message.
|
|
464
435
|
|
|
465
|
-
|
|
436
|
+
```python
|
|
437
|
+
@lambda_task(singleton=True, retry_singleton=False)
|
|
438
|
+
def sync_inventory(*, warehouse_id: int) -> None:
|
|
439
|
+
# Lock contention → SUCCESS with traceback (no retry)
|
|
440
|
+
...
|
|
441
|
+
```
|
|
466
442
|
|
|
467
443
|
---
|
|
468
444
|
|
|
@@ -470,8 +446,6 @@ def sync_inventory(*, warehouse_id: int) -> None:
|
|
|
470
446
|
|
|
471
447
|
Each task runs inside a `django.db.transaction.atomic` block. If the task raises an unhandled exception, all ORM writes made inside the task are rolled back. The `TaskRecord` failure update is always written outside the atomic block so it survives the rollback.
|
|
472
448
|
|
|
473
|
-
When an exception matches `ignore_errors`, the same rollback applies to task-side writes — the atomic block still exits via exception. Only the `TaskRecord` update (written outside the block) is committed, recording the `SUCCESS` outcome and traceback.
|
|
474
|
-
|
|
475
449
|
---
|
|
476
450
|
|
|
477
451
|
## Error handling
|
|
@@ -62,7 +62,7 @@ def register(request):
|
|
|
62
62
|
You can override the delay for a specific enqueue by passing `_delay`:
|
|
63
63
|
|
|
64
64
|
```python
|
|
65
|
-
# Delay this particular invocation by 60 seconds
|
|
65
|
+
# Delay this particular invocation by 60 seconds
|
|
66
66
|
send_welcome_email.execute_on_commit(user_id=user.id, template="welcome", _delay=60)
|
|
67
67
|
```
|
|
68
68
|
|
|
@@ -195,10 +195,10 @@ Key characteristics:
|
|
|
195
195
|
soft_timeout=60, # seconds — overrides global default for this task
|
|
196
196
|
hard_timeout=90, # seconds — overrides global default for this task
|
|
197
197
|
queue="default", # named queue from LAMBDA_TASKS_QUEUES
|
|
198
|
-
ignore_errors=(), # exception types treated as non-fatal
|
|
199
198
|
retry_on=(), # exception types that trigger automatic retry
|
|
200
199
|
retry_delay=0, # base retry delay in seconds (jitter always added, capped at 900)
|
|
201
200
|
singleton=False, # prevent concurrent execution via Redis lock
|
|
201
|
+
retry_singleton=True, # retry on LockError for singleton tasks (or treat as success if False)
|
|
202
202
|
)
|
|
203
203
|
def my_task(*, arg: str) -> None:
|
|
204
204
|
...
|
|
@@ -206,32 +206,31 @@ def my_task(*, arg: str) -> None:
|
|
|
206
206
|
|
|
207
207
|
| Parameter | Type | Default | Description |
|
|
208
208
|
|---|---|---|---|
|
|
209
|
-
| `delay` | `int` | `0` | Seconds to delay the SQS message before it becomes visible to consumers (max 900). |
|
|
210
209
|
| `soft_timeout` | `int \| None` | `None` (uses global default) | Per-task soft timeout in seconds (max 900). |
|
|
211
210
|
| `hard_timeout` | `int \| None` | `None` (uses global default) | Per-task hard timeout in seconds (max 900). |
|
|
212
211
|
| `queue` | `str` | `"default"` | Named queue to route this task to. |
|
|
213
|
-
| `ignore_errors` | `tuple[type[BaseException], ...]` | `()` | Exception types to treat as non-fatal (see [Ignored exceptions](#ignored-exceptions)). |
|
|
214
212
|
| `retry_on` | `tuple[type[BaseException], ...]` | `()` | Exception types that trigger an automatic retry (see [Automatic retries](#automatic-retries)). |
|
|
215
213
|
| `retry_delay` | `int` | `0` | Base delay in seconds when enqueuing a retry. Jitter (1–5s) is always added; result capped at 900. Requires `retry_on` to be non-empty. |
|
|
216
214
|
| `singleton` | `bool` | `False` | Prevent concurrent execution via a Redis lock (see [Singleton tasks](#singleton-tasks)). |
|
|
215
|
+
| `retry_singleton` | `bool` | `True` | When `True`, `LockError` on a singleton task triggers a retry. When `False`, lock contention is treated as a successful no-op (traceback recorded). |
|
|
217
216
|
|
|
218
217
|
### Per-call delay override
|
|
219
218
|
|
|
220
|
-
|
|
219
|
+
By default, `execute_on_commit()` uses a delay of 0 seconds. To set the SQS `DelaySeconds` for a specific call, pass `_delay` to `execute_on_commit()` or `serialize()`:
|
|
221
220
|
|
|
222
221
|
```python
|
|
223
|
-
@lambda_task
|
|
222
|
+
@lambda_task
|
|
224
223
|
def notify_user(*, user_id: int) -> None:
|
|
225
224
|
...
|
|
226
225
|
|
|
227
|
-
#
|
|
226
|
+
# Default (0 seconds delay)
|
|
228
227
|
notify_user.execute_on_commit(user_id=1)
|
|
229
228
|
|
|
230
|
-
#
|
|
229
|
+
# Delay this specific invocation by 120 seconds
|
|
231
230
|
notify_user.execute_on_commit(user_id=1, _delay=120)
|
|
232
231
|
```
|
|
233
232
|
|
|
234
|
-
`_delay` is validated against the
|
|
233
|
+
`_delay` is validated against the range `[0, 900]`. It only affects the SQS `DelaySeconds` — it has no effect in eager or async-local mode.
|
|
235
234
|
|
|
236
235
|
---
|
|
237
236
|
|
|
@@ -374,35 +373,6 @@ fields @timestamp, @message
|
|
|
374
373
|
|
|
375
374
|
---
|
|
376
375
|
|
|
377
|
-
## Ignored exceptions
|
|
378
|
-
|
|
379
|
-
Pass a tuple of exception types to `ignore_errors` on `@lambda_task`. If the task raises an instance of any listed type (or a subclass), the executor treats it as a non-fatal outcome:
|
|
380
|
-
|
|
381
|
-
- `TaskRecord.status` is set to `SUCCESS`
|
|
382
|
-
- The exception traceback is saved to `TaskRecord.traceback` for observability
|
|
383
|
-
- Task-side ORM writes inside the `transaction.atomic()` block are still rolled back
|
|
384
|
-
- The `TaskRecord` update is committed outside the atomic block
|
|
385
|
-
|
|
386
|
-
Exceptions not in `ignore_errors` continue to produce `FAILED` with a rollback. The default (`()`) preserves existing behaviour.
|
|
387
|
-
|
|
388
|
-
```python
|
|
389
|
-
from lambda_tasks.decorators import lambda_task
|
|
390
|
-
|
|
391
|
-
class RecordNotFound(Exception):
|
|
392
|
-
pass
|
|
393
|
-
|
|
394
|
-
@lambda_task(ignore_errors=(RecordNotFound,))
|
|
395
|
-
def sync_user(*, user_id: int) -> None:
|
|
396
|
-
user = fetch_user(user_id) # raises RecordNotFound if already deleted
|
|
397
|
-
update_profile(user)
|
|
398
|
-
```
|
|
399
|
-
|
|
400
|
-
If `sync_user` raises `RecordNotFound`, the `TaskRecord` will have `status=SUCCESS` and the traceback recorded in `traceback`. Any ORM writes made before the exception are rolled back.
|
|
401
|
-
|
|
402
|
-
`ignore_errors` is validated at decoration time — passing a non-exception type raises `TypeError` immediately. It is stored on `LambdaTaskWrapper` and read by the executor at execution time; it is never serialised into the SQS message.
|
|
403
|
-
|
|
404
|
-
---
|
|
405
|
-
|
|
406
376
|
## Automatic retries
|
|
407
377
|
|
|
408
378
|
Pass a tuple of exception types to `retry_on` on `@lambda_task`. If the task raises an instance of any listed type (or a subclass), the executor re-enqueues the task with an incremented retry counter:
|
|
@@ -410,8 +380,6 @@ Pass a tuple of exception types to `retry_on` on `@lambda_task`. If the task rai
|
|
|
410
380
|
- `TaskRecord.status` is set to `RETRYING` and the traceback is recorded
|
|
411
381
|
- The retry is a new invocation — the current record is terminal at `RETRYING`
|
|
412
382
|
- Retries continue until `n_retries` reaches `LAMBDA_TASKS_MAX_RETRIES` (default `2880`), at which point `MaxRetriesExceededError` is raised and the record is saved as `FAILED`
|
|
413
|
-
- `ignore_errors` is checked first — a type in both `ignore_errors` and `retry_on` is treated as ignored (SUCCESS), not retried
|
|
414
|
-
- `retry_on` and `ignore_errors` must not overlap; overlapping raises `TypeError` at decoration time
|
|
415
383
|
|
|
416
384
|
```python
|
|
417
385
|
from lambda_tasks.decorators import lambda_task
|
|
@@ -438,19 +406,27 @@ from lambda_tasks.decorators import lambda_task
|
|
|
438
406
|
|
|
439
407
|
@lambda_task(singleton=True)
|
|
440
408
|
def sync_inventory(*, warehouse_id: int) -> None:
|
|
441
|
-
# Only one instance runs at a time
|
|
409
|
+
# Only one instance runs at a time; LockError → RETRYING + re-enqueued
|
|
442
410
|
...
|
|
443
411
|
```
|
|
444
412
|
|
|
445
413
|
- Lock key format: `lambda_tasks.singleton_lock.{task_name}`
|
|
446
414
|
- The lock is acquired with `blocking_timeout=0` (fail immediately if held) and `timeout=hard_timeout` (auto-expire if the worker crashes)
|
|
447
|
-
- If the lock cannot be acquired (`LockError`), the task is retried
|
|
415
|
+
- If the lock cannot be acquired (`LockError`) and `retry_singleton=True` (the default), the task is retried — `TaskRecord` is set to `RETRYING`, the traceback is recorded, and the task is re-enqueued with `n_retries + 1`
|
|
416
|
+
- If `retry_singleton=False`, lock contention is treated as a successful no-op — `TaskRecord` is set to `SUCCESS` with the traceback recorded, and no retry is enqueued
|
|
448
417
|
- If `n_retries` reaches `LAMBDA_TASKS_MAX_RETRIES`, `MaxRetriesExceededError` is raised and the record is saved as `FAILED`
|
|
449
418
|
- The cache backend used for locks is controlled by `LAMBDA_TASKS_SINGLETON_CACHE` (default `"default"`)
|
|
450
419
|
|
|
451
|
-
`LockError` is
|
|
420
|
+
`LockError` is handled automatically for singleton tasks — do not include it in `retry_on` (doing so raises `TypeError` at decoration time).
|
|
421
|
+
|
|
422
|
+
`singleton` and `retry_singleton` are stored on `LambdaTaskWrapper` and read by the executor at execution time; they are never serialised into the SQS message.
|
|
452
423
|
|
|
453
|
-
|
|
424
|
+
```python
|
|
425
|
+
@lambda_task(singleton=True, retry_singleton=False)
|
|
426
|
+
def sync_inventory(*, warehouse_id: int) -> None:
|
|
427
|
+
# Lock contention → SUCCESS with traceback (no retry)
|
|
428
|
+
...
|
|
429
|
+
```
|
|
454
430
|
|
|
455
431
|
---
|
|
456
432
|
|
|
@@ -458,8 +434,6 @@ def sync_inventory(*, warehouse_id: int) -> None:
|
|
|
458
434
|
|
|
459
435
|
Each task runs inside a `django.db.transaction.atomic` block. If the task raises an unhandled exception, all ORM writes made inside the task are rolled back. The `TaskRecord` failure update is always written outside the atomic block so it survives the rollback.
|
|
460
436
|
|
|
461
|
-
When an exception matches `ignore_errors`, the same rollback applies to task-side writes — the atomic block still exits via exception. Only the `TaskRecord` update (written outside the block) is committed, recording the `SUCCESS` outcome and traceback.
|
|
462
|
-
|
|
463
437
|
---
|
|
464
438
|
|
|
465
439
|
## Error handling
|
|
@@ -11,7 +11,6 @@ Also provides the lambda_task decorator factory.
|
|
|
11
11
|
import functools
|
|
12
12
|
import inspect
|
|
13
13
|
import types
|
|
14
|
-
import uuid
|
|
15
14
|
from collections.abc import Callable
|
|
16
15
|
from typing import Any, overload
|
|
17
16
|
|
|
@@ -65,35 +64,30 @@ class LambdaTaskWrapper:
|
|
|
65
64
|
self,
|
|
66
65
|
func: types.FunctionType,
|
|
67
66
|
*,
|
|
68
|
-
delay: int = 0,
|
|
69
67
|
retry_delay: int = 0,
|
|
70
68
|
soft_timeout: int | None = None,
|
|
71
69
|
hard_timeout: int | None = None,
|
|
72
70
|
queue: str = "default",
|
|
73
|
-
ignore_errors: tuple[type[BaseException], ...] = (),
|
|
74
71
|
retry_on: tuple[type[BaseException], ...] = (),
|
|
75
72
|
singleton: bool = False,
|
|
73
|
+
retry_singleton: bool = True,
|
|
76
74
|
) -> None:
|
|
77
75
|
self._validate_func(func=func)
|
|
78
76
|
self._validate_timeouts(soft_timeout=soft_timeout, hard_timeout=hard_timeout)
|
|
79
|
-
self._validate_delay(delay=delay)
|
|
80
77
|
self._validate_retry_delay(retry_delay=retry_delay, retry_on=retry_on)
|
|
81
|
-
self._validate_ignore_errors(ignore_errors=ignore_errors)
|
|
82
78
|
self._validate_retry_on(retry_on=retry_on)
|
|
83
|
-
self._validate_no_overlap(retry_on=retry_on, ignore_errors=ignore_errors)
|
|
84
79
|
self._validate_singleton(singleton=singleton, retry_on=retry_on)
|
|
85
80
|
|
|
86
81
|
functools.update_wrapper(self, func)
|
|
87
82
|
|
|
88
83
|
self._func: types.FunctionType = func
|
|
89
|
-
self._delay = delay
|
|
90
84
|
self._retry_delay = retry_delay
|
|
91
85
|
self._soft_timeout = soft_timeout
|
|
92
86
|
self._hard_timeout = hard_timeout
|
|
93
87
|
self._queue = queue
|
|
94
|
-
self._ignore_errors = ignore_errors
|
|
95
88
|
self._retry_on = retry_on
|
|
96
89
|
self._singleton = singleton
|
|
90
|
+
self._retry_singleton = retry_singleton
|
|
97
91
|
self._kwargs_model: type[pydantic.BaseModel] = _build_kwargs_model(func)
|
|
98
92
|
|
|
99
93
|
@property
|
|
@@ -162,7 +156,7 @@ class LambdaTaskWrapper:
|
|
|
162
156
|
ValueError: if ``_delay`` is outside the allowed range [0, 900].
|
|
163
157
|
"""
|
|
164
158
|
n_retries = kwargs.pop("_n_retries", 0)
|
|
165
|
-
delay = kwargs.pop("_delay",
|
|
159
|
+
delay = kwargs.pop("_delay", 0)
|
|
166
160
|
|
|
167
161
|
self._validate_delay(delay=delay)
|
|
168
162
|
|
|
@@ -240,11 +234,6 @@ class LambdaTaskWrapper:
|
|
|
240
234
|
"""The SQS queue name this task is routed to."""
|
|
241
235
|
return self._queue
|
|
242
236
|
|
|
243
|
-
@property
|
|
244
|
-
def ignore_errors(self) -> tuple[type[BaseException], ...]:
|
|
245
|
-
"""Exception types that are treated as non-fatal during task execution."""
|
|
246
|
-
return self._ignore_errors
|
|
247
|
-
|
|
248
237
|
@property
|
|
249
238
|
def retry_on(self) -> tuple[type[BaseException], ...]:
|
|
250
239
|
"""Exception types that trigger an automatic retry."""
|
|
@@ -255,6 +244,11 @@ class LambdaTaskWrapper:
|
|
|
255
244
|
"""Whether this task enforces single-concurrency via a Redis lock."""
|
|
256
245
|
return self._singleton
|
|
257
246
|
|
|
247
|
+
@property
|
|
248
|
+
def retry_singleton(self) -> bool:
|
|
249
|
+
"""Whether LockError on a singleton task triggers an automatic retry."""
|
|
250
|
+
return self._retry_singleton
|
|
251
|
+
|
|
258
252
|
@staticmethod
|
|
259
253
|
def _validate_delay(*, delay: int) -> None:
|
|
260
254
|
"""Raise ValueError if delay is outside the allowed range [0, MAX_DELAY]."""
|
|
@@ -279,18 +273,6 @@ class LambdaTaskWrapper:
|
|
|
279
273
|
f"retry_delay only applies when the task is configured to retry."
|
|
280
274
|
)
|
|
281
275
|
|
|
282
|
-
@staticmethod
|
|
283
|
-
def _validate_ignore_errors(
|
|
284
|
-
*, ignore_errors: tuple[type[BaseException], ...]
|
|
285
|
-
) -> None:
|
|
286
|
-
"""Raise TypeError if any element is not a subclass of BaseException."""
|
|
287
|
-
for item in ignore_errors:
|
|
288
|
-
if not (isinstance(item, type) and issubclass(item, BaseException)):
|
|
289
|
-
raise TypeError(
|
|
290
|
-
f"ignore_errors must contain only exception types (subclasses of "
|
|
291
|
-
f"BaseException); got {item!r}."
|
|
292
|
-
)
|
|
293
|
-
|
|
294
276
|
@staticmethod
|
|
295
277
|
def _validate_retry_on(*, retry_on: tuple[type[BaseException], ...]) -> None:
|
|
296
278
|
"""Raise TypeError if any element is not a subclass of BaseException."""
|
|
@@ -301,24 +283,6 @@ class LambdaTaskWrapper:
|
|
|
301
283
|
f"BaseException); got {item!r}."
|
|
302
284
|
)
|
|
303
285
|
|
|
304
|
-
@staticmethod
|
|
305
|
-
def _validate_no_overlap(
|
|
306
|
-
*,
|
|
307
|
-
retry_on: tuple[type[BaseException], ...],
|
|
308
|
-
ignore_errors: tuple[type[BaseException], ...],
|
|
309
|
-
) -> None:
|
|
310
|
-
"""Raise TypeError if any type in retry_on and ignore_errors overlap via subclass."""
|
|
311
|
-
for retry_type in retry_on:
|
|
312
|
-
for ignore_type in ignore_errors:
|
|
313
|
-
if issubclass(retry_type, ignore_type) or issubclass(
|
|
314
|
-
ignore_type, retry_type
|
|
315
|
-
):
|
|
316
|
-
raise TypeError(
|
|
317
|
-
f"retry_on and ignore_errors must not overlap: "
|
|
318
|
-
f"{retry_type.__name__!r} and {ignore_type.__name__!r} conflict "
|
|
319
|
-
f"(one is a subclass of the other)."
|
|
320
|
-
)
|
|
321
|
-
|
|
322
286
|
@staticmethod
|
|
323
287
|
def _validate_timeouts(
|
|
324
288
|
*, soft_timeout: int | None, hard_timeout: int | None
|
|
@@ -368,14 +332,13 @@ class LambdaTaskWrapper:
|
|
|
368
332
|
def lambda_task(
|
|
369
333
|
func: types.FunctionType,
|
|
370
334
|
*,
|
|
371
|
-
delay: int = ...,
|
|
372
335
|
retry_delay: int = ...,
|
|
373
336
|
soft_timeout: int | None = ...,
|
|
374
337
|
hard_timeout: int | None = ...,
|
|
375
338
|
queue: str = ...,
|
|
376
|
-
ignore_errors: tuple[type[BaseException], ...] = ...,
|
|
377
339
|
retry_on: tuple[type[BaseException], ...] = ...,
|
|
378
340
|
singleton: bool = ...,
|
|
341
|
+
retry_singleton: bool = ...,
|
|
379
342
|
) -> LambdaTaskWrapper: ...
|
|
380
343
|
|
|
381
344
|
|
|
@@ -383,28 +346,26 @@ def lambda_task(
|
|
|
383
346
|
def lambda_task(
|
|
384
347
|
func: None = None,
|
|
385
348
|
*,
|
|
386
|
-
delay: int = ...,
|
|
387
349
|
retry_delay: int = ...,
|
|
388
350
|
soft_timeout: int | None = ...,
|
|
389
351
|
hard_timeout: int | None = ...,
|
|
390
352
|
queue: str = ...,
|
|
391
|
-
ignore_errors: tuple[type[BaseException], ...] = ...,
|
|
392
353
|
retry_on: tuple[type[BaseException], ...] = ...,
|
|
393
354
|
singleton: bool = ...,
|
|
355
|
+
retry_singleton: bool = ...,
|
|
394
356
|
) -> Callable[[types.FunctionType], LambdaTaskWrapper]: ...
|
|
395
357
|
|
|
396
358
|
|
|
397
359
|
def lambda_task(
|
|
398
360
|
func: types.FunctionType | None = None,
|
|
399
361
|
*,
|
|
400
|
-
delay: int = 0,
|
|
401
362
|
retry_delay: int = 0,
|
|
402
363
|
soft_timeout: int | None = None,
|
|
403
364
|
hard_timeout: int | None = None,
|
|
404
365
|
queue: str = "default",
|
|
405
|
-
ignore_errors: tuple[type[BaseException], ...] = (),
|
|
406
366
|
retry_on: tuple[type[BaseException], ...] = (),
|
|
407
367
|
singleton: bool = False,
|
|
368
|
+
retry_singleton: bool = True,
|
|
408
369
|
) -> LambdaTaskWrapper | Callable[[types.FunctionType], LambdaTaskWrapper]:
|
|
409
370
|
"""Decorator factory that registers a function as a background task.
|
|
410
371
|
|
|
@@ -413,21 +374,20 @@ def lambda_task(
|
|
|
413
374
|
@lambda_task
|
|
414
375
|
def my_task(*, x: int): ...
|
|
415
376
|
|
|
416
|
-
@lambda_task(
|
|
377
|
+
@lambda_task(soft_timeout=60, hard_timeout=120)
|
|
417
378
|
def my_task(*, x: int): ...
|
|
418
379
|
"""
|
|
419
380
|
|
|
420
381
|
def _decorate(f: types.FunctionType) -> LambdaTaskWrapper:
|
|
421
382
|
wrapper = LambdaTaskWrapper(
|
|
422
383
|
f,
|
|
423
|
-
delay=delay,
|
|
424
384
|
retry_delay=retry_delay,
|
|
425
385
|
soft_timeout=soft_timeout,
|
|
426
386
|
hard_timeout=hard_timeout,
|
|
427
387
|
queue=queue,
|
|
428
|
-
ignore_errors=ignore_errors,
|
|
429
388
|
retry_on=retry_on,
|
|
430
389
|
singleton=singleton,
|
|
390
|
+
retry_singleton=retry_singleton,
|
|
431
391
|
)
|
|
432
392
|
return wrapper
|
|
433
393
|
|
|
@@ -139,7 +139,10 @@ class SQSLambdaTaskMessage(BaseModel):
|
|
|
139
139
|
blocking_timeout=0,
|
|
140
140
|
timeout=hard_timeout,
|
|
141
141
|
)
|
|
142
|
-
|
|
142
|
+
if wrapper.retry_singleton:
|
|
143
|
+
effective_retry_on = (LockError, *wrapper.retry_on)
|
|
144
|
+
else:
|
|
145
|
+
effective_retry_on = wrapper.retry_on
|
|
143
146
|
else:
|
|
144
147
|
lock_ctx = contextlib.nullcontext()
|
|
145
148
|
effective_retry_on = wrapper.retry_on
|
|
@@ -152,7 +155,11 @@ class SQSLambdaTaskMessage(BaseModel):
|
|
|
152
155
|
):
|
|
153
156
|
result = wrapper(**self.kwargs)
|
|
154
157
|
except Exception as error:
|
|
155
|
-
if
|
|
158
|
+
if (
|
|
159
|
+
wrapper.singleton
|
|
160
|
+
and not wrapper.retry_singleton
|
|
161
|
+
and isinstance(error, LockError)
|
|
162
|
+
):
|
|
156
163
|
ignored_exception = error
|
|
157
164
|
ignored_traceback = traceback.format_exc()
|
|
158
165
|
elif effective_retry_on and isinstance(error, effective_retry_on):
|