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.
Files changed (58) hide show
  1. relinker/.github/workflows/ci.yml +43 -0
  2. relinker/__init__.py +70 -0
  3. relinker/attempt.py +46 -0
  4. relinker/conditions/__init__.py +17 -0
  5. relinker/conditions/base.py +41 -0
  6. relinker/conditions/composite.py +38 -0
  7. relinker/conditions/custom.py +29 -0
  8. relinker/conditions/exception.py +26 -0
  9. relinker/conditions/result.py +24 -0
  10. relinker/context.py +619 -0
  11. relinker/delays/__init__.py +24 -0
  12. relinker/delays/base.py +31 -0
  13. relinker/delays/chain.py +35 -0
  14. relinker/delays/composite.py +18 -0
  15. relinker/delays/custom.py +22 -0
  16. relinker/delays/exponential.py +35 -0
  17. relinker/delays/fixed.py +22 -0
  18. relinker/delays/linear.py +35 -0
  19. relinker/delays/random_delay.py +35 -0
  20. relinker/delays/random_exponential.py +47 -0
  21. relinker/delays/stateful.py +85 -0
  22. relinker/diagnostics.py +179 -0
  23. relinker/event.py +50 -0
  24. relinker/exceptions.py +53 -0
  25. relinker/executors/__init__.py +6 -0
  26. relinker/executors/async_.py +378 -0
  27. relinker/executors/sync.py +378 -0
  28. relinker/http.py +189 -0
  29. relinker/internal/__init__.py +4 -0
  30. relinker/internal/clock.py +10 -0
  31. relinker/internal/decorator.py +65 -0
  32. relinker/internal/exhaustion.py +70 -0
  33. relinker/internal/policy_diagnostics.py +181 -0
  34. relinker/internal/policy_logging.py +108 -0
  35. relinker/internal/policy_simulation.py +199 -0
  36. relinker/internal/sleep.py +19 -0
  37. relinker/internal/validation.py +35 -0
  38. relinker/policy.py +614 -0
  39. relinker/presets.py +149 -0
  40. relinker/py.typed +0 -0
  41. relinker/result.py +213 -0
  42. relinker/retry.py +58 -0
  43. relinker/state.py +77 -0
  44. relinker/stats.py +120 -0
  45. relinker/stop/__init__.py +17 -0
  46. relinker/stop/attempts.py +22 -0
  47. relinker/stop/base.py +38 -0
  48. relinker/stop/composite.py +29 -0
  49. relinker/stop/forever.py +21 -0
  50. relinker/stop/max_time.py +22 -0
  51. relinker/testing/__init__.py +6 -0
  52. relinker/testing/fake_task.py +44 -0
  53. relinker/testing/no_sleep.py +33 -0
  54. relinker/typing.py +54 -0
  55. relinker-0.6.0.dist-info/METADATA +397 -0
  56. relinker-0.6.0.dist-info/RECORD +58 -0
  57. relinker-0.6.0.dist-info/WHEEL +4 -0
  58. 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))