resilience-kit 0.1.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.
- resilience_kit/__init__.py +123 -0
- resilience_kit/_providers.py +104 -0
- resilience_kit/_version.py +7 -0
- resilience_kit/adapters/__init__.py +13 -0
- resilience_kit/adapters/_envelope.py +135 -0
- resilience_kit/adapters/django/__init__.py +27 -0
- resilience_kit/adapters/django/apps.py +155 -0
- resilience_kit/adapters/django/drf_throttles.py +163 -0
- resilience_kit/adapters/django/exception_handler.py +94 -0
- resilience_kit/adapters/django/fields.py +70 -0
- resilience_kit/adapters/django/management/__init__.py +0 -0
- resilience_kit/adapters/django/management/commands/__init__.py +0 -0
- resilience_kit/adapters/django/management/commands/resilience_reset.py +60 -0
- resilience_kit/adapters/django/management/commands/resilience_status.py +65 -0
- resilience_kit/adapters/django/middleware.py +392 -0
- resilience_kit/adapters/fastapi/__init__.py +27 -0
- resilience_kit/adapters/fastapi/dependencies.py +137 -0
- resilience_kit/adapters/fastapi/exception_handlers.py +114 -0
- resilience_kit/adapters/fastapi/fields.py +82 -0
- resilience_kit/adapters/fastapi/lifespan.py +145 -0
- resilience_kit/adapters/fastapi/middleware.py +124 -0
- resilience_kit/audit/__init__.py +29 -0
- resilience_kit/audit/backends/__init__.py +23 -0
- resilience_kit/audit/backends/base.py +81 -0
- resilience_kit/audit/backends/noop.py +30 -0
- resilience_kit/audit/backends/postgres.py +182 -0
- resilience_kit/audit/backends/stdlib_logging.py +59 -0
- resilience_kit/audit/decorators.py +237 -0
- resilience_kit/audit/dispatch.py +234 -0
- resilience_kit/audit/factory.py +110 -0
- resilience_kit/audit/sanitizers.py +105 -0
- resilience_kit/cache/__init__.py +11 -0
- resilience_kit/cache/base.py +85 -0
- resilience_kit/cache/memory_impl.py +139 -0
- resilience_kit/cache/provider.py +96 -0
- resilience_kit/cache/redis_impl.py +245 -0
- resilience_kit/circuit_breaker/__init__.py +17 -0
- resilience_kit/circuit_breaker/base.py +113 -0
- resilience_kit/circuit_breaker/lua_scripts.py +96 -0
- resilience_kit/circuit_breaker/memory_impl.py +183 -0
- resilience_kit/circuit_breaker/provider.py +133 -0
- resilience_kit/circuit_breaker/pybreaker_impl.py +135 -0
- resilience_kit/circuit_breaker/redis_impl.py +295 -0
- resilience_kit/context.py +90 -0
- resilience_kit/crypto/__init__.py +27 -0
- resilience_kit/crypto/exceptions.py +38 -0
- resilience_kit/crypto/fernet.py +166 -0
- resilience_kit/decorators.py +146 -0
- resilience_kit/dispatch/__init__.py +14 -0
- resilience_kit/dispatch/fire_and_forget.py +249 -0
- resilience_kit/exceptions/__init__.py +36 -0
- resilience_kit/exceptions/base.py +59 -0
- resilience_kit/exceptions/http_status.py +59 -0
- resilience_kit/exceptions/infrastructure.py +138 -0
- resilience_kit/exceptions/validation.py +92 -0
- resilience_kit/health.py +127 -0
- resilience_kit/http_client/__init__.py +33 -0
- resilience_kit/http_client/auth.py +161 -0
- resilience_kit/http_client/client.py +385 -0
- resilience_kit/http_client/dns_pin.py +134 -0
- resilience_kit/http_client/errors.py +137 -0
- resilience_kit/http_client/session.py +107 -0
- resilience_kit/metrics.py +207 -0
- resilience_kit/middleware/__init__.py +29 -0
- resilience_kit/middleware/_asgi.py +16 -0
- resilience_kit/middleware/body_limit.py +92 -0
- resilience_kit/middleware/exception_logging.py +122 -0
- resilience_kit/middleware/rate_limit_headers.py +69 -0
- resilience_kit/middleware/request_id.py +100 -0
- resilience_kit/middleware/security_headers.py +74 -0
- resilience_kit/middleware/selective_cors.py +119 -0
- resilience_kit/py.typed +0 -0
- resilience_kit/recovery.py +236 -0
- resilience_kit/registry.py +206 -0
- resilience_kit/retry/__init__.py +11 -0
- resilience_kit/retry/backoff.py +71 -0
- resilience_kit/retry/decorator.py +362 -0
- resilience_kit/runtime.py +201 -0
- resilience_kit/settings.py +125 -0
- resilience_kit/ssrf/__init__.py +24 -0
- resilience_kit/ssrf/_ipchecks.py +68 -0
- resilience_kit/ssrf/guard.py +204 -0
- resilience_kit/tasks/__init__.py +22 -0
- resilience_kit/tasks/queue.py +100 -0
- resilience_kit/tasks/registry.py +80 -0
- resilience_kit/testing/__init__.py +60 -0
- resilience_kit/testing/contract.py +137 -0
- resilience_kit/testing/fakes.py +129 -0
- resilience_kit/testing/reset.py +58 -0
- resilience_kit/throttle/__init__.py +25 -0
- resilience_kit/throttle/base.py +143 -0
- resilience_kit/throttle/lua_scripts.py +71 -0
- resilience_kit/throttle/memory_impl.py +94 -0
- resilience_kit/throttle/provider.py +93 -0
- resilience_kit/throttle/redis_impl.py +206 -0
- resilience_kit/throttle/scopes.py +80 -0
- resilience_kit-0.1.0.dist-info/METADATA +440 -0
- resilience_kit-0.1.0.dist-info/RECORD +101 -0
- resilience_kit-0.1.0.dist-info/WHEEL +4 -0
- resilience_kit-0.1.0.dist-info/entry_points.txt +19 -0
- resilience_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""``resilience-kit`` — framework-agnostic Python resilience kernel.
|
|
2
|
+
|
|
3
|
+
Public surface:
|
|
4
|
+
|
|
5
|
+
* Decorators — :func:`retry`, :func:`retry_on_failure`,
|
|
6
|
+
:func:`circuit_breaker`, :func:`resilient`.
|
|
7
|
+
* Registry — :class:`ResilienceRegistry`, :data:`registry`.
|
|
8
|
+
* Exceptions — see :mod:`resilience_kit.exceptions`.
|
|
9
|
+
* SSRF guard — :func:`resolve_and_validate`, :func:`assert_public_url`,
|
|
10
|
+
:func:`assert_allowed_url` (M3).
|
|
11
|
+
* HTTP client — :class:`AsyncAPIClient`, :func:`pinned` (M3, requires
|
|
12
|
+
the ``http`` extra; imported lazily so users without the extra still
|
|
13
|
+
``import resilience_kit``).
|
|
14
|
+
* Field crypto — :class:`FernetCipher` (M3, requires the ``crypto``
|
|
15
|
+
extra; imported lazily).
|
|
16
|
+
|
|
17
|
+
Backends and adapters land in later milestones (see ``docs/ROADMAP.md``).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import TYPE_CHECKING, Any
|
|
23
|
+
|
|
24
|
+
from resilience_kit._version import __version__
|
|
25
|
+
from resilience_kit.audit import AuditEvent, log_inbound, log_outbound
|
|
26
|
+
from resilience_kit.decorators import circuit_breaker, resilient
|
|
27
|
+
from resilience_kit.exceptions import (
|
|
28
|
+
HTTP_STATUS_MAP,
|
|
29
|
+
DecryptionError,
|
|
30
|
+
ExternalServiceError,
|
|
31
|
+
ExternalTimeoutError,
|
|
32
|
+
MissingExtraError,
|
|
33
|
+
RateLimitError,
|
|
34
|
+
RepositoryError,
|
|
35
|
+
ResilienceKitError,
|
|
36
|
+
ServiceUnavailableError,
|
|
37
|
+
TransientError,
|
|
38
|
+
UnknownBackendError,
|
|
39
|
+
ValidationError,
|
|
40
|
+
http_status_for,
|
|
41
|
+
)
|
|
42
|
+
from resilience_kit.health import HealthAggregate, HealthStatus, health_snapshot
|
|
43
|
+
from resilience_kit.registry import ResilienceRegistry, registry
|
|
44
|
+
from resilience_kit.retry import retry, retry_on_failure
|
|
45
|
+
from resilience_kit.ssrf import (
|
|
46
|
+
assert_allowed_url,
|
|
47
|
+
assert_public_url,
|
|
48
|
+
resolve_and_validate,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if TYPE_CHECKING:
|
|
52
|
+
from resilience_kit.crypto import FernetCipher
|
|
53
|
+
from resilience_kit.http_client import AsyncAPIClient, pinned
|
|
54
|
+
|
|
55
|
+
# Lazy re-exports for optional-extra-gated names.
|
|
56
|
+
# Importing the kit without ``[http]`` / ``[crypto]`` must not fail.
|
|
57
|
+
_LAZY: dict[str, tuple[str, str]] = {
|
|
58
|
+
"AsyncAPIClient": ("resilience_kit.http_client", "AsyncAPIClient"),
|
|
59
|
+
"pinned": ("resilience_kit.http_client", "pinned"),
|
|
60
|
+
"FernetCipher": ("resilience_kit.crypto", "FernetCipher"),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def __getattr__(name: str) -> Any:
|
|
65
|
+
"""Resolve optional-extra-gated names lazily on first access.
|
|
66
|
+
|
|
67
|
+
Importing :mod:`resilience_kit` itself must not require the
|
|
68
|
+
``[http]`` or ``[crypto]`` extras. The names below are resolved at
|
|
69
|
+
attribute-access time; missing the extra surfaces as
|
|
70
|
+
:class:`MissingExtraError` (raised by the submodule's import guard).
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
name: Attribute name being accessed.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
The resolved attribute.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
AttributeError: ``name`` is not exposed by this package.
|
|
80
|
+
"""
|
|
81
|
+
if name in _LAZY:
|
|
82
|
+
module_path, attr = _LAZY[name]
|
|
83
|
+
import importlib # noqa: PLC0415
|
|
84
|
+
|
|
85
|
+
return getattr(importlib.import_module(module_path), attr)
|
|
86
|
+
msg = f"module 'resilience_kit' has no attribute {name!r}"
|
|
87
|
+
raise AttributeError(msg)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
__all__ = [
|
|
91
|
+
"HTTP_STATUS_MAP",
|
|
92
|
+
"AsyncAPIClient",
|
|
93
|
+
"AuditEvent",
|
|
94
|
+
"DecryptionError",
|
|
95
|
+
"ExternalServiceError",
|
|
96
|
+
"ExternalTimeoutError",
|
|
97
|
+
"FernetCipher",
|
|
98
|
+
"HealthAggregate",
|
|
99
|
+
"HealthStatus",
|
|
100
|
+
"MissingExtraError",
|
|
101
|
+
"RateLimitError",
|
|
102
|
+
"RepositoryError",
|
|
103
|
+
"ResilienceKitError",
|
|
104
|
+
"ResilienceRegistry",
|
|
105
|
+
"ServiceUnavailableError",
|
|
106
|
+
"TransientError",
|
|
107
|
+
"UnknownBackendError",
|
|
108
|
+
"ValidationError",
|
|
109
|
+
"__version__",
|
|
110
|
+
"assert_allowed_url",
|
|
111
|
+
"assert_public_url",
|
|
112
|
+
"circuit_breaker",
|
|
113
|
+
"health_snapshot",
|
|
114
|
+
"http_status_for",
|
|
115
|
+
"log_inbound",
|
|
116
|
+
"log_outbound",
|
|
117
|
+
"pinned",
|
|
118
|
+
"registry",
|
|
119
|
+
"resilient",
|
|
120
|
+
"resolve_and_validate",
|
|
121
|
+
"retry",
|
|
122
|
+
"retry_on_failure",
|
|
123
|
+
]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Shared backend-provider resolution chain (LLD §3).
|
|
2
|
+
|
|
3
|
+
One helper, used by every swappable subsystem (cache / breaker / throttle /
|
|
4
|
+
audit / sanitizer / metrics / settings-source). Resolution order:
|
|
5
|
+
|
|
6
|
+
1. Explicit callable / instance — return as-is.
|
|
7
|
+
2. ``"pkg.mod:Class"`` importable string — import + instantiate.
|
|
8
|
+
3. ``name`` matches an entry point in the named group — load + instantiate.
|
|
9
|
+
4. ``name`` matches one of the kit's builtin names — instantiate.
|
|
10
|
+
5. Otherwise raise :class:`UnknownBackendError` with the list of available
|
|
11
|
+
names so the failure message itself tells the operator what to set
|
|
12
|
+
``RESILIENCE_<SUBSYSTEM>_BACKEND`` to.
|
|
13
|
+
|
|
14
|
+
Backends gated behind a pip extra raise :class:`MissingExtraError` at
|
|
15
|
+
import time (not first use), so the failure is immediate and the hint is
|
|
16
|
+
unambiguous.
|
|
17
|
+
|
|
18
|
+
Precedence note (ADR 0004): entry points are checked **before** builtins
|
|
19
|
+
(step 3 before step 4). A third-party package that publishes an entry
|
|
20
|
+
point with the same name as a kit builtin therefore **shadows** the
|
|
21
|
+
builtin. This is intentional — it lets a third party ship a drop-in
|
|
22
|
+
replacement for ``memory`` / ``noop`` / ``stdlib_logging`` without
|
|
23
|
+
forking the kit — but it is also a footgun for accidental name
|
|
24
|
+
collisions. Operators should namespace third-party backend names
|
|
25
|
+
(``acme-redis``, not ``redis``) to avoid surprises. Documented in
|
|
26
|
+
``docs/LLD.md`` §3 and ``docs/adr/0004-entry-points-for-third-party-backends.md``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import importlib
|
|
32
|
+
from importlib.metadata import entry_points
|
|
33
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
34
|
+
|
|
35
|
+
from resilience_kit.exceptions import UnknownBackendError
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from collections.abc import Callable, Mapping
|
|
39
|
+
|
|
40
|
+
T = TypeVar("T")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resolve_provider(
|
|
44
|
+
*,
|
|
45
|
+
group: str,
|
|
46
|
+
name: str | T | Callable[..., T],
|
|
47
|
+
builtins: Mapping[str, Callable[..., T]],
|
|
48
|
+
factory_kwargs: Mapping[str, Any] | None = None,
|
|
49
|
+
) -> T:
|
|
50
|
+
"""Resolve a backend instance via the 5-step chain in this module's docstring.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
group: Entry-point group queried (e.g. ``"resilience_kit.cache_backends"``).
|
|
54
|
+
name: Explicit instance / callable, importable ``"mod:Class"`` string,
|
|
55
|
+
entry-point name, or builtin name.
|
|
56
|
+
builtins: Mapping of builtin names → callables that produce a backend.
|
|
57
|
+
factory_kwargs: Optional kwargs threaded into the factory call when
|
|
58
|
+
``name`` resolves to a callable (string / entry-point / builtin
|
|
59
|
+
cases). Pre-built instances pass through unchanged.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
The resolved backend.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
UnknownBackendError: ``name`` is a string but does not resolve via
|
|
66
|
+
entry-point or builtin.
|
|
67
|
+
TypeError: ``name`` is a string with ``":"`` but the import target
|
|
68
|
+
is not a callable.
|
|
69
|
+
"""
|
|
70
|
+
kwargs: Mapping[str, Any] = factory_kwargs or {}
|
|
71
|
+
|
|
72
|
+
# 1. Explicit instance — anything that is not a string is treated as
|
|
73
|
+
# either a ready-made instance or a no-arg factory the caller wants to
|
|
74
|
+
# use as-is.
|
|
75
|
+
if not isinstance(name, str):
|
|
76
|
+
return name(**kwargs) if callable(name) else name
|
|
77
|
+
|
|
78
|
+
# 2. Importable string "pkg.mod:Class".
|
|
79
|
+
if ":" in name:
|
|
80
|
+
module_path, _, attr = name.partition(":")
|
|
81
|
+
module = importlib.import_module(module_path)
|
|
82
|
+
target = getattr(module, attr)
|
|
83
|
+
if not callable(target):
|
|
84
|
+
raise TypeError(
|
|
85
|
+
f"Importable string {name!r} resolved to a non-callable "
|
|
86
|
+
f"({type(target).__name__}); expected a class or factory.",
|
|
87
|
+
)
|
|
88
|
+
return target(**kwargs) # type: ignore[no-any-return]
|
|
89
|
+
|
|
90
|
+
# 3. Entry-point lookup.
|
|
91
|
+
for ep in entry_points(group=group):
|
|
92
|
+
if ep.name == name:
|
|
93
|
+
target = ep.load()
|
|
94
|
+
return target(**kwargs) # type: ignore[no-any-return]
|
|
95
|
+
|
|
96
|
+
# 4. Builtin.
|
|
97
|
+
if name in builtins:
|
|
98
|
+
return builtins[name](**kwargs)
|
|
99
|
+
|
|
100
|
+
# 5. Fail with the list of options.
|
|
101
|
+
available = sorted(
|
|
102
|
+
{*builtins.keys(), *(ep.name for ep in entry_points(group=group))},
|
|
103
|
+
)
|
|
104
|
+
raise UnknownBackendError(group=group, name=name, available=available)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Framework adapters.
|
|
2
|
+
|
|
3
|
+
Each adapter is pure glue between a web framework's lifecycle hooks and
|
|
4
|
+
the kit's framework-agnostic primitives (recovery monitor, audit
|
|
5
|
+
dispatcher, health aggregator, middleware, decorators). Adapters never
|
|
6
|
+
hold business logic; if an adapter file grows past ~300 LOC the
|
|
7
|
+
underlying primitive is wrong, not the adapter.
|
|
8
|
+
|
|
9
|
+
Sub-packages are extras-gated: importing
|
|
10
|
+
``resilience_kit.adapters.fastapi`` (or ``resilience_kit.adapters.django``)
|
|
11
|
+
without the corresponding extra installed raises
|
|
12
|
+
:class:`~resilience_kit.exceptions.MissingExtraError`.
|
|
13
|
+
"""
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Framework-agnostic envelope builder for kit exceptions.
|
|
2
|
+
|
|
3
|
+
Both the FastAPI and Django adapters need to translate a
|
|
4
|
+
:class:`~resilience_kit.exceptions.ResilienceKitError` into
|
|
5
|
+
|
|
6
|
+
* a JSON-serialisable body dict,
|
|
7
|
+
* an HTTP status code,
|
|
8
|
+
* a header dict (with ``Retry-After`` + ``X-RateLimit-*`` for rate-limit errors).
|
|
9
|
+
|
|
10
|
+
Before this module they each built that triple inline, producing duplicated
|
|
11
|
+
code (FastAPI :func:`_envelope` and Django :func:`_build_response` had the same
|
|
12
|
+
body, severity, and header logic). The duplication is consolidated here so a
|
|
13
|
+
third adapter (Litestar / Flask / Starlette-only) gets the behaviour for free
|
|
14
|
+
and consumers can call :func:`from_exception` directly to wrap kit exceptions
|
|
15
|
+
into a non-kit envelope shape — the M7 FastAPI dogfooding fix for the
|
|
16
|
+
two-handler envelope collision (MIGRATION §10.2 "Option 3").
|
|
17
|
+
|
|
18
|
+
The default envelope shape (``envelope_cls=None``) is **byte-for-byte
|
|
19
|
+
identical** to what both adapters emitted before the refactor — LLD §11's
|
|
20
|
+
``{error_code, message, details}``. The ``envelope_cls`` projection is opt-in
|
|
21
|
+
and only consulted when a consumer hands in their own pydantic model.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import TYPE_CHECKING, Any
|
|
27
|
+
|
|
28
|
+
from resilience_kit.context import request_id
|
|
29
|
+
from resilience_kit.exceptions import RateLimitError, ResilienceKitError, http_status_for
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from collections.abc import Mapping
|
|
33
|
+
|
|
34
|
+
from pydantic import BaseModel
|
|
35
|
+
|
|
36
|
+
# Field-name aliases recognised when projecting onto a consumer envelope model.
|
|
37
|
+
# Order matters — the first match wins. If a target model declares more than
|
|
38
|
+
# one, only the first is filled.
|
|
39
|
+
_ERROR_CODE_ALIASES = ("error_code", "code", "error")
|
|
40
|
+
_MESSAGE_ALIASES = ("message", "detail")
|
|
41
|
+
_DETAILS_ALIASES = ("details", "errors")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def from_exception(
|
|
45
|
+
exc: ResilienceKitError,
|
|
46
|
+
*,
|
|
47
|
+
envelope_cls: type[BaseModel] | None = None,
|
|
48
|
+
extra_headers: Mapping[str, str] | None = None,
|
|
49
|
+
) -> tuple[dict[str, Any], int, dict[str, str]]:
|
|
50
|
+
"""Build ``(body, status, headers)`` for a kit exception.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
exc: Any :class:`ResilienceKitError` subclass instance.
|
|
54
|
+
envelope_cls: Optional pydantic model whose field names should
|
|
55
|
+
receive the projected values. When omitted the body is the
|
|
56
|
+
locked LLD §11 shape — ``{error_code, message, details}``.
|
|
57
|
+
When given, the projection looks at ``envelope_cls.model_fields``
|
|
58
|
+
and writes ``error_code`` onto whichever of ``error_code | code
|
|
59
|
+
| error`` is declared, ``message`` onto ``message | detail``,
|
|
60
|
+
and ``details`` onto ``details`` (as a dict) or ``errors`` (as
|
|
61
|
+
a list of ``{field, message}`` objects, mirroring DRF-style
|
|
62
|
+
validation envelopes). A ``request_id`` field is filled from
|
|
63
|
+
the current :data:`resilience_kit.context.request_id` value
|
|
64
|
+
when present. A ``success`` field is set to ``False``.
|
|
65
|
+
Other declared fields are left unfilled — the consumer's model
|
|
66
|
+
defaults / required-field rules decide what happens.
|
|
67
|
+
extra_headers: Headers the caller wants merged into the response
|
|
68
|
+
(e.g. CORS, instrumentation). ``Retry-After`` + ``X-RateLimit-*``
|
|
69
|
+
from :meth:`RateLimitError.response_headers` are always added
|
|
70
|
+
on top for :class:`RateLimitError`.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
``(body, status, headers)`` — the body is a plain dict ready to
|
|
74
|
+
feed to ``JSONResponse`` / DRF ``Response`` / the consumer's
|
|
75
|
+
envelope constructor. ``status`` comes from
|
|
76
|
+
:func:`http_status_for`. ``headers`` is a fresh dict (never the
|
|
77
|
+
caller's original).
|
|
78
|
+
"""
|
|
79
|
+
body: dict[str, Any] = {
|
|
80
|
+
"error_code": exc.error_code,
|
|
81
|
+
"message": str(exc),
|
|
82
|
+
"details": dict(exc.details),
|
|
83
|
+
}
|
|
84
|
+
status = http_status_for(exc)
|
|
85
|
+
headers = dict(extra_headers or {})
|
|
86
|
+
if isinstance(exc, RateLimitError):
|
|
87
|
+
headers.update(exc.response_headers())
|
|
88
|
+
if envelope_cls is not None:
|
|
89
|
+
body = _project_onto_envelope(body, envelope_cls)
|
|
90
|
+
return body, status, headers
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _project_onto_envelope(
|
|
94
|
+
body: dict[str, Any],
|
|
95
|
+
envelope_cls: type[BaseModel],
|
|
96
|
+
) -> dict[str, Any]:
|
|
97
|
+
"""Project the canonical body onto whatever field names ``envelope_cls`` declares."""
|
|
98
|
+
fields = set(envelope_cls.model_fields)
|
|
99
|
+
out: dict[str, Any] = {}
|
|
100
|
+
|
|
101
|
+
code_field = _first_match(_ERROR_CODE_ALIASES, fields)
|
|
102
|
+
if code_field is not None:
|
|
103
|
+
out[code_field] = body["error_code"]
|
|
104
|
+
|
|
105
|
+
message_field = _first_match(_MESSAGE_ALIASES, fields)
|
|
106
|
+
if message_field is not None:
|
|
107
|
+
out[message_field] = body["message"]
|
|
108
|
+
|
|
109
|
+
if "details" in fields:
|
|
110
|
+
out["details"] = body["details"]
|
|
111
|
+
elif "errors" in fields:
|
|
112
|
+
out["errors"] = _details_to_error_list(body["details"])
|
|
113
|
+
|
|
114
|
+
if "request_id" in fields:
|
|
115
|
+
out["request_id"] = request_id.get()
|
|
116
|
+
|
|
117
|
+
if "success" in fields:
|
|
118
|
+
out["success"] = False
|
|
119
|
+
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _first_match(candidates: tuple[str, ...], fields: set[str]) -> str | None:
|
|
124
|
+
for c in candidates:
|
|
125
|
+
if c in fields:
|
|
126
|
+
return c
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _details_to_error_list(details: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
131
|
+
"""Convert a flat ``details`` dict to the ``[{field, message}, ...]`` shape."""
|
|
132
|
+
return [{"field": k, "message": str(v)} for k, v in details.items()]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
__all__ = ["from_exception"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Django adapter (ROADMAP M6).
|
|
2
|
+
|
|
3
|
+
Wires the kit into Django's `AppConfig.ready()` lifecycle, middleware
|
|
4
|
+
chain, DRF throttle classes, DRF exception handler, model fields, and
|
|
5
|
+
management commands. Pure glue; no business logic.
|
|
6
|
+
|
|
7
|
+
Django is sync-first; the kit is async-first. The bridge lives in
|
|
8
|
+
``apps.py``: a daemon thread owns a private asyncio loop and drives the
|
|
9
|
+
recovery monitor for the lifetime of the worker. ADR 0011 documents
|
|
10
|
+
the bridge in full.
|
|
11
|
+
|
|
12
|
+
This module exposes the AppConfig path Django expects:
|
|
13
|
+
|
|
14
|
+
.. code-block:: python
|
|
15
|
+
|
|
16
|
+
# settings.py
|
|
17
|
+
INSTALLED_APPS = [
|
|
18
|
+
...,
|
|
19
|
+
"resilience_kit.adapters.django",
|
|
20
|
+
]
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
default_app_config = "resilience_kit.adapters.django.apps.ResilienceConfig"
|
|
26
|
+
|
|
27
|
+
__all__ = ["default_app_config"]
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Django :class:`AppConfig` for the resilience-kit adapter.
|
|
2
|
+
|
|
3
|
+
``ResilienceConfig.ready()`` runs once per Django process — twice if the
|
|
4
|
+
autoreloader is active, hence the idempotency guard. It performs three
|
|
5
|
+
jobs:
|
|
6
|
+
|
|
7
|
+
1. Reads ``settings.RESILIENCE`` (a dict the project supplies) and calls
|
|
8
|
+
:meth:`ResilienceRegistry.register_service` for each declared
|
|
9
|
+
service so ``@resilient(name)`` sees the right retry / breaker
|
|
10
|
+
config.
|
|
11
|
+
|
|
12
|
+
2. Spawns a daemon thread that owns a private :mod:`asyncio` loop and
|
|
13
|
+
drives :data:`recovery.monitor`. Django is sync-first so there is no
|
|
14
|
+
ambient loop the monitor can attach to; the daemon thread bridges
|
|
15
|
+
that gap. The thread is daemon=True so it dies with the worker —
|
|
16
|
+
acceptable because the monitor is purely re-probing, never holding
|
|
17
|
+
un-flushed state. ADR 0011 documents the bridge in detail.
|
|
18
|
+
|
|
19
|
+
3. Registers an :func:`atexit` hook to call :meth:`monitor.stop` and
|
|
20
|
+
drain the audit dispatcher on graceful exit. Daemon-thread death on
|
|
21
|
+
SIGKILL still loses in-flight audit events; the
|
|
22
|
+
:class:`FireAndForgetDispatcher` design accepts that.
|
|
23
|
+
|
|
24
|
+
Reading ``settings.RESILIENCE`` lazily inside ``ready()`` (not at
|
|
25
|
+
module import) keeps the adapter importable in projects that do not
|
|
26
|
+
configure it yet, which makes the install a one-liner in
|
|
27
|
+
``INSTALLED_APPS``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import asyncio
|
|
33
|
+
import atexit
|
|
34
|
+
import logging
|
|
35
|
+
import threading
|
|
36
|
+
from typing import TYPE_CHECKING, Any
|
|
37
|
+
|
|
38
|
+
from resilience_kit.exceptions import MissingExtraError
|
|
39
|
+
from resilience_kit.recovery import monitor
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
from django.apps import AppConfig
|
|
43
|
+
except ImportError as exc: # pragma: no cover
|
|
44
|
+
raise MissingExtraError("django", "resilience-kit[django]") from exc
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
from collections.abc import Mapping
|
|
48
|
+
|
|
49
|
+
_logger = logging.getLogger("resilience_kit.adapters.django")
|
|
50
|
+
|
|
51
|
+
_lock = threading.Lock()
|
|
52
|
+
_thread: threading.Thread | None = None
|
|
53
|
+
_loop: asyncio.AbstractEventLoop | None = None
|
|
54
|
+
_shutdown_event: threading.Event | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ResilienceConfig(AppConfig): # type: ignore[misc] # django.apps untyped — see mypy.ini
|
|
58
|
+
"""Adapter ``AppConfig`` — registers services and starts the monitor."""
|
|
59
|
+
|
|
60
|
+
name = "resilience_kit.adapters.django"
|
|
61
|
+
label = "resilience_kit"
|
|
62
|
+
verbose_name = "Resilience kit"
|
|
63
|
+
|
|
64
|
+
def ready(self) -> None:
|
|
65
|
+
"""Register services and ensure the recovery thread is running."""
|
|
66
|
+
from django.conf import settings as django_settings # noqa: PLC0415
|
|
67
|
+
|
|
68
|
+
from resilience_kit.registry import registry # noqa: PLC0415
|
|
69
|
+
|
|
70
|
+
services: Mapping[str, Mapping[str, Any]] = getattr(
|
|
71
|
+
django_settings,
|
|
72
|
+
"RESILIENCE",
|
|
73
|
+
{},
|
|
74
|
+
).get("services", {})
|
|
75
|
+
for name, overrides in services.items():
|
|
76
|
+
registry.register_service(name, overrides)
|
|
77
|
+
|
|
78
|
+
_ensure_monitor_thread()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _ensure_monitor_thread() -> None:
|
|
82
|
+
"""Spawn the daemon thread that owns the monitor's asyncio loop.
|
|
83
|
+
|
|
84
|
+
Idempotent — guards against AppConfig.ready() being called twice by
|
|
85
|
+
the autoreloader.
|
|
86
|
+
"""
|
|
87
|
+
global _thread, _shutdown_event # noqa: PLW0603
|
|
88
|
+
with _lock:
|
|
89
|
+
if _thread is not None and _thread.is_alive():
|
|
90
|
+
return
|
|
91
|
+
_shutdown_event = threading.Event()
|
|
92
|
+
ready = threading.Event()
|
|
93
|
+
_thread = threading.Thread(
|
|
94
|
+
target=_run_loop,
|
|
95
|
+
args=(ready,),
|
|
96
|
+
name="resilience_kit.recovery_monitor",
|
|
97
|
+
daemon=True,
|
|
98
|
+
)
|
|
99
|
+
_thread.start()
|
|
100
|
+
# Block briefly so callers know the loop is up before they
|
|
101
|
+
# schedule work on it.
|
|
102
|
+
ready.wait(timeout=2.0)
|
|
103
|
+
atexit.register(_shutdown_monitor_thread)
|
|
104
|
+
_logger.info("Resilience monitor thread started.")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _run_loop(ready: threading.Event) -> None:
|
|
108
|
+
"""Body of the daemon thread — owns a private asyncio loop."""
|
|
109
|
+
global _loop # noqa: PLW0603
|
|
110
|
+
loop = asyncio.new_event_loop()
|
|
111
|
+
_loop = loop
|
|
112
|
+
asyncio.set_event_loop(loop)
|
|
113
|
+
try:
|
|
114
|
+
loop.run_until_complete(_drive_monitor(ready))
|
|
115
|
+
finally:
|
|
116
|
+
loop.close()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def _drive_monitor(ready: threading.Event) -> None:
|
|
120
|
+
"""Start the monitor, park until shutdown, then drain audit + stop."""
|
|
121
|
+
from resilience_kit.audit.factory import get_dispatcher # noqa: PLC0415
|
|
122
|
+
|
|
123
|
+
monitor.start()
|
|
124
|
+
ready.set()
|
|
125
|
+
assert _shutdown_event is not None # set by _ensure_monitor_thread.
|
|
126
|
+
# Poll the threading.Event without blocking the loop. The 0.5 s
|
|
127
|
+
# cadence is fast enough for tests and cheap enough for prod.
|
|
128
|
+
# ASYNC110 suggests asyncio.Event but the signal originates from a
|
|
129
|
+
# different thread (the atexit hook), so a threading.Event polled
|
|
130
|
+
# from this loop is the correct primitive.
|
|
131
|
+
while not _shutdown_event.is_set(): # noqa: ASYNC110
|
|
132
|
+
await asyncio.sleep(0.5)
|
|
133
|
+
# Drain audit + stop monitor on this loop BEFORE _run_loop closes
|
|
134
|
+
# it. Doing this from the atexit hook would post coroutines to a
|
|
135
|
+
# closed loop.
|
|
136
|
+
try:
|
|
137
|
+
await get_dispatcher().aclose(drain_timeout=5.0)
|
|
138
|
+
except Exception:
|
|
139
|
+
_logger.exception("Audit dispatcher drain raised during shutdown")
|
|
140
|
+
await monitor.stop()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _shutdown_monitor_thread() -> None:
|
|
144
|
+
"""Atexit hook: signal the loop thread to drain + stop, then join."""
|
|
145
|
+
global _thread, _loop, _shutdown_event # noqa: PLW0603
|
|
146
|
+
if _shutdown_event is None or _thread is None:
|
|
147
|
+
return
|
|
148
|
+
_shutdown_event.set()
|
|
149
|
+
_thread.join(timeout=8.0)
|
|
150
|
+
_thread = None
|
|
151
|
+
_loop = None
|
|
152
|
+
_shutdown_event = None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
__all__ = ["ResilienceConfig"]
|