threadmill 0.3.1__tar.gz → 0.4.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 (22) hide show
  1. {threadmill-0.3.1 → threadmill-0.4.0}/PKG-INFO +78 -12
  2. {threadmill-0.3.1 → threadmill-0.4.0}/README.md +76 -7
  3. {threadmill-0.3.1 → threadmill-0.4.0}/pyproject.toml +2 -5
  4. {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/_version.py +3 -3
  5. {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/backends/base.py +70 -42
  6. {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/backends/redis.py +29 -2
  7. {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/exceptions.py +0 -2
  8. {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/executor.py +17 -4
  9. {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/inspector/app.py +0 -2
  10. threadmill-0.4.0/threadmill/retry.py +71 -0
  11. {threadmill-0.3.1 → threadmill-0.4.0}/LICENSE +0 -0
  12. {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/__init__.py +0 -0
  13. {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/backends/__init__.py +0 -0
  14. {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/backends/lua/acknowledge.lua +0 -0
  15. {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/backends/lua/acquire.lua +0 -0
  16. {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/backends/lua/mover.lua +0 -0
  17. {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/backends/lua/reaper.lua +0 -0
  18. {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/inspector/__init__.py +0 -0
  19. {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/inspector/inspector.scss +0 -0
  20. {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/management/__init__.py +0 -0
  21. {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/management/commands/__init__.py +0 -0
  22. {threadmill-0.3.1 → threadmill-0.4.0}/threadmill/management/commands/threadmill.py +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: threadmill
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: The most reliable backend for Django's task framework.
5
5
  Keywords: Django,tasks,worker
6
6
  Author-email: Johannes Maron <johannes@maron.family>
7
- Requires-Python: >=3.12
7
+ Requires-Python: >=3.14
8
8
  Description-Content-Type: text/markdown
9
9
  Classifier: Development Status :: 4 - Beta
10
10
  Classifier: Programming Language :: Python
@@ -20,11 +20,8 @@ Classifier: Topic :: Software Development
20
20
  Classifier: Programming Language :: Python
21
21
  Classifier: Programming Language :: Python :: 3
22
22
  Classifier: Programming Language :: Python :: 3 :: Only
23
- Classifier: Programming Language :: Python :: 3.12
24
- Classifier: Programming Language :: Python :: 3.13
25
23
  Classifier: Programming Language :: Python :: 3.14
26
24
  Classifier: Framework :: Django
27
- Classifier: Framework :: Django :: 6.0
28
25
  Classifier: Framework :: Django :: 6.1
29
26
  License-File: LICENSE
30
27
  Requires-Dist: django>=6.0
@@ -59,13 +56,9 @@ Provides-Extra: redis
59
56
 
60
57
  ## Design Principles
61
58
 
62
- - **Durability** – We recover from any failures, even poorly written tasks.
63
- - **Consistency** – We never lose data, even if someone unplugs the power or network.
64
- - **Utilization** – We keep the CPU saturated with tasks, not with idle time or waiting for locks.
65
-
66
- ## Sponsors
67
-
68
- [![Sponsors](https://django.the-box.sh/sponsors/codingjoe/threadmill.svg)](https://github.com/sponsors/codingjoe)
59
+ - **Durability** – Recover from any failures, even poorly written tasks.
60
+ - **Consistency** – Never lose data, even if someone unplugs the power or network.
61
+ - **Utilization** – Keep the CPU saturated with tasks, not with idle time or waiting for locks.
69
62
 
70
63
  ## Setup
71
64
 
@@ -176,5 +169,78 @@ every multi-key operation — including the cross-queue acquire — runs on a si
176
169
  shard. Scale horizontally by running additional backend aliases, not by relying
177
170
  on cross-slot operations.
178
171
 
172
+ ### Retrying failed tasks
173
+
174
+ Pass a `retry` callback to `@task()` to retry failed tasks with a delay.
175
+ The callback receives a `TaskContext` — use `context.attempt` for the current
176
+ attempt count and `context.task_result.errors[-1]` for the latest error.
177
+ Return a `timedelta` to schedule the next attempt, or `None` to stop retrying.
178
+
179
+ The worker re-queues the failed task, preserving its ID and error history;
180
+ the broker promotes it back to the ready queue once the delay elapses.
181
+
182
+ #### Built-in `ExponentialBackoff`
183
+
184
+ `threadmill.retry.ExponentialBackoff` provides a serializable exponential
185
+ backoff strategy out of the box. It caps the delay at `max_delay`, stops
186
+ after `max_retries` attempts, and only retries exceptions listed in
187
+ `expected_exceptions`.
188
+
189
+ ```python
190
+ import datetime
191
+
192
+ from django.tasks import task
193
+ from requests import HTTPError
194
+
195
+ from threadmill.retry import ExponentialBackoff
196
+
197
+
198
+ @task(
199
+ retry=ExponentialBackoff(
200
+ base_delay=datetime.timedelta(seconds=1),
201
+ max_delay=datetime.timedelta(minutes=5),
202
+ factor=2.0,
203
+ max_retries=5,
204
+ expected_exceptions=(HTTPError,),
205
+ )
206
+ )
207
+ def fetch_github_api(url: str): ...
208
+ ```
209
+
210
+ #### Custom retry callbacks
211
+
212
+ For cases that need logic beyond what `ExponentialBackoff` supports,
213
+ write a callable that accepts a `TaskContext` and returns a `timedelta`
214
+ or `None`. Use `TaskError.exception_class` to filter by exception type:
215
+
216
+ ```python
217
+ import datetime
218
+
219
+ from django.tasks import task
220
+ from django.tasks.base import TaskContext
221
+ from requests import HTTPError
222
+
223
+
224
+ def retry_on_rate_limit(context: TaskContext) -> datetime.timedelta | None:
225
+ """Retry HTTP 429 responses with exponential backoff, up to 5 attempts."""
226
+ if context.attempt >= 5:
227
+ return None
228
+ error = context.task_result.errors[-1]
229
+ if not issubclass(error.exception_class, HTTPError):
230
+ return None
231
+ return min(
232
+ datetime.timedelta(seconds=2**context.attempt),
233
+ datetime.timedelta(seconds=60),
234
+ )
235
+
236
+
237
+ @task(retry=retry_on_rate_limit)
238
+ def fetch_github_api(url: str): ...
239
+ ```
240
+
241
+ ## Sponsors
242
+
243
+ [![Sponsors](https://django.the-box.sh/sponsors/codingjoe/threadmill.svg)](https://github.com/sponsors/codingjoe)
244
+
179
245
  [django-tasks]: https://docs.djangoproject.com/en/stable/topics/tasks/
180
246
 
@@ -17,13 +17,9 @@
17
17
 
18
18
  ## Design Principles
19
19
 
20
- - **Durability** – We recover from any failures, even poorly written tasks.
21
- - **Consistency** – We never lose data, even if someone unplugs the power or network.
22
- - **Utilization** – We keep the CPU saturated with tasks, not with idle time or waiting for locks.
23
-
24
- ## Sponsors
25
-
26
- [![Sponsors](https://django.the-box.sh/sponsors/codingjoe/threadmill.svg)](https://github.com/sponsors/codingjoe)
20
+ - **Durability** – Recover from any failures, even poorly written tasks.
21
+ - **Consistency** – Never lose data, even if someone unplugs the power or network.
22
+ - **Utilization** – Keep the CPU saturated with tasks, not with idle time or waiting for locks.
27
23
 
28
24
  ## Setup
29
25
 
@@ -134,4 +130,77 @@ every multi-key operation — including the cross-queue acquire — runs on a si
134
130
  shard. Scale horizontally by running additional backend aliases, not by relying
135
131
  on cross-slot operations.
136
132
 
133
+ ### Retrying failed tasks
134
+
135
+ Pass a `retry` callback to `@task()` to retry failed tasks with a delay.
136
+ The callback receives a `TaskContext` — use `context.attempt` for the current
137
+ attempt count and `context.task_result.errors[-1]` for the latest error.
138
+ Return a `timedelta` to schedule the next attempt, or `None` to stop retrying.
139
+
140
+ The worker re-queues the failed task, preserving its ID and error history;
141
+ the broker promotes it back to the ready queue once the delay elapses.
142
+
143
+ #### Built-in `ExponentialBackoff`
144
+
145
+ `threadmill.retry.ExponentialBackoff` provides a serializable exponential
146
+ backoff strategy out of the box. It caps the delay at `max_delay`, stops
147
+ after `max_retries` attempts, and only retries exceptions listed in
148
+ `expected_exceptions`.
149
+
150
+ ```python
151
+ import datetime
152
+
153
+ from django.tasks import task
154
+ from requests import HTTPError
155
+
156
+ from threadmill.retry import ExponentialBackoff
157
+
158
+
159
+ @task(
160
+ retry=ExponentialBackoff(
161
+ base_delay=datetime.timedelta(seconds=1),
162
+ max_delay=datetime.timedelta(minutes=5),
163
+ factor=2.0,
164
+ max_retries=5,
165
+ expected_exceptions=(HTTPError,),
166
+ )
167
+ )
168
+ def fetch_github_api(url: str): ...
169
+ ```
170
+
171
+ #### Custom retry callbacks
172
+
173
+ For cases that need logic beyond what `ExponentialBackoff` supports,
174
+ write a callable that accepts a `TaskContext` and returns a `timedelta`
175
+ or `None`. Use `TaskError.exception_class` to filter by exception type:
176
+
177
+ ```python
178
+ import datetime
179
+
180
+ from django.tasks import task
181
+ from django.tasks.base import TaskContext
182
+ from requests import HTTPError
183
+
184
+
185
+ def retry_on_rate_limit(context: TaskContext) -> datetime.timedelta | None:
186
+ """Retry HTTP 429 responses with exponential backoff, up to 5 attempts."""
187
+ if context.attempt >= 5:
188
+ return None
189
+ error = context.task_result.errors[-1]
190
+ if not issubclass(error.exception_class, HTTPError):
191
+ return None
192
+ return min(
193
+ datetime.timedelta(seconds=2**context.attempt),
194
+ datetime.timedelta(seconds=60),
195
+ )
196
+
197
+
198
+ @task(retry=retry_on_rate_limit)
199
+ def fetch_github_api(url: str): ...
200
+ ```
201
+
202
+ ## Sponsors
203
+
204
+ [![Sponsors](https://django.the-box.sh/sponsors/codingjoe/threadmill.svg)](https://github.com/sponsors/codingjoe)
205
+
137
206
  [django-tasks]: https://docs.djangoproject.com/en/stable/topics/tasks/
@@ -26,14 +26,11 @@ classifiers = [
26
26
  "Programming Language :: Python",
27
27
  "Programming Language :: Python :: 3",
28
28
  "Programming Language :: Python :: 3 :: Only",
29
- "Programming Language :: Python :: 3.12",
30
- "Programming Language :: Python :: 3.13",
31
29
  "Programming Language :: Python :: 3.14",
32
30
  "Framework :: Django",
33
- "Framework :: Django :: 6.0",
34
31
  "Framework :: Django :: 6.1",
35
32
  ]
36
- requires-python = ">=3.12"
33
+ requires-python = ">=3.14"
37
34
  dependencies = ["django>=6.0"]
38
35
 
39
36
  [project.optional-dependencies]
@@ -59,7 +56,7 @@ name = "threadmill"
59
56
  write_to = "threadmill/_version.py"
60
57
 
61
58
  [tool.pytest.ini_options]
62
- minversion = "6.0"
59
+ minversion = "6.1a1"
63
60
  addopts = "--cov --cov-report=xml --cov-report=term --tb=short -rxs --benchmark-autosave --benchmark-group-by=fullname --benchmark-min-rounds=10"
64
61
  testpaths = ["tests"]
65
62
  DJANGO_SETTINGS_MODULE = "tests.testapp.settings"
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.3.1'
22
- __version_tuple__ = version_tuple = (0, 3, 1)
21
+ __version__ = version = '0.4.0'
22
+ __version_tuple__ = version_tuple = (0, 4, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g32b680b8b'
24
+ __commit_id__ = commit_id = 'gf9749f870'
@@ -1,39 +1,63 @@
1
- from __future__ import annotations
2
-
3
1
  import collections.abc
4
2
  import dataclasses
5
3
  import datetime
6
4
  import json
7
5
  import threading
6
+ import typing
8
7
  from abc import ABC
9
8
 
10
- import django
11
9
  from django.core.serializers.json import DjangoJSONEncoder
12
10
  from django.tasks import DEFAULT_TASK_QUEUE_NAME, Task, TaskResult, TaskResultStatus
13
11
  from django.tasks.backends.base import BaseTaskBackend
14
- from django.tasks.base import TaskError
12
+ from django.tasks.base import TaskContext, TaskError
13
+ from django.tasks.exceptions import InvalidTask
14
+ from django.utils.inspect import is_module_level_function
15
15
  from django.utils.module_loading import import_string
16
16
 
17
- if django.VERSION == (6, 0):
18
- # https://github.com/django/django/commit/8c8b833d32c02d3ae6f43b04bb1e45968796b402
19
- @dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
20
- class Task(Task):
21
- @classmethod
22
- def _reconstruct(cls, kwargs):
23
- func_path = kwargs["func"]
24
- try:
25
- func = import_string(func_path)
26
- kwargs["func"] = func.func
27
- except (ImportError, AttributeError) as e:
28
- msg = f"Expected {func_path!r} to point to a Task instance."
29
- raise ValueError(msg) from e
30
- return cls(**kwargs)
31
17
 
32
- def __reduce__(self):
33
- kwargs = {f.name: getattr(self, f.name) for f in dataclasses.fields(self)}
34
- kwargs["func"] = self.module_path
18
+ @dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
19
+ class RetryTask(Task):
20
+ """Task with an optional retry callback for backoff scheduling.
21
+
22
+ Usage:
23
+
24
+ def retry_callback(context: TaskContext) -> datetime.timedelta | None:
25
+ if context.attempt < 3:
26
+ return datetime.timedelta(seconds=60 * (2 ** context.attempt))
35
27
 
36
- return (self.__class__._reconstruct, (kwargs,))
28
+ @task(retry=retry_callback)
29
+ def my_task():
30
+ ...
31
+
32
+ """
33
+
34
+ retry: collections.abc.Callable[[TaskContext], datetime.timedelta | None] | None = (
35
+ None
36
+ )
37
+
38
+ @property
39
+ def retry_path(self) -> tuple[str, tuple, dict[str, typing.Any]] | str | None:
40
+ """Importable dotted path of the retry callback, or None."""
41
+ if self.retry:
42
+ if hasattr(self.retry, "deconstruct"):
43
+ return self.retry.deconstruct()
44
+ return f"{self.retry.__module__}.{self.retry.__qualname__}"
45
+
46
+ @classmethod
47
+ def _reconstruct(cls, kwargs):
48
+ if retry_path := kwargs.pop("retry", None):
49
+ try:
50
+ path, pos_args, kw_args = retry_path
51
+ except ValueError:
52
+ kwargs["retry"] = import_string(retry_path)
53
+ else:
54
+ kwargs["retry"] = import_string(path)(*pos_args, **kw_args)
55
+ return super()._reconstruct(kwargs)
56
+
57
+ def __reduce__(self):
58
+ reconstructor, (kwargs,) = super().__reduce__()
59
+ kwargs["retry"] = self.retry_path
60
+ return (reconstructor, (kwargs,))
37
61
 
38
62
 
39
63
  @dataclasses.dataclass(kw_only=True, slots=True)
@@ -116,19 +140,20 @@ class TaskResultEncoder(DjangoJSONEncoder):
116
140
  field.name: getattr(o, field.name)
117
141
  for field in dataclasses.fields(type(o))
118
142
  }
119
- if isinstance(o, Task):
120
- return {
143
+ if isinstance(o, RetryTask):
144
+ data = {
121
145
  field.name: getattr(o, field.name)
122
- for field in dataclasses.fields(Task)
123
- if field.name != "func" # Exclude the function object itself
124
- } | {"func": o.module_path}
146
+ for field in dataclasses.fields(RetryTask)
147
+ if field.name not in {"func", "retry"}
148
+ } | {"func": o.module_path, "retry": o.retry_path}
149
+ return data
125
150
  return super().default(o)
126
151
 
127
152
 
128
153
  class ThreadmillTaskBackend(BaseTaskBackend, ABC):
129
154
  """Interface for task queues to be processed by the executor."""
130
155
 
131
- task_class = Task # can be removed in the future when Django 6.0 support is dropped
156
+ task_class = RetryTask
132
157
  supports_async_task = True
133
158
  supports_get_result = True
134
159
  broker_class: type[Broker] | None = None
@@ -144,18 +169,8 @@ class ThreadmillTaskBackend(BaseTaskBackend, ABC):
144
169
  def _object_hook(d: dict) -> dict | TaskResult:
145
170
  if "task" in d and isinstance(d["task"], dict) and "func" in d["task"]:
146
171
  task_data = d["task"]
147
- func = import_string(task_data["func"])
148
- if isinstance(func, cls.task_class):
149
- func = func.func
150
- d["task"] = cls.task_class(
151
- func=func,
152
- **{
153
- field.name: _parse_datetime(task_data[field.name])
154
- for field in dataclasses.fields(cls.task_class)
155
- if field.name not in {"func", "takes_context"}
156
- and field.name in task_data
157
- },
158
- )
172
+ task_data["run_after"] = _parse_datetime(task_data["run_after"])
173
+ d["task"] = cls.task_class._reconstruct(task_data)
159
174
  d["status"] = TaskResultStatus(d["status"])
160
175
  d["errors"] = [TaskError(**error) for error in d["errors"]]
161
176
  return_value = d.pop("_return_value", None)
@@ -168,6 +183,15 @@ class ThreadmillTaskBackend(BaseTaskBackend, ABC):
168
183
 
169
184
  return json.loads(data, object_hook=_object_hook)
170
185
 
186
+ def validate_task(self, task: RetryTask) -> None:
187
+ super().validate_task(task)
188
+ if task.retry is not None and not (
189
+ is_module_level_function(task.retry) or hasattr(task.retry, "deconstruct")
190
+ ):
191
+ raise InvalidTask(
192
+ "Task's retry function must be defined at a module level or be a deconstructible callable."
193
+ )
194
+
171
195
  def acquire(
172
196
  self,
173
197
  *queue_names: str,
@@ -192,15 +216,19 @@ class ThreadmillTaskBackend(BaseTaskBackend, ABC):
192
216
  """Remove the task from the queue and publish the result."""
193
217
  raise NotImplementedError
194
218
 
219
+ def requeue(self, task_result: TaskResult, run_after: datetime.datetime) -> None:
220
+ """Re-queue a failed task result for a retry attempt after `run_after`."""
221
+ raise NotImplementedError
222
+
195
223
  def peek(
196
224
  self,
197
225
  queue_name: str = DEFAULT_TASK_QUEUE_NAME,
198
226
  *,
199
227
  status: TaskResultStatus,
200
228
  count: int = 1,
201
- ) -> collections.abc.Generator[TaskResult, None, None]:
229
+ ) -> collections.abc.Generator[TaskResult]:
202
230
  """
203
- Yield up to ``count`` tasks from a queue in the given status segment.
231
+ Yield up to `count` tasks from a queue in the given status segment.
204
232
 
205
233
  Args:
206
234
  queue_name: The name of the queue to peek into.
@@ -1,7 +1,6 @@
1
1
  """Redis-backed durable priority queue backend for Django's task framework."""
2
2
 
3
- from __future__ import annotations
4
-
3
+ import dataclasses
5
4
  import datetime
6
5
  import logging
7
6
  import queue
@@ -315,6 +314,34 @@ class RedisTaskBackend(ThreadmillTaskBackend):
315
314
  ],
316
315
  )
317
316
 
317
+ def requeue(self, task_result: TaskResult, run_after: datetime.datetime) -> None:
318
+ task_result = dataclasses.replace(
319
+ task_result,
320
+ status=TaskResultStatus.READY,
321
+ started_at=None,
322
+ finished_at=None,
323
+ )
324
+ serialized = self.serialize_task_result(task_result)
325
+ running_key = self.RUNNING_KEY.format(
326
+ prefix=self.key_prefix, queue_name=task_result.task.queue_name
327
+ )
328
+ deferred_key = self.DEFERRED_KEY.format(
329
+ prefix=self.key_prefix, queue_name=task_result.task.queue_name
330
+ )
331
+ task_key = self.TASK_KEY.format(prefix=self.key_prefix, task_id=task_result.id)
332
+ score = self._compute_score(task_result.task.priority, task_result.enqueued_at)
333
+ run_after_ms = run_after.timestamp() * 1000
334
+ task_data_ttl = int(
335
+ self.lease_ttl.total_seconds() * 3 + self.result_ttl.total_seconds()
336
+ )
337
+
338
+ pipe = self.client.pipeline()
339
+ pipe.zrem(running_key, task_result.id)
340
+ pipe.hset(task_key, mapping={"data": serialized, "score": str(score)})
341
+ pipe.expire(task_key, task_data_ttl)
342
+ pipe.zadd(deferred_key, {task_result.id: run_after_ms})
343
+ pipe.execute()
344
+
318
345
  def peek(
319
346
  self,
320
347
  queue_name: str = DEFAULT_TASK_QUEUE_NAME,
@@ -1,7 +1,5 @@
1
1
  """Custom exceptions for the threadmill task framework."""
2
2
 
3
- from __future__ import annotations
4
-
5
3
 
6
4
  class AcknowledgementTimeout(Exception):
7
5
  """Raised when a task's lease has expired before it could be acknowledged."""
@@ -1,7 +1,5 @@
1
1
  """Task worker executor implementation."""
2
2
 
3
- from __future__ import annotations
4
-
5
3
  import asyncio
6
4
  import dataclasses
7
5
  import datetime
@@ -205,17 +203,32 @@ class WorkerThread(threading.Thread):
205
203
  timeout=datetime.timedelta(seconds=1),
206
204
  worker=self.name,
207
205
  )
208
- except (Empty, TimeoutError):
206
+ except Empty, TimeoutError:
209
207
  if self.worker.shutdown_requested.is_set() or self.worker.exit_empty:
210
208
  return
211
209
  continue
212
210
 
213
211
  try:
214
212
  result = self.execute_task_result(task_result)
215
- self.backend.acknowledge(result)
213
+ if (
214
+ result.status is TaskResultStatus.FAILED
215
+ and (delay := self.retry_delay(result)) is not None
216
+ ):
217
+ self.backend.requeue(result, timezone.now() + delay)
218
+ else:
219
+ self.backend.acknowledge(result)
216
220
  finally:
217
221
  self.worker.record_task()
218
222
 
223
+ @staticmethod
224
+ def retry_delay(task_result: TaskResult) -> datetime.timedelta | None:
225
+ """Return the retry delay for a failed task, or None to stop retrying."""
226
+ if task_result.task.retry:
227
+ try:
228
+ return task_result.task.retry(TaskContext(task_result=task_result))
229
+ except Exception:
230
+ logger.exception("Retry callback failed for task %r", task_result.id)
231
+
219
232
  def execute_task_result(self, task_result: TaskResult) -> TaskResult:
220
233
  """Execute task from task result and update result lifecycle state."""
221
234
  logger.info("Executing task %r", task_result.id)
@@ -1,7 +1,5 @@
1
1
  """Textual app for the inspector TUI."""
2
2
 
3
- from __future__ import annotations
4
-
5
3
  import datetime
6
4
  import logging
7
5
  import math
@@ -0,0 +1,71 @@
1
+ """Reusable callables for retrying tasks."""
2
+
3
+ import dataclasses
4
+ import datetime
5
+ import typing
6
+
7
+ if typing.TYPE_CHECKING:
8
+ from django.tasks import TaskContext
9
+
10
+
11
+ @dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
12
+ class ExponentialBackoff:
13
+ """Delay retries exponentially, capped at ``max_delay`` and limited to ``max_retries`` attempts.
14
+
15
+ Only retry exceptions listed in ``expected_exceptions``.
16
+
17
+ Usage:
18
+
19
+ @task(retry=ExponentialBackoff(base_delay=datetime.timedelta(seconds=1), max_delay=datetime.timedelta(minutes=5), factor=2.0, max_retries=5))
20
+ def my_task():
21
+ ...
22
+ """
23
+
24
+ base_delay: datetime.timedelta = dataclasses.field(
25
+ default=datetime.timedelta(seconds=1), doc="Base delay in seconds"
26
+ )
27
+ max_delay: datetime.timedelta = dataclasses.field(
28
+ default=datetime.timedelta(minutes=60), doc="Maximum delay in seconds"
29
+ )
30
+ factor: float = dataclasses.field(default=2.0, doc="Exponential factor for backoff")
31
+ max_retries: int = dataclasses.field(default=5, doc="Maximum number of retries")
32
+ expected_exceptions: tuple[type[Exception], ...] = dataclasses.field(
33
+ default=(Exception,), doc="Tuple of exception classes that trigger a retry"
34
+ )
35
+
36
+ def __post_init__(self) -> None:
37
+ """Resolve deserialized fields back to their original types."""
38
+ for field_name in ("base_delay", "max_delay"):
39
+ value = getattr(self, field_name)
40
+ if isinstance(value, (int, float)):
41
+ object.__setattr__(self, field_name, datetime.timedelta(seconds=value))
42
+ resolved = []
43
+ for exc in self.expected_exceptions:
44
+ if isinstance(exc, str):
45
+ from django.utils.module_loading import import_string
46
+
47
+ exc = import_string(exc)
48
+ resolved.append(exc)
49
+ object.__setattr__(self, "expected_exceptions", tuple(resolved))
50
+
51
+ def __call__(self, context: TaskContext) -> datetime.timedelta | None:
52
+ if context.attempt < self.max_retries and issubclass(
53
+ context.task_result.errors[-1].exception_class, self.expected_exceptions
54
+ ):
55
+ return min(self.base_delay * (self.factor**context.attempt), self.max_delay)
56
+
57
+ def deconstruct(self):
58
+ return (
59
+ f"{self.__class__.__module__}.{self.__class__.__qualname__}",
60
+ (),
61
+ {
62
+ "base_delay": self.base_delay.total_seconds(),
63
+ "max_delay": self.max_delay.total_seconds(),
64
+ "factor": self.factor,
65
+ "max_retries": self.max_retries,
66
+ "expected_exceptions": tuple(
67
+ f"{exc.__module__}.{exc.__qualname__}"
68
+ for exc in self.expected_exceptions
69
+ ),
70
+ },
71
+ )
File without changes