Skip to content

Commit e517919

Browse files
committed
feat: add NoResultError to abort retries immediately
1 parent ec60fe5 commit e517919

File tree

7 files changed

+74
-7
lines changed

7 files changed

+74
-7
lines changed

docs/advanced_usage/exception_handlers.md

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,43 @@ async def my_recovery_handler(exc: Exception, context: JobContext) -> str:
107107
@app.task(exception_handlers={ValueError: my_recovery_handler})
108108
async def my_task() -> None:
109109
raise ValueError("Oops!")
110+
```
111+
112+
### 3. Aborting Retries with NoResultError
113+
114+
Sometimes, an error can be fatal and retrying a task (even if the `retry` configuration is set) would be a waste of resources.
115+
In these cases, it is recommended to raise the `jobify.exceptions.NoResultError` exception.
116+
117+
- When this exception is raised, the job's status will be set to FAILED.
118+
- The `RetryMiddleware` component will catch this exception and stop all further retries.
119+
- No more retries will be attempted.
120+
121+
```python
122+
import asyncio
123+
124+
from jobify import JobContext, Jobify
125+
from jobify.exceptions import NoResultError
126+
127+
app = Jobify()
128+
129+
async def fatal_error_handler(exc: Exception, context: JobContext) -> None:
130+
print(f"Fatal error in job {context.job.id}: {exc}")
131+
# Signal that we should stop retries and fail the job immediately
132+
raise NoResultError
133+
134+
@app.task(retry=3, exception_handlers={ValueError: fatal_error_handler})
135+
async def my_task() -> None:
136+
raise ValueError("Corrupted data!")
137+
138+
async def main() -> None:
139+
async with app:
140+
job = await my_task.push()
141+
await job.wait()
142+
143+
print(job.status) # FAILED
144+
print(job.exception) # NoResultError
110145

111-
# ... after execution ...
112-
await job.wait()
113-
print(job.status) # SUCCESS
114-
print(job.result()) # "default_value"
146+
asyncio.run(main())
115147
```
116148

117149
## Example: Hierarchical Handling

src/jobify/_internal/common/datastructures.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ class EmptyPlaceholder(str):
1111
__slots__: tuple[()] = ()
1212

1313
def __new__(cls) -> Self:
14-
# Создаем пустую строку как основу
1514
return super().__new__(cls, "__EMPTY__")
1615

1716
def __bool__(self) -> Literal[False]:

src/jobify/_internal/exceptions.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,12 @@ def raise_app_already_started_error(operation: str) -> NoReturn:
102102
"Move this call outside/before the 'async with jobify:' block."
103103
),
104104
)
105+
106+
107+
class NoResultError(BaseJobifyError):
108+
"""Raised when a task should fail immediately without retrying."""
109+
110+
def __init__(
111+
self, msg: str = "Job aborted: no result expected and retries stopped."
112+
) -> None:
113+
super().__init__(msg)

src/jobify/_internal/middleware/retry.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from typing_extensions import override
88

9+
from jobify._internal.exceptions import NoResultError
910
from jobify._internal.middleware.base import BaseMiddleware, CallNext
1011

1112
if TYPE_CHECKING:
@@ -25,7 +26,9 @@ async def __call__(self, call_next: CallNext, context: JobContext) -> Any:
2526
while True:
2627
try:
2728
return await call_next(context)
28-
except Exception as exc: # noqa: PERF203
29+
except NoResultError: # noqa: PERF203
30+
raise
31+
except Exception as exc:
2932
failures += 1
3033
if failures > max_retries:
3134
msg = (

src/jobify/_internal/scheduler/job.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,8 @@ def set_exception(self, exc: Exception, *, status: JobStatus) -> None:
138138
self.status = status
139139

140140
def update(self, *, exec_at: datetime, status: JobStatus) -> None:
141-
self.status = status
142141
self._event = asyncio.Event()
142+
self.status = status
143143
self.exec_at = exec_at
144144

145145
def is_done(self) -> bool:

src/jobify/exceptions/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
JobFailedError,
1313
JobNotCompletedError,
1414
JobTimeoutError,
15+
NoResultError,
1516
RouteAlreadyRegisteredError,
1617
)
1718

@@ -22,5 +23,6 @@
2223
"JobFailedError",
2324
"JobNotCompletedError",
2425
"JobTimeoutError",
26+
"NoResultError",
2527
"RouteAlreadyRegisteredError",
2628
)

tests/test_middleware.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
from typing import Any
33
from unittest.mock import AsyncMock, Mock, call, patch, sentinel
44

5+
import pytest
56
from typing_extensions import override
67

78
from jobify import JobContext, JobStatus, OuterContext
9+
from jobify._internal.common.constants import EMPTY
10+
from jobify.exceptions import JobFailedError, NoResultError
811
from jobify.middleware import (
912
BaseMiddleware,
1013
BaseOuterMiddleware,
@@ -124,3 +127,22 @@ async def __call__(
124127
async with app:
125128
job = await f.schedule().delay(0.01)
126129
assert job._handle is handle is not None
130+
131+
132+
async def test_retry_no_result_error(amock: AsyncMock) -> None:
133+
amock.side_effect = NoResultError("Fatal failure")
134+
135+
app = create_app()
136+
f = app.task(amock, retry=3)
137+
async with app:
138+
job = await f.push()
139+
await job.wait()
140+
141+
assert job.status is JobStatus.FAILED
142+
143+
with pytest.raises(JobFailedError, match="Fatal failure"):
144+
job.result()
145+
146+
assert job._result is EMPTY
147+
assert type(job.exception) is NoResultError
148+
amock.assert_awaited_once()

0 commit comments

Comments
 (0)