relinker 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- relinker/.github/workflows/ci.yml +43 -0
- relinker/__init__.py +70 -0
- relinker/attempt.py +46 -0
- relinker/conditions/__init__.py +17 -0
- relinker/conditions/base.py +41 -0
- relinker/conditions/composite.py +38 -0
- relinker/conditions/custom.py +29 -0
- relinker/conditions/exception.py +26 -0
- relinker/conditions/result.py +24 -0
- relinker/context.py +619 -0
- relinker/delays/__init__.py +24 -0
- relinker/delays/base.py +31 -0
- relinker/delays/chain.py +35 -0
- relinker/delays/composite.py +18 -0
- relinker/delays/custom.py +22 -0
- relinker/delays/exponential.py +35 -0
- relinker/delays/fixed.py +22 -0
- relinker/delays/linear.py +35 -0
- relinker/delays/random_delay.py +35 -0
- relinker/delays/random_exponential.py +47 -0
- relinker/delays/stateful.py +85 -0
- relinker/diagnostics.py +179 -0
- relinker/event.py +50 -0
- relinker/exceptions.py +53 -0
- relinker/executors/__init__.py +6 -0
- relinker/executors/async_.py +378 -0
- relinker/executors/sync.py +378 -0
- relinker/http.py +189 -0
- relinker/internal/__init__.py +4 -0
- relinker/internal/clock.py +10 -0
- relinker/internal/decorator.py +65 -0
- relinker/internal/exhaustion.py +70 -0
- relinker/internal/policy_diagnostics.py +181 -0
- relinker/internal/policy_logging.py +108 -0
- relinker/internal/policy_simulation.py +199 -0
- relinker/internal/sleep.py +19 -0
- relinker/internal/validation.py +35 -0
- relinker/policy.py +614 -0
- relinker/presets.py +149 -0
- relinker/py.typed +0 -0
- relinker/result.py +213 -0
- relinker/retry.py +58 -0
- relinker/state.py +77 -0
- relinker/stats.py +120 -0
- relinker/stop/__init__.py +17 -0
- relinker/stop/attempts.py +22 -0
- relinker/stop/base.py +38 -0
- relinker/stop/composite.py +29 -0
- relinker/stop/forever.py +21 -0
- relinker/stop/max_time.py +22 -0
- relinker/testing/__init__.py +6 -0
- relinker/testing/fake_task.py +44 -0
- relinker/testing/no_sleep.py +33 -0
- relinker/typing.py +54 -0
- relinker-0.6.0.dist-info/METADATA +397 -0
- relinker-0.6.0.dist-info/RECORD +58 -0
- relinker-0.6.0.dist-info/WHEEL +4 -0
- relinker-0.6.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: ["main"]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: ["main"]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
name: Python ${{ matrix.python-version }}
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
|
|
14
|
+
strategy:
|
|
15
|
+
fail-fast: false
|
|
16
|
+
matrix:
|
|
17
|
+
python-version: ["3.10", "3.11", "3.12"]
|
|
18
|
+
|
|
19
|
+
steps:
|
|
20
|
+
- name: Checkout repository
|
|
21
|
+
uses: actions/checkout@v4
|
|
22
|
+
|
|
23
|
+
- name: Set up Python
|
|
24
|
+
uses: actions/setup-python@v5
|
|
25
|
+
with:
|
|
26
|
+
python-version: ${{ matrix.python-version }}
|
|
27
|
+
|
|
28
|
+
- name: Install package with development dependencies
|
|
29
|
+
run: |
|
|
30
|
+
python -m pip install --upgrade pip
|
|
31
|
+
python -m pip install -e ".[dev]"
|
|
32
|
+
|
|
33
|
+
- name: Check formatting
|
|
34
|
+
run: ruff format --check .
|
|
35
|
+
|
|
36
|
+
- name: Run lint
|
|
37
|
+
run: ruff check .
|
|
38
|
+
|
|
39
|
+
- name: Run type checks
|
|
40
|
+
run: mypy src
|
|
41
|
+
|
|
42
|
+
- name: Run tests
|
|
43
|
+
run: pytest
|
relinker/__init__.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Relinker public API.
|
|
3
|
+
|
|
4
|
+
This module exposes the stable imports users should rely on. Internal modules can
|
|
5
|
+
change, but these names should remain stable whenever possible.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from relinker.context import AsyncRetryAttemptContext, RetryAttemptContext
|
|
11
|
+
from relinker.diagnostics import (
|
|
12
|
+
PolicyHealthReport,
|
|
13
|
+
PolicyWarning,
|
|
14
|
+
RetrySimulation,
|
|
15
|
+
RetrySimulationAttempt,
|
|
16
|
+
)
|
|
17
|
+
from relinker.exceptions import (
|
|
18
|
+
InvalidRetryConfigError,
|
|
19
|
+
RelinkerError,
|
|
20
|
+
RetryExhaustedError,
|
|
21
|
+
TryAgain,
|
|
22
|
+
)
|
|
23
|
+
from relinker.http import (
|
|
24
|
+
DEFAULT_RETRYABLE_STATUSES,
|
|
25
|
+
http_retry_policy,
|
|
26
|
+
parse_retry_after,
|
|
27
|
+
retry_after_delay,
|
|
28
|
+
retry_if_status,
|
|
29
|
+
should_retry_http_status,
|
|
30
|
+
)
|
|
31
|
+
from relinker.policy import RetryPolicy
|
|
32
|
+
from relinker.presets import background_job, database, fast, network, patient
|
|
33
|
+
from relinker.result import RetryResult
|
|
34
|
+
from relinker.retry import retry
|
|
35
|
+
from relinker.state import RetryState
|
|
36
|
+
from relinker.stats import RetryStats, RetryStatsSnapshot
|
|
37
|
+
from relinker.typing import RetryWrappedFunction
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"AsyncRetryAttemptContext",
|
|
41
|
+
"DEFAULT_RETRYABLE_STATUSES",
|
|
42
|
+
"InvalidRetryConfigError",
|
|
43
|
+
"PolicyHealthReport",
|
|
44
|
+
"PolicyWarning",
|
|
45
|
+
"RetryAttemptContext",
|
|
46
|
+
"RetryExhaustedError",
|
|
47
|
+
"RelinkerError",
|
|
48
|
+
"RetryPolicy",
|
|
49
|
+
"RetryResult",
|
|
50
|
+
"RetrySimulation",
|
|
51
|
+
"RetrySimulationAttempt",
|
|
52
|
+
"RetryState",
|
|
53
|
+
"RetryStats",
|
|
54
|
+
"RetryStatsSnapshot",
|
|
55
|
+
"RetryWrappedFunction",
|
|
56
|
+
"TryAgain",
|
|
57
|
+
"background_job",
|
|
58
|
+
"database",
|
|
59
|
+
"fast",
|
|
60
|
+
"http_retry_policy",
|
|
61
|
+
"network",
|
|
62
|
+
"parse_retry_after",
|
|
63
|
+
"patient",
|
|
64
|
+
"retry",
|
|
65
|
+
"retry_after_delay",
|
|
66
|
+
"retry_if_status",
|
|
67
|
+
"should_retry_http_status",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
__version__ = "0.6.0"
|
relinker/attempt.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Attempt records.
|
|
3
|
+
|
|
4
|
+
An attempt is one execution of the wrapped function. Relinker stores attempts
|
|
5
|
+
so users can inspect what happened after a run.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class AttemptRecord:
|
|
16
|
+
"""
|
|
17
|
+
Immutable information about a single attempt.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
number: One-based attempt number.
|
|
21
|
+
started_at: Monotonic timestamp when the attempt started.
|
|
22
|
+
ended_at: Monotonic timestamp when the attempt ended.
|
|
23
|
+
value: Returned value, when the attempt succeeded.
|
|
24
|
+
error: Raised exception, when the attempt failed.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
number: int
|
|
28
|
+
started_at: float
|
|
29
|
+
ended_at: float
|
|
30
|
+
value: Any = None
|
|
31
|
+
error: BaseException | None = None
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def duration(self) -> float:
|
|
35
|
+
"""Return how many seconds this attempt took."""
|
|
36
|
+
return self.ended_at - self.started_at
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def succeeded(self) -> bool:
|
|
40
|
+
"""Return True when this attempt completed without an exception."""
|
|
41
|
+
return self.error is None
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def failed(self) -> bool:
|
|
45
|
+
"""Return True when this attempt raised an exception."""
|
|
46
|
+
return self.error is not None
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Retry conditions."""
|
|
2
|
+
|
|
3
|
+
from relinker.conditions.base import ConditionMixin, RetryCondition
|
|
4
|
+
from relinker.conditions.composite import AllCondition, AnyCondition
|
|
5
|
+
from relinker.conditions.custom import CustomCondition
|
|
6
|
+
from relinker.conditions.exception import ExceptionCondition
|
|
7
|
+
from relinker.conditions.result import ResultCondition
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AllCondition",
|
|
11
|
+
"AnyCondition",
|
|
12
|
+
"ConditionMixin",
|
|
13
|
+
"CustomCondition",
|
|
14
|
+
"ExceptionCondition",
|
|
15
|
+
"ResultCondition",
|
|
16
|
+
"RetryCondition",
|
|
17
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Base retry condition interfaces and composition helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Protocol, cast
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RetryCondition(Protocol):
|
|
9
|
+
"""
|
|
10
|
+
Protocol implemented by retry conditions.
|
|
11
|
+
|
|
12
|
+
A condition can decide based on an exception, a returned value, or both.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def should_retry_exception(self, error: BaseException) -> bool:
|
|
16
|
+
"""Return True when this exception should be retried."""
|
|
17
|
+
|
|
18
|
+
def should_retry_result(self, value: Any) -> bool:
|
|
19
|
+
"""Return True when this returned value should be retried."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConditionMixin:
|
|
23
|
+
"""
|
|
24
|
+
Mixin that gives retry conditions boolean composition.
|
|
25
|
+
|
|
26
|
+
This mixin is intentionally not a Protocol itself. The cast calls below tell
|
|
27
|
+
static type checkers that classes using this mixin are expected to implement
|
|
28
|
+
the RetryCondition protocol.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __or__(self, other: RetryCondition) -> RetryCondition:
|
|
32
|
+
"""Return a condition that passes when either condition passes."""
|
|
33
|
+
from relinker.conditions.composite import AnyCondition
|
|
34
|
+
|
|
35
|
+
return AnyCondition((cast(RetryCondition, self), other))
|
|
36
|
+
|
|
37
|
+
def __and__(self, other: RetryCondition) -> RetryCondition:
|
|
38
|
+
"""Return a condition that passes only when both conditions pass."""
|
|
39
|
+
from relinker.conditions.composite import AllCondition
|
|
40
|
+
|
|
41
|
+
return AllCondition((cast(RetryCondition, self), other))
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Composite retry conditions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from relinker.conditions.base import ConditionMixin, RetryCondition
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class AnyCondition(ConditionMixin):
|
|
13
|
+
"""Retries when any child condition says retry."""
|
|
14
|
+
|
|
15
|
+
conditions: tuple[RetryCondition, ...]
|
|
16
|
+
|
|
17
|
+
def should_retry_exception(self, error: BaseException) -> bool:
|
|
18
|
+
"""Return True when any child retries this exception."""
|
|
19
|
+
return any(condition.should_retry_exception(error) for condition in self.conditions)
|
|
20
|
+
|
|
21
|
+
def should_retry_result(self, value: Any) -> bool:
|
|
22
|
+
"""Return True when any child retries this value."""
|
|
23
|
+
return any(condition.should_retry_result(value) for condition in self.conditions)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class AllCondition(ConditionMixin):
|
|
28
|
+
"""Retries only when all child conditions say retry."""
|
|
29
|
+
|
|
30
|
+
conditions: tuple[RetryCondition, ...]
|
|
31
|
+
|
|
32
|
+
def should_retry_exception(self, error: BaseException) -> bool:
|
|
33
|
+
"""Return True when all children retry this exception."""
|
|
34
|
+
return all(condition.should_retry_exception(error) for condition in self.conditions)
|
|
35
|
+
|
|
36
|
+
def should_retry_result(self, value: Any) -> bool:
|
|
37
|
+
"""Return True when all children retry this value."""
|
|
38
|
+
return all(condition.should_retry_result(value) for condition in self.conditions)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Custom retry condition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from relinker.conditions.base import ConditionMixin
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class CustomCondition(ConditionMixin):
|
|
14
|
+
"""
|
|
15
|
+
Fully custom retry condition.
|
|
16
|
+
|
|
17
|
+
The callback receives either an error or a value. Exactly one of them will
|
|
18
|
+
be non-None for each decision.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
callback: Callable[[BaseException | None, Any], bool]
|
|
22
|
+
|
|
23
|
+
def should_retry_exception(self, error: BaseException) -> bool:
|
|
24
|
+
"""Return True when the callback says the exception should be retried."""
|
|
25
|
+
return bool(self.callback(error, None))
|
|
26
|
+
|
|
27
|
+
def should_retry_result(self, value: Any) -> bool:
|
|
28
|
+
"""Return True when the callback says the value should be retried."""
|
|
29
|
+
return bool(self.callback(None, value))
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Retry condition based on exception types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from relinker.conditions.base import ConditionMixin
|
|
8
|
+
from relinker.internal.validation import ensure_exception_types
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class ExceptionCondition(ConditionMixin):
|
|
13
|
+
"""Retries when the raised exception matches one of the configured types."""
|
|
14
|
+
|
|
15
|
+
exception_types: tuple[type[BaseException], ...] = (Exception,)
|
|
16
|
+
|
|
17
|
+
def __post_init__(self) -> None:
|
|
18
|
+
ensure_exception_types(self.exception_types)
|
|
19
|
+
|
|
20
|
+
def should_retry_exception(self, error: BaseException) -> bool:
|
|
21
|
+
"""Return True when the error matches the configured exception types."""
|
|
22
|
+
return isinstance(error, self.exception_types)
|
|
23
|
+
|
|
24
|
+
def should_retry_result(self, value: object) -> bool:
|
|
25
|
+
"""Exception-based conditions do not retry successful return values."""
|
|
26
|
+
return False
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Retry condition based on returned values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from relinker.conditions.base import ConditionMixin
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class ResultCondition(ConditionMixin):
|
|
14
|
+
"""Retries when a user-provided predicate returns True for a value."""
|
|
15
|
+
|
|
16
|
+
predicate: Callable[[Any], bool]
|
|
17
|
+
|
|
18
|
+
def should_retry_exception(self, error: BaseException) -> bool:
|
|
19
|
+
"""Result-based conditions do not retry exceptions."""
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
def should_retry_result(self, value: Any) -> bool:
|
|
23
|
+
"""Return True when the value should trigger another attempt."""
|
|
24
|
+
return bool(self.predicate(value))
|