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.
Files changed (101) hide show
  1. resilience_kit/__init__.py +123 -0
  2. resilience_kit/_providers.py +104 -0
  3. resilience_kit/_version.py +7 -0
  4. resilience_kit/adapters/__init__.py +13 -0
  5. resilience_kit/adapters/_envelope.py +135 -0
  6. resilience_kit/adapters/django/__init__.py +27 -0
  7. resilience_kit/adapters/django/apps.py +155 -0
  8. resilience_kit/adapters/django/drf_throttles.py +163 -0
  9. resilience_kit/adapters/django/exception_handler.py +94 -0
  10. resilience_kit/adapters/django/fields.py +70 -0
  11. resilience_kit/adapters/django/management/__init__.py +0 -0
  12. resilience_kit/adapters/django/management/commands/__init__.py +0 -0
  13. resilience_kit/adapters/django/management/commands/resilience_reset.py +60 -0
  14. resilience_kit/adapters/django/management/commands/resilience_status.py +65 -0
  15. resilience_kit/adapters/django/middleware.py +392 -0
  16. resilience_kit/adapters/fastapi/__init__.py +27 -0
  17. resilience_kit/adapters/fastapi/dependencies.py +137 -0
  18. resilience_kit/adapters/fastapi/exception_handlers.py +114 -0
  19. resilience_kit/adapters/fastapi/fields.py +82 -0
  20. resilience_kit/adapters/fastapi/lifespan.py +145 -0
  21. resilience_kit/adapters/fastapi/middleware.py +124 -0
  22. resilience_kit/audit/__init__.py +29 -0
  23. resilience_kit/audit/backends/__init__.py +23 -0
  24. resilience_kit/audit/backends/base.py +81 -0
  25. resilience_kit/audit/backends/noop.py +30 -0
  26. resilience_kit/audit/backends/postgres.py +182 -0
  27. resilience_kit/audit/backends/stdlib_logging.py +59 -0
  28. resilience_kit/audit/decorators.py +237 -0
  29. resilience_kit/audit/dispatch.py +234 -0
  30. resilience_kit/audit/factory.py +110 -0
  31. resilience_kit/audit/sanitizers.py +105 -0
  32. resilience_kit/cache/__init__.py +11 -0
  33. resilience_kit/cache/base.py +85 -0
  34. resilience_kit/cache/memory_impl.py +139 -0
  35. resilience_kit/cache/provider.py +96 -0
  36. resilience_kit/cache/redis_impl.py +245 -0
  37. resilience_kit/circuit_breaker/__init__.py +17 -0
  38. resilience_kit/circuit_breaker/base.py +113 -0
  39. resilience_kit/circuit_breaker/lua_scripts.py +96 -0
  40. resilience_kit/circuit_breaker/memory_impl.py +183 -0
  41. resilience_kit/circuit_breaker/provider.py +133 -0
  42. resilience_kit/circuit_breaker/pybreaker_impl.py +135 -0
  43. resilience_kit/circuit_breaker/redis_impl.py +295 -0
  44. resilience_kit/context.py +90 -0
  45. resilience_kit/crypto/__init__.py +27 -0
  46. resilience_kit/crypto/exceptions.py +38 -0
  47. resilience_kit/crypto/fernet.py +166 -0
  48. resilience_kit/decorators.py +146 -0
  49. resilience_kit/dispatch/__init__.py +14 -0
  50. resilience_kit/dispatch/fire_and_forget.py +249 -0
  51. resilience_kit/exceptions/__init__.py +36 -0
  52. resilience_kit/exceptions/base.py +59 -0
  53. resilience_kit/exceptions/http_status.py +59 -0
  54. resilience_kit/exceptions/infrastructure.py +138 -0
  55. resilience_kit/exceptions/validation.py +92 -0
  56. resilience_kit/health.py +127 -0
  57. resilience_kit/http_client/__init__.py +33 -0
  58. resilience_kit/http_client/auth.py +161 -0
  59. resilience_kit/http_client/client.py +385 -0
  60. resilience_kit/http_client/dns_pin.py +134 -0
  61. resilience_kit/http_client/errors.py +137 -0
  62. resilience_kit/http_client/session.py +107 -0
  63. resilience_kit/metrics.py +207 -0
  64. resilience_kit/middleware/__init__.py +29 -0
  65. resilience_kit/middleware/_asgi.py +16 -0
  66. resilience_kit/middleware/body_limit.py +92 -0
  67. resilience_kit/middleware/exception_logging.py +122 -0
  68. resilience_kit/middleware/rate_limit_headers.py +69 -0
  69. resilience_kit/middleware/request_id.py +100 -0
  70. resilience_kit/middleware/security_headers.py +74 -0
  71. resilience_kit/middleware/selective_cors.py +119 -0
  72. resilience_kit/py.typed +0 -0
  73. resilience_kit/recovery.py +236 -0
  74. resilience_kit/registry.py +206 -0
  75. resilience_kit/retry/__init__.py +11 -0
  76. resilience_kit/retry/backoff.py +71 -0
  77. resilience_kit/retry/decorator.py +362 -0
  78. resilience_kit/runtime.py +201 -0
  79. resilience_kit/settings.py +125 -0
  80. resilience_kit/ssrf/__init__.py +24 -0
  81. resilience_kit/ssrf/_ipchecks.py +68 -0
  82. resilience_kit/ssrf/guard.py +204 -0
  83. resilience_kit/tasks/__init__.py +22 -0
  84. resilience_kit/tasks/queue.py +100 -0
  85. resilience_kit/tasks/registry.py +80 -0
  86. resilience_kit/testing/__init__.py +60 -0
  87. resilience_kit/testing/contract.py +137 -0
  88. resilience_kit/testing/fakes.py +129 -0
  89. resilience_kit/testing/reset.py +58 -0
  90. resilience_kit/throttle/__init__.py +25 -0
  91. resilience_kit/throttle/base.py +143 -0
  92. resilience_kit/throttle/lua_scripts.py +71 -0
  93. resilience_kit/throttle/memory_impl.py +94 -0
  94. resilience_kit/throttle/provider.py +93 -0
  95. resilience_kit/throttle/redis_impl.py +206 -0
  96. resilience_kit/throttle/scopes.py +80 -0
  97. resilience_kit-0.1.0.dist-info/METADATA +440 -0
  98. resilience_kit-0.1.0.dist-info/RECORD +101 -0
  99. resilience_kit-0.1.0.dist-info/WHEEL +4 -0
  100. resilience_kit-0.1.0.dist-info/entry_points.txt +19 -0
  101. 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,7 @@
1
+ """Single source of truth for the package version.
2
+
3
+ Read by ``hatchling`` at build time (see ``[tool.hatch.version]`` in
4
+ ``pyproject.toml``) and re-exported as ``resilience_kit.__version__``.
5
+ """
6
+
7
+ __version__ = "0.1.0"
@@ -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"]