django-lambda-tasks 0.4.10__tar.gz → 0.5.0__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.
Files changed (101) hide show
  1. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/steering/product.md +20 -37
  2. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/PKG-INFO +39 -65
  3. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/README.md +38 -64
  4. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/lambda_tasks/decorators.py +13 -53
  5. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/lambda_tasks/environment_loader.py +17 -25
  6. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/lambda_tasks/models.py +11 -4
  7. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/lambda_tasks/secret_loader.py +30 -25
  8. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/pyproject.toml +1 -1
  9. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/tests/test_decorator.py +7 -98
  10. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/tests/test_decorators.py +76 -57
  11. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/tests/test_deferred_enqueue.py +5 -5
  12. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/tests/test_environment_loader.py +11 -20
  13. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/tests/test_kwargs_only.py +1 -1
  14. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/tests/test_models.py +51 -279
  15. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/tests/test_secret_loader.py +15 -20
  16. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.github/workflows/ci.yml +0 -0
  17. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.github/workflows/release.yml +0 -0
  18. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.gitignore +0 -0
  19. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/async-local-execution/.config.kiro +0 -0
  20. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/async-local-execution/design.md +0 -0
  21. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/async-local-execution/requirements.md +0 -0
  22. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/async-local-execution/tasks.md +0 -0
  23. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/deferred-task-enqueue/.config.kiro +0 -0
  24. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/deferred-task-enqueue/design.md +0 -0
  25. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/deferred-task-enqueue/requirements.md +0 -0
  26. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/deferred-task-enqueue/tasks.md +0 -0
  27. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/eager-mode-example-app/.config.kiro +0 -0
  28. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/eager-mode-example-app/design.md +0 -0
  29. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/eager-mode-example-app/requirements.md +0 -0
  30. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/eager-mode-example-app/tasks.md +0 -0
  31. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/ignore-errors-decorator-option/.config.kiro +0 -0
  32. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/ignore-errors-decorator-option/design.md +0 -0
  33. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/ignore-errors-decorator-option/requirements.md +0 -0
  34. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/ignore-errors-decorator-option/tasks.md +0 -0
  35. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/import-string-task-resolution/.config.kiro +0 -0
  36. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/import-string-task-resolution/design.md +0 -0
  37. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/import-string-task-resolution/requirements.md +0 -0
  38. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/import-string-task-resolution/tasks.md +0 -0
  39. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/retry-delay/.config.kiro +0 -0
  40. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/retry-delay/design.md +0 -0
  41. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/retry-delay/requirements.md +0 -0
  42. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/retry-delay/tasks.md +0 -0
  43. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/rse-background-tasks/.config.kiro +0 -0
  44. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/rse-background-tasks/design.md +0 -0
  45. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/rse-background-tasks/requirements.md +0 -0
  46. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/rse-background-tasks/tasks.md +0 -0
  47. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/rse-background-tasks-bugfix/.config.kiro +0 -0
  48. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/rse-background-tasks-bugfix/bugfix.md +0 -0
  49. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/rse-background-tasks-bugfix/design.md +0 -0
  50. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/rse-background-tasks-bugfix/tasks.md +0 -0
  51. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/singleton-task/.config.kiro +0 -0
  52. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/singleton-task/design.md +0 -0
  53. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/singleton-task/requirements.md +0 -0
  54. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/singleton-task/tasks.md +0 -0
  55. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/ssm-environment-loader/.config.kiro +0 -0
  56. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/ssm-environment-loader/design.md +0 -0
  57. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/ssm-environment-loader/requirements.md +0 -0
  58. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/ssm-environment-loader/tasks.md +0 -0
  59. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/task-retry/.config.kiro +0 -0
  60. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/task-retry/design.md +0 -0
  61. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/task-retry/requirements.md +0 -0
  62. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/specs/task-retry/tasks.md +0 -0
  63. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/steering/structure.md +0 -0
  64. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.kiro/steering/tech.md +0 -0
  65. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.pre-commit-config.yaml +0 -0
  66. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/.vscode/settings.json +0 -0
  67. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/example/README.md +0 -0
  68. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/example/example_app/__init__.py +0 -0
  69. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/example/example_app/apps.py +0 -0
  70. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/example/example_app/tasks.py +0 -0
  71. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/example/example_app/urls.py +0 -0
  72. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/example/example_app/views.py +0 -0
  73. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/example/example_project/__init__.py +0 -0
  74. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/example/example_project/settings.py +0 -0
  75. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/example/example_project/urls.py +0 -0
  76. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/example/example_project/wsgi.py +0 -0
  77. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/example/manage.py +0 -0
  78. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/lambda_tasks/__init__.py +0 -0
  79. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/lambda_tasks/admin.py +0 -0
  80. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/lambda_tasks/apps.py +0 -0
  81. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/lambda_tasks/handler.py +0 -0
  82. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/lambda_tasks/local_executor.py +0 -0
  83. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/lambda_tasks/logging.py +0 -0
  84. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/lambda_tasks/migrations/0001_initial.py +0 -0
  85. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/lambda_tasks/migrations/0002_alter_taskrecord_status.py +0 -0
  86. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/lambda_tasks/migrations/__init__.py +0 -0
  87. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/lambda_tasks/settings.py +0 -0
  88. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/lambda_tasks/tasks.py +0 -0
  89. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/lambda_tasks/timeouts.py +0 -0
  90. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/tests/conftest.py +0 -0
  91. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/tests/settings.py +0 -0
  92. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/tests/test_admin.py +0 -0
  93. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/tests/test_handler.py +0 -0
  94. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/tests/test_local_executor.py +0 -0
  95. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/tests/test_logging.py +0 -0
  96. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/tests/test_memory_limit.py +0 -0
  97. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/tests/test_serializer.py +0 -0
  98. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/tests/test_settings.py +0 -0
  99. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/tests/test_tasks.py +0 -0
  100. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/tests/test_timeout_validation.py +0 -0
  101. {django_lambda_tasks-0.4.10 → django_lambda_tasks-0.5.0}/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(delay=0, retry_delay=30, soft_timeout=60, hard_timeout=120, queue="default",
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()` uses the decorator `delay` value by default. Pass `_delay=<seconds>` at call time to override the delay for that specific enqueue:
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") # uses decorator delay
46
- my_task.execute_on_commit(user_id=1, action="x", _delay=60) # overrides with 60s
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 same range `[0, 900]` as the decorator `delay`. It only affects the SQS `DelaySeconds` — it has no effect in eager or async-local mode.
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` is stored on `LambdaTaskWrapper` and read by the executor at execution time; it is never serialised into the SQS message.
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 ignored exception (type matches `ignore_errors`): rolls back task-side writes, commits record as `SUCCESS` with traceback and `end_time`
134
- 8. On retryable exception (type matches `retry_on` or `LockError` for singleton tasks, `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`
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
 
@@ -155,12 +138,12 @@ class SQSLambdaTaskMessage(BaseModel):
155
138
 
156
139
  When the environment variable `LAMBDA_TASKS_ENVIRONMENT_SECRETS_MANAGER_ARN` is set, the loader parses the reference, fetches the named Secrets Manager secret, parses its JSON content as a flat key-value mapping, and sets the resulting pairs as environment variables.
157
140
 
158
- Required format: `<arn>:<version-stage>:<version-id>` (9 colon-separated segments — the ARN is 7 segments, plus version-stage and version-id). Both suffix fields must be non-empty.
141
+ Required format: `<arn>:<version-id>` (8 colon-separated segments — the ARN is 7 segments, plus the version-id). The version-id must be non-empty.
159
142
 
160
143
  Behaviour:
161
144
  - If `LAMBDA_TASKS_ENVIRONMENT_SECRETS_MANAGER_ARN` is not set, does nothing (no AWS API calls)
162
145
  - Validates the reference format before any AWS call — malformed references raise `ValueError` immediately
163
- - Fetches the secret via `secretsmanager.get_secret_value(SecretId=..., VersionStage=..., VersionId=...)`
146
+ - Fetches the secret via `secretsmanager.get_secret_value(SecretId=..., VersionId=...)`
164
147
  - Validates the secret value is a flat JSON object (all values must be strings, no empty keys)
165
148
  - Sets each key-value pair in `os.environ` — existing env vars are overridden (no conflict detection)
166
149
  - Idempotent via a module-level `_loaded` sentinel — subsequent calls are free no-ops
@@ -172,12 +155,12 @@ Behaviour:
172
155
 
173
156
  Any env var prefixed `LAMBDA_TASKS_SECRET_` is treated as a Secrets Manager reference. The unprefixed name is the target env var.
174
157
 
175
- Required format: `<arn>:<json-key>:<version-stage>:<version-id>` (10 colon-separated segments, all fields non-empty).
158
+ Required format: `<arn>:<json-key>:<version-stage>:<version-id>` (10 colon-separated segments). The version-stage segment must be empty. The json-key and version-id must be non-empty.
176
159
 
177
160
  Behaviour:
178
161
  - All references are validated before any AWS call — malformed references raise `ValueError` immediately
179
162
  - Setting both `LAMBDA_TASKS_SECRET_FOO` and `FOO` is a configuration error and raises `ValueError`
180
- - Calls are batched by `(ARN, version-stage, version-id)` — one `GetSecretValue` per unique combination
163
+ - Calls are batched by `(ARN, version-id)` — one `GetSecretValue` per unique combination
181
164
  - Fetched secrets are cached in-process; warm invocations pay no extra cost
182
165
 
183
166
  ## Models
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-lambda-tasks
3
- Version: 0.4.10
3
+ Version: 0.5.0
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 instead of the decorator default
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
 
@@ -150,7 +150,7 @@ LAMBDA_TASKS_DEFAULT_HARD_TIMEOUT = 270
150
150
 
151
151
  | Setting | Type | Description |
152
152
  |---|---|---|
153
- | `LAMBDA_TASKS_ENVIRONMENT_SECRETS_MANAGER_ARN` | env var | Secrets Manager reference (`<arn>:<version-stage>:<version-id>`) to load as environment variables at Lambda cold start. |
153
+ | `LAMBDA_TASKS_ENVIRONMENT_SECRETS_MANAGER_ARN` | env var | Secrets Manager reference (`<arn>:<version-id>`) to load as environment variables at Lambda cold start. |
154
154
  | `LAMBDA_TASKS_SECRET_*` | env var(s) | Secrets Manager references resolved into env vars at Lambda cold start. The unprefixed name becomes the target env var. |
155
155
 
156
156
  These are environment variables set on the Lambda function, not Django settings. See [Loading environment variables from Secrets Manager](#loading-environment-variables-from-secrets-manager) and [Resolving individual secrets from AWS Secrets Manager](#resolving-individual-secrets-from-aws-secrets-manager) for full details.
@@ -203,14 +203,13 @@ Key characteristics:
203
203
 
204
204
  ```python
205
205
  @lambda_task(
206
- delay=0, # seconds — SQS DelaySeconds before message becomes visible
207
206
  soft_timeout=60, # seconds — overrides global default for this task
208
207
  hard_timeout=90, # seconds — overrides global default for this task
209
208
  queue="default", # named queue from LAMBDA_TASKS_QUEUES
210
- ignore_errors=(), # exception types treated as non-fatal
211
209
  retry_on=(), # exception types that trigger automatic retry
212
210
  retry_delay=0, # base retry delay in seconds (jitter always added, capped at 900)
213
211
  singleton=False, # prevent concurrent execution via Redis lock
212
+ retry_singleton=True, # retry on LockError for singleton tasks (or treat as success if False)
214
213
  )
215
214
  def my_task(*, arg: str) -> None:
216
215
  ...
@@ -218,32 +217,31 @@ def my_task(*, arg: str) -> None:
218
217
 
219
218
  | Parameter | Type | Default | Description |
220
219
  |---|---|---|---|
221
- | `delay` | `int` | `0` | Seconds to delay the SQS message before it becomes visible to consumers (max 900). |
222
220
  | `soft_timeout` | `int \| None` | `None` (uses global default) | Per-task soft timeout in seconds (max 900). |
223
221
  | `hard_timeout` | `int \| None` | `None` (uses global default) | Per-task hard timeout in seconds (max 900). |
224
222
  | `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
223
  | `retry_on` | `tuple[type[BaseException], ...]` | `()` | Exception types that trigger an automatic retry (see [Automatic retries](#automatic-retries)). |
227
224
  | `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
225
  | `singleton` | `bool` | `False` | Prevent concurrent execution via a Redis lock (see [Singleton tasks](#singleton-tasks)). |
226
+ | `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
227
 
230
228
  ### Per-call delay override
231
229
 
232
- The `delay` decorator parameter sets the default SQS `DelaySeconds` for all invocations of a task. To override it for a specific call, pass `_delay` to `execute_on_commit()` or `serialize()`:
230
+ 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
231
 
234
232
  ```python
235
- @lambda_task(delay=0)
233
+ @lambda_task
236
234
  def notify_user(*, user_id: int) -> None:
237
235
  ...
238
236
 
239
- # Uses the decorator default (0 seconds)
237
+ # Default (0 seconds delay)
240
238
  notify_user.execute_on_commit(user_id=1)
241
239
 
242
- # Override: delay this specific invocation by 120 seconds
240
+ # Delay this specific invocation by 120 seconds
243
241
  notify_user.execute_on_commit(user_id=1, _delay=120)
244
242
  ```
245
243
 
246
- `_delay` is validated against the same `[0, 900]` range as the decorator `delay`. It only affects the SQS `DelaySeconds` — it has no effect in eager or async-local mode.
244
+ `_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
245
 
248
246
  ---
249
247
 
@@ -386,35 +384,6 @@ fields @timestamp, @message
386
384
 
387
385
  ---
388
386
 
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
387
  ## Automatic retries
419
388
 
420
389
  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 +391,6 @@ Pass a tuple of exception types to `retry_on` on `@lambda_task`. If the task rai
422
391
  - `TaskRecord.status` is set to `RETRYING` and the traceback is recorded
423
392
  - The retry is a new invocation — the current record is terminal at `RETRYING`
424
393
  - 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
394
 
428
395
  ```python
429
396
  from lambda_tasks.decorators import lambda_task
@@ -450,19 +417,27 @@ from lambda_tasks.decorators import lambda_task
450
417
 
451
418
  @lambda_task(singleton=True)
452
419
  def sync_inventory(*, warehouse_id: int) -> None:
453
- # Only one instance runs at a time
420
+ # Only one instance runs at a time; LockError → RETRYING + re-enqueued
454
421
  ...
455
422
  ```
456
423
 
457
424
  - Lock key format: `lambda_tasks.singleton_lock.{task_name}`
458
425
  - 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 via the existing retry mechanism — `TaskRecord` is set to `RETRYING`, the traceback is recorded, and the task is re-enqueued with `n_retries + 1`
426
+ - 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`
427
+ - 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
428
  - If `n_retries` reaches `LAMBDA_TASKS_MAX_RETRIES`, `MaxRetriesExceededError` is raised and the record is saved as `FAILED`
461
429
  - The cache backend used for locks is controlled by `LAMBDA_TASKS_SINGLETON_CACHE` (default `"default"`)
462
430
 
463
- `LockError` is retried automatically for singleton tasks — do not include it in `retry_on` (doing so raises `TypeError` at decoration time). You may include `LockError` in `ignore_errors` if you want lock contention to be treated as a non-fatal outcome instead of triggering a retry.
431
+ `LockError` is handled automatically for singleton tasks — do not include it in `retry_on` (doing so raises `TypeError` at decoration time).
432
+
433
+ `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
434
 
465
- `singleton` is stored on `LambdaTaskWrapper` and read by the executor at execution time; it is never serialised into the SQS message.
435
+ ```python
436
+ @lambda_task(singleton=True, retry_singleton=False)
437
+ def sync_inventory(*, warehouse_id: int) -> None:
438
+ # Lock contention → SUCCESS with traceback (no retry)
439
+ ...
440
+ ```
466
441
 
467
442
  ---
468
443
 
@@ -470,8 +445,6 @@ def sync_inventory(*, warehouse_id: int) -> None:
470
445
 
471
446
  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
447
 
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
448
  ---
476
449
 
477
450
  ## Error handling
@@ -519,20 +492,20 @@ Ensure the Lambda execution environment has `DJANGO_SETTINGS_MODULE` set and tha
519
492
  | Environment Variable | Required | Description |
520
493
  |---|---|---|
521
494
  | `DJANGO_SETTINGS_MODULE` | Yes | Django settings module path (e.g. `myapp.settings.production`). |
522
- | `LAMBDA_TASKS_ENVIRONMENT_SECRETS_MANAGER_ARN` | No | Secrets Manager reference (`<arn>:<version-stage>:<version-id>`) to load as environment variables at cold start (runs first). |
495
+ | `LAMBDA_TASKS_ENVIRONMENT_SECRETS_MANAGER_ARN` | No | Secrets Manager reference (`<arn>:<version-id>`) to load as environment variables at cold start (runs first). |
523
496
  | `LAMBDA_TASKS_SECRET_*` | No | Secrets Manager references resolved into individual env vars at cold start (runs second, after environment loading). |
524
497
 
525
498
  ### Loading environment variables from Secrets Manager
526
499
 
527
500
  The Lambda handler supports loading environment variables from an AWS Secrets Manager secret at cold start. This lets you manage environment configuration centrally in Secrets Manager without baking values into the Lambda deployment package.
528
501
 
529
- Set the `LAMBDA_TASKS_ENVIRONMENT_SECRETS_MANAGER_ARN` environment variable to a full reference including version stage and version ID:
502
+ Set the `LAMBDA_TASKS_ENVIRONMENT_SECRETS_MANAGER_ARN` environment variable to a full reference including version ID:
530
503
 
531
504
  ```
532
- LAMBDA_TASKS_ENVIRONMENT_SECRETS_MANAGER_ARN=arn:aws:secretsmanager:eu-west-1:123456789012:secret:myapp/prod/environment:AWSCURRENT:v1
505
+ LAMBDA_TASKS_ENVIRONMENT_SECRETS_MANAGER_ARN=arn:aws:secretsmanager:eu-west-1:123456789012:secret:myapp/prod/environment:v1
533
506
  ```
534
507
 
535
- The format is `<arn>:<version-stage>:<version-id>` (9 colon-separated segments — the ARN is 7 segments, plus version-stage and version-id). Both suffix fields must be non-empty.
508
+ The format is `<arn>:<version-id>` (8 colon-separated segments — the ARN is 7 segments, plus the version-id). The version-id must be non-empty.
536
509
 
537
510
  The secret value must be a flat JSON object where all keys and values are strings:
538
511
 
@@ -547,8 +520,8 @@ The secret value must be a flat JSON object where all keys and values are string
547
520
  At cold start (on the first handler invocation), before `resolve_secrets_into_env()` and `django.setup()`, the handler calls `resolve_environment()` which:
548
521
 
549
522
  1. Checks for the `LAMBDA_TASKS_ENVIRONMENT_SECRETS_MANAGER_ARN` env var — if not set, does nothing
550
- 2. Parses and validates the reference format (9 segments, non-empty version-stage and version-id)
551
- 3. Fetches the secret via `secretsmanager.get_secret_value(SecretId=..., VersionStage=..., VersionId=...)`
523
+ 2. Parses and validates the reference format (8 segments, non-empty version-id)
524
+ 3. Fetches the secret via `secretsmanager.get_secret_value(SecretId=..., VersionId=...)`
552
525
  4. Validates the secret value is a flat JSON object (all values must be strings, no empty keys)
553
526
  5. Sets each key-value pair in `os.environ` — existing env vars are overridden
554
527
  6. Caches the result via a module-level sentinel — subsequent calls are free no-ops
@@ -559,7 +532,7 @@ Because environment loading runs first, the secret can provide `DJANGO_SETTINGS_
559
532
 
560
533
  The following raise `ValueError` at cold start, preventing the Lambda container from starting:
561
534
 
562
- - Reference format is invalid (wrong segment count, empty version-stage or version-id)
535
+ - Reference format is invalid (wrong segment count, empty version-id)
563
536
  - Secret value is not valid JSON
564
537
  - JSON is not a flat object (contains non-string values) — error message lists the offending keys
565
538
  - JSON contains an empty string key
@@ -573,14 +546,14 @@ The Lambda handler supports loading individual secret values from AWS Secrets Ma
573
546
  Set any env var with the prefix `LAMBDA_TASKS_SECRET_` to a full Secrets Manager dynamic reference. The unprefixed name becomes the target env var:
574
547
 
575
548
  ```
576
- LAMBDA_TASKS_SECRET_DATABASE_URL=arn:aws:secretsmanager:eu-west-1:123456789012:secret:myapp/prod:DATABASE_URL:AWSCURRENT:v1
549
+ LAMBDA_TASKS_SECRET_DATABASE_URL=arn:aws:secretsmanager:eu-west-1:123456789012:secret:myapp/prod:DATABASE_URL::v1
577
550
  ```
578
551
 
579
552
  At cold start (on the first handler invocation), after `resolve_environment()` and before `django.setup()`, the handler calls `resolve_secrets_into_env()` which:
580
553
 
581
554
  1. Scans all env vars for the `LAMBDA_TASKS_SECRET_` prefix
582
555
  2. Validates every reference — malformed references raise immediately so the container fails to start rather than misconfiguring Django silently
583
- 3. Groups references by `(ARN, version-stage, version-id)` and makes one `GetSecretValue` call per unique combination
556
+ 3. Groups references by `(ARN, version-id)` and makes one `GetSecretValue` call per unique combination
584
557
  4. Extracts the named JSON key from the secret and writes it into `os.environ`
585
558
  5. Caches fetched secrets in-process — warm invocations pay no extra cost
586
559
 
@@ -592,18 +565,18 @@ Every value must follow the full dynamic reference syntax:
592
565
  <arn>:<json-key>:<version-stage>:<version-id>
593
566
  ```
594
567
 
595
- All four fields are required and must be non-empty. The secret value must be a JSON object; `json-key` names the field to extract.
568
+ The version-stage segment must be empty. The json-key and version-id fields are required and must be non-empty. The secret value must be a JSON object; `json-key` names the field to extract.
596
569
 
597
570
  ```
598
- # arn (7 segments) : json-key : version-stage : version-id
599
- arn:aws:secretsmanager:eu-west-1:123456789012:secret:myapp/prod:DATABASE_URL:AWSCURRENT:v1
571
+ # arn (7 segments) : json-key : version-stage (empty) : version-id
572
+ arn:aws:secretsmanager:eu-west-1:123456789012:secret:myapp/prod:DATABASE_URL::v1
600
573
  ```
601
574
 
602
- Multiple env vars can reference different keys from the same secret — only one `GetSecretValue` call is made for that `(ARN, version-stage, version-id)` combination:
575
+ Multiple env vars can reference different keys from the same secret — only one `GetSecretValue` call is made for that `(ARN, version-id)` combination:
603
576
 
604
577
  ```
605
- LAMBDA_TASKS_SECRET_DATABASE_URL=arn:...:myapp/prod:DATABASE_URL:AWSCURRENT:v1
606
- LAMBDA_TASKS_SECRET_SECRET_KEY=arn:...:myapp/prod:SECRET_KEY:AWSCURRENT:v1
578
+ LAMBDA_TASKS_SECRET_DATABASE_URL=arn:...:myapp/prod:DATABASE_URL::v1
579
+ LAMBDA_TASKS_SECRET_SECRET_KEY=arn:...:myapp/prod:SECRET_KEY::v1
607
580
  ```
608
581
 
609
582
  #### Validation errors
@@ -611,7 +584,8 @@ LAMBDA_TASKS_SECRET_SECRET_KEY=arn:...:myapp/prod:SECRET_KEY:AWSCURRENT:v1
611
584
  The following all raise `ValueError` at cold start, preventing the Lambda container from starting with a misconfigured environment:
612
585
 
613
586
  - Wrong number of colon-separated segments (must be exactly 10)
614
- - Empty `json-key`, `version-stage`, or `version-id`
587
+ - Empty `json-key` or `version-id`
588
+ - Non-empty `version-stage` (version-stage is not supported)
615
589
  - Both `LAMBDA_TASKS_SECRET_FOO` and `FOO` are set — use one or the other
616
590
  - The named JSON key does not exist in the fetched secret
617
591
  - The secret value is not valid JSON
@@ -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 instead of the decorator default
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
 
@@ -138,7 +138,7 @@ LAMBDA_TASKS_DEFAULT_HARD_TIMEOUT = 270
138
138
 
139
139
  | Setting | Type | Description |
140
140
  |---|---|---|
141
- | `LAMBDA_TASKS_ENVIRONMENT_SECRETS_MANAGER_ARN` | env var | Secrets Manager reference (`<arn>:<version-stage>:<version-id>`) to load as environment variables at Lambda cold start. |
141
+ | `LAMBDA_TASKS_ENVIRONMENT_SECRETS_MANAGER_ARN` | env var | Secrets Manager reference (`<arn>:<version-id>`) to load as environment variables at Lambda cold start. |
142
142
  | `LAMBDA_TASKS_SECRET_*` | env var(s) | Secrets Manager references resolved into env vars at Lambda cold start. The unprefixed name becomes the target env var. |
143
143
 
144
144
  These are environment variables set on the Lambda function, not Django settings. See [Loading environment variables from Secrets Manager](#loading-environment-variables-from-secrets-manager) and [Resolving individual secrets from AWS Secrets Manager](#resolving-individual-secrets-from-aws-secrets-manager) for full details.
@@ -191,14 +191,13 @@ Key characteristics:
191
191
 
192
192
  ```python
193
193
  @lambda_task(
194
- delay=0, # seconds — SQS DelaySeconds before message becomes visible
195
194
  soft_timeout=60, # seconds — overrides global default for this task
196
195
  hard_timeout=90, # seconds — overrides global default for this task
197
196
  queue="default", # named queue from LAMBDA_TASKS_QUEUES
198
- ignore_errors=(), # exception types treated as non-fatal
199
197
  retry_on=(), # exception types that trigger automatic retry
200
198
  retry_delay=0, # base retry delay in seconds (jitter always added, capped at 900)
201
199
  singleton=False, # prevent concurrent execution via Redis lock
200
+ retry_singleton=True, # retry on LockError for singleton tasks (or treat as success if False)
202
201
  )
203
202
  def my_task(*, arg: str) -> None:
204
203
  ...
@@ -206,32 +205,31 @@ def my_task(*, arg: str) -> None:
206
205
 
207
206
  | Parameter | Type | Default | Description |
208
207
  |---|---|---|---|
209
- | `delay` | `int` | `0` | Seconds to delay the SQS message before it becomes visible to consumers (max 900). |
210
208
  | `soft_timeout` | `int \| None` | `None` (uses global default) | Per-task soft timeout in seconds (max 900). |
211
209
  | `hard_timeout` | `int \| None` | `None` (uses global default) | Per-task hard timeout in seconds (max 900). |
212
210
  | `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
211
  | `retry_on` | `tuple[type[BaseException], ...]` | `()` | Exception types that trigger an automatic retry (see [Automatic retries](#automatic-retries)). |
215
212
  | `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
213
  | `singleton` | `bool` | `False` | Prevent concurrent execution via a Redis lock (see [Singleton tasks](#singleton-tasks)). |
214
+ | `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
215
 
218
216
  ### Per-call delay override
219
217
 
220
- The `delay` decorator parameter sets the default SQS `DelaySeconds` for all invocations of a task. To override it for a specific call, pass `_delay` to `execute_on_commit()` or `serialize()`:
218
+ 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
219
 
222
220
  ```python
223
- @lambda_task(delay=0)
221
+ @lambda_task
224
222
  def notify_user(*, user_id: int) -> None:
225
223
  ...
226
224
 
227
- # Uses the decorator default (0 seconds)
225
+ # Default (0 seconds delay)
228
226
  notify_user.execute_on_commit(user_id=1)
229
227
 
230
- # Override: delay this specific invocation by 120 seconds
228
+ # Delay this specific invocation by 120 seconds
231
229
  notify_user.execute_on_commit(user_id=1, _delay=120)
232
230
  ```
233
231
 
234
- `_delay` is validated against the same `[0, 900]` range as the decorator `delay`. It only affects the SQS `DelaySeconds` — it has no effect in eager or async-local mode.
232
+ `_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
233
 
236
234
  ---
237
235
 
@@ -374,35 +372,6 @@ fields @timestamp, @message
374
372
 
375
373
  ---
376
374
 
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
375
  ## Automatic retries
407
376
 
408
377
  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 +379,6 @@ Pass a tuple of exception types to `retry_on` on `@lambda_task`. If the task rai
410
379
  - `TaskRecord.status` is set to `RETRYING` and the traceback is recorded
411
380
  - The retry is a new invocation — the current record is terminal at `RETRYING`
412
381
  - 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
382
 
416
383
  ```python
417
384
  from lambda_tasks.decorators import lambda_task
@@ -438,19 +405,27 @@ from lambda_tasks.decorators import lambda_task
438
405
 
439
406
  @lambda_task(singleton=True)
440
407
  def sync_inventory(*, warehouse_id: int) -> None:
441
- # Only one instance runs at a time
408
+ # Only one instance runs at a time; LockError → RETRYING + re-enqueued
442
409
  ...
443
410
  ```
444
411
 
445
412
  - Lock key format: `lambda_tasks.singleton_lock.{task_name}`
446
413
  - 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 via the existing retry mechanism — `TaskRecord` is set to `RETRYING`, the traceback is recorded, and the task is re-enqueued with `n_retries + 1`
414
+ - 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`
415
+ - 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
416
  - If `n_retries` reaches `LAMBDA_TASKS_MAX_RETRIES`, `MaxRetriesExceededError` is raised and the record is saved as `FAILED`
449
417
  - The cache backend used for locks is controlled by `LAMBDA_TASKS_SINGLETON_CACHE` (default `"default"`)
450
418
 
451
- `LockError` is retried automatically for singleton tasks — do not include it in `retry_on` (doing so raises `TypeError` at decoration time). You may include `LockError` in `ignore_errors` if you want lock contention to be treated as a non-fatal outcome instead of triggering a retry.
419
+ `LockError` is handled automatically for singleton tasks — do not include it in `retry_on` (doing so raises `TypeError` at decoration time).
420
+
421
+ `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
422
 
453
- `singleton` is stored on `LambdaTaskWrapper` and read by the executor at execution time; it is never serialised into the SQS message.
423
+ ```python
424
+ @lambda_task(singleton=True, retry_singleton=False)
425
+ def sync_inventory(*, warehouse_id: int) -> None:
426
+ # Lock contention → SUCCESS with traceback (no retry)
427
+ ...
428
+ ```
454
429
 
455
430
  ---
456
431
 
@@ -458,8 +433,6 @@ def sync_inventory(*, warehouse_id: int) -> None:
458
433
 
459
434
  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
435
 
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
436
  ---
464
437
 
465
438
  ## Error handling
@@ -507,20 +480,20 @@ Ensure the Lambda execution environment has `DJANGO_SETTINGS_MODULE` set and tha
507
480
  | Environment Variable | Required | Description |
508
481
  |---|---|---|
509
482
  | `DJANGO_SETTINGS_MODULE` | Yes | Django settings module path (e.g. `myapp.settings.production`). |
510
- | `LAMBDA_TASKS_ENVIRONMENT_SECRETS_MANAGER_ARN` | No | Secrets Manager reference (`<arn>:<version-stage>:<version-id>`) to load as environment variables at cold start (runs first). |
483
+ | `LAMBDA_TASKS_ENVIRONMENT_SECRETS_MANAGER_ARN` | No | Secrets Manager reference (`<arn>:<version-id>`) to load as environment variables at cold start (runs first). |
511
484
  | `LAMBDA_TASKS_SECRET_*` | No | Secrets Manager references resolved into individual env vars at cold start (runs second, after environment loading). |
512
485
 
513
486
  ### Loading environment variables from Secrets Manager
514
487
 
515
488
  The Lambda handler supports loading environment variables from an AWS Secrets Manager secret at cold start. This lets you manage environment configuration centrally in Secrets Manager without baking values into the Lambda deployment package.
516
489
 
517
- Set the `LAMBDA_TASKS_ENVIRONMENT_SECRETS_MANAGER_ARN` environment variable to a full reference including version stage and version ID:
490
+ Set the `LAMBDA_TASKS_ENVIRONMENT_SECRETS_MANAGER_ARN` environment variable to a full reference including version ID:
518
491
 
519
492
  ```
520
- LAMBDA_TASKS_ENVIRONMENT_SECRETS_MANAGER_ARN=arn:aws:secretsmanager:eu-west-1:123456789012:secret:myapp/prod/environment:AWSCURRENT:v1
493
+ LAMBDA_TASKS_ENVIRONMENT_SECRETS_MANAGER_ARN=arn:aws:secretsmanager:eu-west-1:123456789012:secret:myapp/prod/environment:v1
521
494
  ```
522
495
 
523
- The format is `<arn>:<version-stage>:<version-id>` (9 colon-separated segments — the ARN is 7 segments, plus version-stage and version-id). Both suffix fields must be non-empty.
496
+ The format is `<arn>:<version-id>` (8 colon-separated segments — the ARN is 7 segments, plus the version-id). The version-id must be non-empty.
524
497
 
525
498
  The secret value must be a flat JSON object where all keys and values are strings:
526
499
 
@@ -535,8 +508,8 @@ The secret value must be a flat JSON object where all keys and values are string
535
508
  At cold start (on the first handler invocation), before `resolve_secrets_into_env()` and `django.setup()`, the handler calls `resolve_environment()` which:
536
509
 
537
510
  1. Checks for the `LAMBDA_TASKS_ENVIRONMENT_SECRETS_MANAGER_ARN` env var — if not set, does nothing
538
- 2. Parses and validates the reference format (9 segments, non-empty version-stage and version-id)
539
- 3. Fetches the secret via `secretsmanager.get_secret_value(SecretId=..., VersionStage=..., VersionId=...)`
511
+ 2. Parses and validates the reference format (8 segments, non-empty version-id)
512
+ 3. Fetches the secret via `secretsmanager.get_secret_value(SecretId=..., VersionId=...)`
540
513
  4. Validates the secret value is a flat JSON object (all values must be strings, no empty keys)
541
514
  5. Sets each key-value pair in `os.environ` — existing env vars are overridden
542
515
  6. Caches the result via a module-level sentinel — subsequent calls are free no-ops
@@ -547,7 +520,7 @@ Because environment loading runs first, the secret can provide `DJANGO_SETTINGS_
547
520
 
548
521
  The following raise `ValueError` at cold start, preventing the Lambda container from starting:
549
522
 
550
- - Reference format is invalid (wrong segment count, empty version-stage or version-id)
523
+ - Reference format is invalid (wrong segment count, empty version-id)
551
524
  - Secret value is not valid JSON
552
525
  - JSON is not a flat object (contains non-string values) — error message lists the offending keys
553
526
  - JSON contains an empty string key
@@ -561,14 +534,14 @@ The Lambda handler supports loading individual secret values from AWS Secrets Ma
561
534
  Set any env var with the prefix `LAMBDA_TASKS_SECRET_` to a full Secrets Manager dynamic reference. The unprefixed name becomes the target env var:
562
535
 
563
536
  ```
564
- LAMBDA_TASKS_SECRET_DATABASE_URL=arn:aws:secretsmanager:eu-west-1:123456789012:secret:myapp/prod:DATABASE_URL:AWSCURRENT:v1
537
+ LAMBDA_TASKS_SECRET_DATABASE_URL=arn:aws:secretsmanager:eu-west-1:123456789012:secret:myapp/prod:DATABASE_URL::v1
565
538
  ```
566
539
 
567
540
  At cold start (on the first handler invocation), after `resolve_environment()` and before `django.setup()`, the handler calls `resolve_secrets_into_env()` which:
568
541
 
569
542
  1. Scans all env vars for the `LAMBDA_TASKS_SECRET_` prefix
570
543
  2. Validates every reference — malformed references raise immediately so the container fails to start rather than misconfiguring Django silently
571
- 3. Groups references by `(ARN, version-stage, version-id)` and makes one `GetSecretValue` call per unique combination
544
+ 3. Groups references by `(ARN, version-id)` and makes one `GetSecretValue` call per unique combination
572
545
  4. Extracts the named JSON key from the secret and writes it into `os.environ`
573
546
  5. Caches fetched secrets in-process — warm invocations pay no extra cost
574
547
 
@@ -580,18 +553,18 @@ Every value must follow the full dynamic reference syntax:
580
553
  <arn>:<json-key>:<version-stage>:<version-id>
581
554
  ```
582
555
 
583
- All four fields are required and must be non-empty. The secret value must be a JSON object; `json-key` names the field to extract.
556
+ The version-stage segment must be empty. The json-key and version-id fields are required and must be non-empty. The secret value must be a JSON object; `json-key` names the field to extract.
584
557
 
585
558
  ```
586
- # arn (7 segments) : json-key : version-stage : version-id
587
- arn:aws:secretsmanager:eu-west-1:123456789012:secret:myapp/prod:DATABASE_URL:AWSCURRENT:v1
559
+ # arn (7 segments) : json-key : version-stage (empty) : version-id
560
+ arn:aws:secretsmanager:eu-west-1:123456789012:secret:myapp/prod:DATABASE_URL::v1
588
561
  ```
589
562
 
590
- Multiple env vars can reference different keys from the same secret — only one `GetSecretValue` call is made for that `(ARN, version-stage, version-id)` combination:
563
+ Multiple env vars can reference different keys from the same secret — only one `GetSecretValue` call is made for that `(ARN, version-id)` combination:
591
564
 
592
565
  ```
593
- LAMBDA_TASKS_SECRET_DATABASE_URL=arn:...:myapp/prod:DATABASE_URL:AWSCURRENT:v1
594
- LAMBDA_TASKS_SECRET_SECRET_KEY=arn:...:myapp/prod:SECRET_KEY:AWSCURRENT:v1
566
+ LAMBDA_TASKS_SECRET_DATABASE_URL=arn:...:myapp/prod:DATABASE_URL::v1
567
+ LAMBDA_TASKS_SECRET_SECRET_KEY=arn:...:myapp/prod:SECRET_KEY::v1
595
568
  ```
596
569
 
597
570
  #### Validation errors
@@ -599,7 +572,8 @@ LAMBDA_TASKS_SECRET_SECRET_KEY=arn:...:myapp/prod:SECRET_KEY:AWSCURRENT:v1
599
572
  The following all raise `ValueError` at cold start, preventing the Lambda container from starting with a misconfigured environment:
600
573
 
601
574
  - Wrong number of colon-separated segments (must be exactly 10)
602
- - Empty `json-key`, `version-stage`, or `version-id`
575
+ - Empty `json-key` or `version-id`
576
+ - Non-empty `version-stage` (version-stage is not supported)
603
577
  - Both `LAMBDA_TASKS_SECRET_FOO` and `FOO` are set — use one or the other
604
578
  - The named JSON key does not exist in the fetched secret
605
579
  - The secret value is not valid JSON