svc-infra 0.1.706__py3-none-any.whl → 1.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.

Potentially problematic release.


This version of svc-infra might be problematic. Click here for more details.

Files changed (227) hide show
  1. svc_infra/apf_payments/models.py +47 -108
  2. svc_infra/apf_payments/provider/__init__.py +2 -2
  3. svc_infra/apf_payments/provider/aiydan.py +42 -100
  4. svc_infra/apf_payments/provider/base.py +10 -26
  5. svc_infra/apf_payments/provider/registry.py +3 -5
  6. svc_infra/apf_payments/provider/stripe.py +63 -135
  7. svc_infra/apf_payments/schemas.py +82 -90
  8. svc_infra/apf_payments/service.py +40 -86
  9. svc_infra/apf_payments/settings.py +10 -13
  10. svc_infra/api/__init__.py +13 -13
  11. svc_infra/api/fastapi/__init__.py +19 -0
  12. svc_infra/api/fastapi/admin/add.py +13 -18
  13. svc_infra/api/fastapi/apf_payments/router.py +47 -84
  14. svc_infra/api/fastapi/apf_payments/setup.py +7 -13
  15. svc_infra/api/fastapi/auth/__init__.py +1 -1
  16. svc_infra/api/fastapi/auth/_cookies.py +3 -9
  17. svc_infra/api/fastapi/auth/add.py +4 -8
  18. svc_infra/api/fastapi/auth/gaurd.py +9 -26
  19. svc_infra/api/fastapi/auth/mfa/models.py +4 -7
  20. svc_infra/api/fastapi/auth/mfa/pre_auth.py +3 -3
  21. svc_infra/api/fastapi/auth/mfa/router.py +9 -15
  22. svc_infra/api/fastapi/auth/mfa/security.py +3 -5
  23. svc_infra/api/fastapi/auth/mfa/utils.py +3 -2
  24. svc_infra/api/fastapi/auth/mfa/verify.py +2 -9
  25. svc_infra/api/fastapi/auth/providers.py +4 -6
  26. svc_infra/api/fastapi/auth/routers/apikey_router.py +16 -18
  27. svc_infra/api/fastapi/auth/routers/oauth_router.py +37 -85
  28. svc_infra/api/fastapi/auth/routers/session_router.py +3 -6
  29. svc_infra/api/fastapi/auth/security.py +17 -28
  30. svc_infra/api/fastapi/auth/sender.py +1 -3
  31. svc_infra/api/fastapi/auth/settings.py +18 -19
  32. svc_infra/api/fastapi/auth/state.py +6 -7
  33. svc_infra/api/fastapi/auth/ws_security.py +2 -2
  34. svc_infra/api/fastapi/billing/router.py +6 -8
  35. svc_infra/api/fastapi/db/http.py +10 -11
  36. svc_infra/api/fastapi/db/nosql/mongo/add.py +5 -15
  37. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +14 -15
  38. svc_infra/api/fastapi/db/sql/add.py +6 -14
  39. svc_infra/api/fastapi/db/sql/crud_router.py +27 -40
  40. svc_infra/api/fastapi/db/sql/health.py +1 -3
  41. svc_infra/api/fastapi/db/sql/session.py +4 -5
  42. svc_infra/api/fastapi/db/sql/users.py +8 -11
  43. svc_infra/api/fastapi/dependencies/ratelimit.py +4 -6
  44. svc_infra/api/fastapi/docs/add.py +13 -23
  45. svc_infra/api/fastapi/docs/landing.py +6 -8
  46. svc_infra/api/fastapi/docs/scoped.py +34 -42
  47. svc_infra/api/fastapi/dual/dualize.py +1 -1
  48. svc_infra/api/fastapi/dual/protected.py +12 -21
  49. svc_infra/api/fastapi/dual/router.py +14 -31
  50. svc_infra/api/fastapi/ease.py +57 -13
  51. svc_infra/api/fastapi/http/conditional.py +3 -5
  52. svc_infra/api/fastapi/middleware/errors/catchall.py +2 -6
  53. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -4
  54. svc_infra/api/fastapi/middleware/errors/handlers.py +12 -18
  55. svc_infra/api/fastapi/middleware/graceful_shutdown.py +4 -13
  56. svc_infra/api/fastapi/middleware/idempotency.py +11 -16
  57. svc_infra/api/fastapi/middleware/idempotency_store.py +14 -14
  58. svc_infra/api/fastapi/middleware/optimistic_lock.py +5 -8
  59. svc_infra/api/fastapi/middleware/ratelimit.py +8 -8
  60. svc_infra/api/fastapi/middleware/ratelimit_store.py +7 -8
  61. svc_infra/api/fastapi/middleware/request_id.py +1 -3
  62. svc_infra/api/fastapi/middleware/timeout.py +9 -10
  63. svc_infra/api/fastapi/object_router.py +1060 -0
  64. svc_infra/api/fastapi/openapi/apply.py +5 -6
  65. svc_infra/api/fastapi/openapi/conventions.py +4 -4
  66. svc_infra/api/fastapi/openapi/mutators.py +13 -31
  67. svc_infra/api/fastapi/openapi/pipeline.py +2 -2
  68. svc_infra/api/fastapi/openapi/responses.py +4 -6
  69. svc_infra/api/fastapi/openapi/security.py +1 -3
  70. svc_infra/api/fastapi/ops/add.py +7 -9
  71. svc_infra/api/fastapi/pagination.py +25 -37
  72. svc_infra/api/fastapi/routers/__init__.py +16 -38
  73. svc_infra/api/fastapi/setup.py +13 -31
  74. svc_infra/api/fastapi/tenancy/add.py +3 -2
  75. svc_infra/api/fastapi/tenancy/context.py +8 -7
  76. svc_infra/api/fastapi/versioned.py +3 -2
  77. svc_infra/app/env.py +5 -7
  78. svc_infra/app/logging/add.py +2 -1
  79. svc_infra/app/logging/filter.py +1 -1
  80. svc_infra/app/logging/formats.py +3 -2
  81. svc_infra/app/root.py +3 -3
  82. svc_infra/billing/__init__.py +19 -2
  83. svc_infra/billing/async_service.py +27 -7
  84. svc_infra/billing/jobs.py +23 -33
  85. svc_infra/billing/models.py +21 -52
  86. svc_infra/billing/quotas.py +5 -7
  87. svc_infra/billing/schemas.py +4 -6
  88. svc_infra/cache/__init__.py +12 -5
  89. svc_infra/cache/add.py +6 -9
  90. svc_infra/cache/backend.py +6 -5
  91. svc_infra/cache/decorators.py +17 -28
  92. svc_infra/cache/keys.py +2 -2
  93. svc_infra/cache/recache.py +22 -35
  94. svc_infra/cache/resources.py +8 -16
  95. svc_infra/cache/ttl.py +2 -3
  96. svc_infra/cache/utils.py +5 -6
  97. svc_infra/cli/__init__.py +4 -12
  98. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +11 -10
  99. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +6 -9
  100. svc_infra/cli/cmds/db/ops_cmds.py +3 -6
  101. svc_infra/cli/cmds/db/sql/alembic_cmds.py +24 -41
  102. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +9 -17
  103. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +10 -10
  104. svc_infra/cli/cmds/docs/docs_cmds.py +7 -10
  105. svc_infra/cli/cmds/dx/dx_cmds.py +5 -11
  106. svc_infra/cli/cmds/jobs/jobs_cmds.py +2 -7
  107. svc_infra/cli/cmds/obs/obs_cmds.py +4 -7
  108. svc_infra/cli/cmds/sdk/sdk_cmds.py +5 -15
  109. svc_infra/cli/foundation/runner.py +6 -11
  110. svc_infra/cli/foundation/typer_bootstrap.py +1 -2
  111. svc_infra/data/__init__.py +83 -0
  112. svc_infra/data/add.py +5 -5
  113. svc_infra/data/backup.py +8 -10
  114. svc_infra/data/erasure.py +3 -2
  115. svc_infra/data/fixtures.py +3 -3
  116. svc_infra/data/retention.py +8 -13
  117. svc_infra/db/crud_schema.py +9 -8
  118. svc_infra/db/nosql/__init__.py +0 -1
  119. svc_infra/db/nosql/constants.py +1 -1
  120. svc_infra/db/nosql/core.py +7 -14
  121. svc_infra/db/nosql/indexes.py +11 -10
  122. svc_infra/db/nosql/management.py +3 -3
  123. svc_infra/db/nosql/mongo/client.py +3 -3
  124. svc_infra/db/nosql/mongo/settings.py +2 -6
  125. svc_infra/db/nosql/repository.py +27 -28
  126. svc_infra/db/nosql/resource.py +15 -20
  127. svc_infra/db/nosql/scaffold.py +13 -17
  128. svc_infra/db/nosql/service.py +3 -4
  129. svc_infra/db/nosql/service_with_hooks.py +4 -3
  130. svc_infra/db/nosql/types.py +2 -6
  131. svc_infra/db/nosql/utils.py +4 -4
  132. svc_infra/db/ops.py +14 -18
  133. svc_infra/db/outbox.py +15 -18
  134. svc_infra/db/sql/apikey.py +12 -21
  135. svc_infra/db/sql/authref.py +3 -7
  136. svc_infra/db/sql/constants.py +9 -9
  137. svc_infra/db/sql/core.py +11 -11
  138. svc_infra/db/sql/management.py +2 -6
  139. svc_infra/db/sql/repository.py +17 -24
  140. svc_infra/db/sql/resource.py +14 -13
  141. svc_infra/db/sql/scaffold.py +13 -17
  142. svc_infra/db/sql/service.py +7 -16
  143. svc_infra/db/sql/service_with_hooks.py +4 -3
  144. svc_infra/db/sql/tenant.py +6 -14
  145. svc_infra/db/sql/uniq.py +8 -7
  146. svc_infra/db/sql/uniq_hooks.py +14 -19
  147. svc_infra/db/sql/utils.py +24 -53
  148. svc_infra/db/utils.py +3 -3
  149. svc_infra/deploy/__init__.py +8 -15
  150. svc_infra/documents/add.py +7 -8
  151. svc_infra/documents/ease.py +8 -8
  152. svc_infra/documents/models.py +3 -3
  153. svc_infra/documents/storage.py +11 -13
  154. svc_infra/dx/__init__.py +58 -0
  155. svc_infra/dx/add.py +1 -3
  156. svc_infra/dx/changelog.py +2 -2
  157. svc_infra/dx/checks.py +1 -1
  158. svc_infra/health/__init__.py +15 -16
  159. svc_infra/http/client.py +10 -14
  160. svc_infra/jobs/__init__.py +79 -0
  161. svc_infra/jobs/builtins/outbox_processor.py +3 -5
  162. svc_infra/jobs/builtins/webhook_delivery.py +1 -3
  163. svc_infra/jobs/loader.py +4 -5
  164. svc_infra/jobs/queue.py +14 -24
  165. svc_infra/jobs/redis_queue.py +20 -34
  166. svc_infra/jobs/runner.py +7 -11
  167. svc_infra/jobs/scheduler.py +5 -5
  168. svc_infra/jobs/worker.py +1 -1
  169. svc_infra/loaders/base.py +5 -4
  170. svc_infra/loaders/github.py +1 -3
  171. svc_infra/loaders/url.py +3 -9
  172. svc_infra/logging/__init__.py +7 -6
  173. svc_infra/mcp/__init__.py +82 -0
  174. svc_infra/mcp/svc_infra_mcp.py +2 -2
  175. svc_infra/obs/add.py +4 -3
  176. svc_infra/obs/cloud_dash.py +1 -1
  177. svc_infra/obs/metrics/__init__.py +3 -3
  178. svc_infra/obs/metrics/asgi.py +9 -14
  179. svc_infra/obs/metrics/base.py +13 -13
  180. svc_infra/obs/metrics/http.py +5 -9
  181. svc_infra/obs/metrics/sqlalchemy.py +9 -12
  182. svc_infra/obs/metrics.py +3 -3
  183. svc_infra/obs/settings.py +2 -6
  184. svc_infra/resilience/__init__.py +44 -0
  185. svc_infra/resilience/circuit_breaker.py +328 -0
  186. svc_infra/resilience/retry.py +289 -0
  187. svc_infra/security/__init__.py +167 -0
  188. svc_infra/security/add.py +5 -9
  189. svc_infra/security/audit.py +14 -17
  190. svc_infra/security/audit_service.py +9 -9
  191. svc_infra/security/hibp.py +3 -6
  192. svc_infra/security/jwt_rotation.py +7 -10
  193. svc_infra/security/lockout.py +12 -11
  194. svc_infra/security/models.py +37 -46
  195. svc_infra/security/oauth_models.py +8 -8
  196. svc_infra/security/org_invites.py +11 -13
  197. svc_infra/security/passwords.py +4 -6
  198. svc_infra/security/permissions.py +8 -7
  199. svc_infra/security/session.py +6 -7
  200. svc_infra/security/signed_cookies.py +9 -9
  201. svc_infra/storage/add.py +5 -8
  202. svc_infra/storage/backends/local.py +13 -21
  203. svc_infra/storage/backends/memory.py +4 -7
  204. svc_infra/storage/backends/s3.py +17 -36
  205. svc_infra/storage/base.py +2 -2
  206. svc_infra/storage/easy.py +4 -8
  207. svc_infra/storage/settings.py +16 -18
  208. svc_infra/testing/__init__.py +36 -39
  209. svc_infra/utils.py +169 -8
  210. svc_infra/webhooks/__init__.py +1 -1
  211. svc_infra/webhooks/add.py +17 -29
  212. svc_infra/webhooks/encryption.py +2 -2
  213. svc_infra/webhooks/fastapi.py +2 -4
  214. svc_infra/webhooks/router.py +3 -3
  215. svc_infra/webhooks/service.py +5 -6
  216. svc_infra/webhooks/signing.py +5 -5
  217. svc_infra/websocket/add.py +2 -3
  218. svc_infra/websocket/client.py +3 -2
  219. svc_infra/websocket/config.py +6 -18
  220. svc_infra/websocket/manager.py +9 -10
  221. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/METADATA +11 -5
  222. svc_infra-1.1.0.dist-info/RECORD +364 -0
  223. svc_infra/billing/service.py +0 -123
  224. svc_infra-0.1.706.dist-info/RECORD +0 -357
  225. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/LICENSE +0 -0
  226. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
  227. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,44 @@
1
+ """Resilience utilities for svc-infra.
2
+
3
+ This module provides utilities for building resilient services:
4
+ - Retry with exponential backoff
5
+ - Circuit breaker for protecting against cascading failures
6
+ - Timeout enforcement
7
+
8
+ Example:
9
+ >>> from svc_infra.resilience import with_retry, CircuitBreaker
10
+ >>>
11
+ >>> @with_retry(max_attempts=3)
12
+ ... async def fetch_data():
13
+ ... return await external_api.get()
14
+ >>>
15
+ >>> breaker = CircuitBreaker(failure_threshold=5)
16
+ >>> async with breaker:
17
+ ... await risky_operation()
18
+ """
19
+
20
+ from svc_infra.resilience.circuit_breaker import (
21
+ CircuitBreaker,
22
+ CircuitBreakerError,
23
+ CircuitBreakerStats,
24
+ CircuitState,
25
+ )
26
+ from svc_infra.resilience.retry import (
27
+ RetryConfig,
28
+ RetryExhaustedError,
29
+ retry_sync,
30
+ with_retry,
31
+ )
32
+
33
+ __all__ = [
34
+ # Retry
35
+ "RetryConfig",
36
+ "RetryExhaustedError",
37
+ "retry_sync",
38
+ "with_retry",
39
+ # Circuit Breaker
40
+ "CircuitBreaker",
41
+ "CircuitBreakerError",
42
+ "CircuitBreakerStats",
43
+ "CircuitState",
44
+ ]
@@ -0,0 +1,328 @@
1
+ """Circuit breaker for protecting against cascading failures.
2
+
3
+ A circuit breaker prevents repeated calls to a failing service,
4
+ giving it time to recover. The circuit has three states:
5
+
6
+ - CLOSED: Normal operation, calls pass through.
7
+ - OPEN: Calls are blocked, CircuitBreakerError is raised.
8
+ - HALF_OPEN: Limited calls allowed to test if service recovered.
9
+
10
+ Example:
11
+ >>> from svc_infra.resilience import CircuitBreaker
12
+ >>>
13
+ >>> breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=30.0)
14
+ >>>
15
+ >>> async with breaker:
16
+ ... result = await external_service.call()
17
+ >>>
18
+ >>> # Or use as decorator
19
+ >>> @breaker.protect
20
+ ... async def call_external():
21
+ ... return await external_service.call()
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import functools
28
+ import logging
29
+ import time
30
+ from dataclasses import dataclass
31
+ from enum import Enum
32
+ from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
33
+
34
+ if TYPE_CHECKING:
35
+ from collections.abc import Awaitable, Callable
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ P = ParamSpec("P")
40
+ R = TypeVar("R")
41
+
42
+
43
+ class CircuitState(Enum):
44
+ """State of the circuit breaker."""
45
+
46
+ CLOSED = "closed"
47
+ """Normal operation, calls pass through."""
48
+
49
+ OPEN = "open"
50
+ """Circuit is open, calls are blocked."""
51
+
52
+ HALF_OPEN = "half_open"
53
+ """Testing if service recovered, limited calls allowed."""
54
+
55
+
56
+ class CircuitBreakerError(Exception):
57
+ """Raised when circuit breaker is open.
58
+
59
+ Attributes:
60
+ name: Name of the circuit breaker.
61
+ state: Current state of the circuit.
62
+ remaining_timeout: Seconds until circuit will try half-open.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ name: str,
68
+ *,
69
+ state: CircuitState,
70
+ remaining_timeout: float | None = None,
71
+ ):
72
+ self.name = name
73
+ self.state = state
74
+ self.remaining_timeout = remaining_timeout
75
+ message = f"Circuit breaker '{name}' is {state.value}"
76
+ if remaining_timeout is not None:
77
+ message += f" (retry in {remaining_timeout:.1f}s)"
78
+ super().__init__(message)
79
+
80
+
81
+ @dataclass
82
+ class CircuitBreakerStats:
83
+ """Statistics for a circuit breaker.
84
+
85
+ Attributes:
86
+ total_calls: Total number of calls attempted.
87
+ successful_calls: Number of successful calls.
88
+ failed_calls: Number of failed calls.
89
+ rejected_calls: Number of calls rejected due to open circuit.
90
+ state_changes: Number of state transitions.
91
+ """
92
+
93
+ total_calls: int = 0
94
+ successful_calls: int = 0
95
+ failed_calls: int = 0
96
+ rejected_calls: int = 0
97
+ state_changes: int = 0
98
+
99
+
100
+ class CircuitBreaker:
101
+ """Circuit breaker for protecting against cascading failures.
102
+
103
+ The circuit breaker monitors call failures and opens the circuit
104
+ when failures exceed a threshold, preventing further calls until
105
+ the service has time to recover.
106
+
107
+ Args:
108
+ name: Name for this circuit breaker (for logging/metrics).
109
+ failure_threshold: Number of failures before opening circuit.
110
+ recovery_timeout: Seconds to wait before trying half-open.
111
+ half_open_max_calls: Max calls in half-open state before decision.
112
+ success_threshold: Successes in half-open to close circuit.
113
+ failure_exceptions: Exception types that count as failures.
114
+
115
+ Example:
116
+ >>> breaker = CircuitBreaker(
117
+ ... name="external-api",
118
+ ... failure_threshold=5,
119
+ ... recovery_timeout=30.0,
120
+ ... )
121
+ >>>
122
+ >>> async with breaker:
123
+ ... result = await api.call()
124
+ >>>
125
+ >>> # Check state
126
+ >>> if breaker.state == CircuitState.OPEN:
127
+ ... print("Service is down")
128
+ """
129
+
130
+ def __init__(
131
+ self,
132
+ name: str = "default",
133
+ *,
134
+ failure_threshold: int = 5,
135
+ recovery_timeout: float = 30.0,
136
+ half_open_max_calls: int = 3,
137
+ success_threshold: int = 2,
138
+ failure_exceptions: tuple[type[Exception], ...] = (Exception,),
139
+ ):
140
+ self.name = name
141
+ self.failure_threshold = failure_threshold
142
+ self.recovery_timeout = recovery_timeout
143
+ self.half_open_max_calls = half_open_max_calls
144
+ self.success_threshold = success_threshold
145
+ self.failure_exceptions = failure_exceptions
146
+
147
+ self._state = CircuitState.CLOSED
148
+ self._failure_count = 0
149
+ self._success_count = 0
150
+ self._last_failure_time: float | None = None
151
+ self._half_open_calls = 0
152
+ self._lock = asyncio.Lock()
153
+ self._stats = CircuitBreakerStats()
154
+
155
+ @property
156
+ def state(self) -> CircuitState:
157
+ """Get the current circuit state."""
158
+ return self._state
159
+
160
+ @property
161
+ def stats(self) -> CircuitBreakerStats:
162
+ """Get circuit breaker statistics."""
163
+ return self._stats
164
+
165
+ def _should_try_half_open(self) -> bool:
166
+ """Check if enough time has passed to try half-open."""
167
+ if self._state != CircuitState.OPEN:
168
+ return False
169
+ if self._last_failure_time is None:
170
+ return True
171
+ elapsed = time.monotonic() - self._last_failure_time
172
+ return elapsed >= self.recovery_timeout
173
+
174
+ def _remaining_timeout(self) -> float | None:
175
+ """Get remaining time until half-open attempt."""
176
+ if self._state != CircuitState.OPEN:
177
+ return None
178
+ if self._last_failure_time is None:
179
+ return 0.0
180
+ elapsed = time.monotonic() - self._last_failure_time
181
+ remaining = self.recovery_timeout - elapsed
182
+ return max(0.0, remaining)
183
+
184
+ def _transition_to(self, new_state: CircuitState) -> None:
185
+ """Transition to a new state."""
186
+ if self._state != new_state:
187
+ logger.info(
188
+ "Circuit breaker '%s' state: %s -> %s",
189
+ self.name,
190
+ self._state.value,
191
+ new_state.value,
192
+ )
193
+ self._state = new_state
194
+ self._stats.state_changes += 1
195
+
196
+ if new_state == CircuitState.CLOSED:
197
+ self._failure_count = 0
198
+ self._success_count = 0
199
+ elif new_state == CircuitState.HALF_OPEN:
200
+ self._half_open_calls = 0
201
+ self._success_count = 0
202
+
203
+ async def _record_success(self) -> None:
204
+ """Record a successful call."""
205
+ async with self._lock:
206
+ self._stats.successful_calls += 1
207
+
208
+ if self._state == CircuitState.HALF_OPEN:
209
+ self._success_count += 1
210
+ if self._success_count >= self.success_threshold:
211
+ self._transition_to(CircuitState.CLOSED)
212
+ elif self._state == CircuitState.CLOSED:
213
+ # Reset failure count on success
214
+ self._failure_count = 0
215
+
216
+ async def _record_failure(self, exc: Exception) -> None:
217
+ """Record a failed call."""
218
+ async with self._lock:
219
+ self._stats.failed_calls += 1
220
+ self._failure_count += 1
221
+ self._last_failure_time = time.monotonic()
222
+
223
+ if self._state == CircuitState.HALF_OPEN:
224
+ # Any failure in half-open goes back to open
225
+ self._transition_to(CircuitState.OPEN)
226
+ elif self._state == CircuitState.CLOSED:
227
+ if self._failure_count >= self.failure_threshold:
228
+ self._transition_to(CircuitState.OPEN)
229
+ logger.warning(
230
+ "Circuit breaker '%s' opened after %d failures: %s",
231
+ self.name,
232
+ self._failure_count,
233
+ exc,
234
+ )
235
+
236
+ async def _check_state(self) -> None:
237
+ """Check if call should be allowed."""
238
+ async with self._lock:
239
+ self._stats.total_calls += 1
240
+
241
+ if self._state == CircuitState.CLOSED:
242
+ return
243
+
244
+ if self._state == CircuitState.OPEN:
245
+ if self._should_try_half_open():
246
+ self._transition_to(CircuitState.HALF_OPEN)
247
+ else:
248
+ self._stats.rejected_calls += 1
249
+ raise CircuitBreakerError(
250
+ self.name,
251
+ state=self._state,
252
+ remaining_timeout=self._remaining_timeout(),
253
+ )
254
+
255
+ if self._state == CircuitState.HALF_OPEN:
256
+ if self._half_open_calls >= self.half_open_max_calls:
257
+ self._stats.rejected_calls += 1
258
+ raise CircuitBreakerError(
259
+ self.name,
260
+ state=self._state,
261
+ remaining_timeout=None,
262
+ )
263
+ self._half_open_calls += 1
264
+
265
+ async def __aenter__(self) -> CircuitBreaker:
266
+ """Enter circuit breaker context."""
267
+ await self._check_state()
268
+ return self
269
+
270
+ async def __aexit__(
271
+ self,
272
+ exc_type: type[BaseException] | None,
273
+ exc_val: BaseException | None,
274
+ exc_tb: Any,
275
+ ) -> bool:
276
+ """Exit circuit breaker context."""
277
+ if exc_val is None:
278
+ await self._record_success()
279
+ elif isinstance(exc_val, self.failure_exceptions):
280
+ await self._record_failure(exc_val)
281
+ # Don't suppress the exception
282
+ return False
283
+
284
+ def protect(
285
+ self,
286
+ fn: Callable[P, Awaitable[R]],
287
+ ) -> Callable[P, Awaitable[R]]:
288
+ """Decorator to protect an async function with this circuit breaker.
289
+
290
+ Args:
291
+ fn: Async function to protect.
292
+
293
+ Returns:
294
+ Wrapped async function.
295
+
296
+ Example:
297
+ >>> @breaker.protect
298
+ ... async def call_api():
299
+ ... return await api.get("/data")
300
+ """
301
+
302
+ @functools.wraps(fn)
303
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
304
+ async with self:
305
+ result = await fn(*args, **kwargs)
306
+ return result
307
+
308
+ return wrapper
309
+
310
+ def reset(self) -> None:
311
+ """Reset the circuit breaker to closed state.
312
+
313
+ Use this for testing or manual intervention.
314
+ """
315
+ self._state = CircuitState.CLOSED
316
+ self._failure_count = 0
317
+ self._success_count = 0
318
+ self._last_failure_time = None
319
+ self._half_open_calls = 0
320
+ logger.info("Circuit breaker '%s' reset to CLOSED", self.name)
321
+
322
+
323
+ __all__ = [
324
+ "CircuitBreaker",
325
+ "CircuitBreakerError",
326
+ "CircuitBreakerStats",
327
+ "CircuitState",
328
+ ]
@@ -0,0 +1,289 @@
1
+ """Retry utility with exponential backoff.
2
+
3
+ This module provides a decorator for retrying async functions with
4
+ configurable backoff strategies. It does NOT depend on tenacity to
5
+ keep dependencies minimal.
6
+
7
+ Example:
8
+ >>> from svc_infra.resilience import with_retry
9
+ >>>
10
+ >>> @with_retry(max_attempts=3, base_delay=0.1)
11
+ ... async def fetch_data():
12
+ ... return await api.get("/data")
13
+ >>>
14
+ >>> # Retry only on specific exceptions
15
+ >>> @with_retry(max_attempts=5, retry_on=(TimeoutError, ConnectionError))
16
+ ... async def connect():
17
+ ... return await socket.connect()
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import functools
24
+ import logging
25
+ import random
26
+ from dataclasses import dataclass, field
27
+ from typing import TYPE_CHECKING, ParamSpec, TypeVar
28
+
29
+ if TYPE_CHECKING:
30
+ from collections.abc import Awaitable, Callable
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ P = ParamSpec("P")
35
+ R = TypeVar("R")
36
+
37
+
38
+ class RetryExhaustedError(Exception):
39
+ """Raised when all retry attempts have been exhausted.
40
+
41
+ Attributes:
42
+ attempts: Number of attempts made.
43
+ last_exception: The last exception that caused a retry.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ message: str,
49
+ *,
50
+ attempts: int,
51
+ last_exception: Exception | None = None,
52
+ ):
53
+ self.attempts = attempts
54
+ self.last_exception = last_exception
55
+ super().__init__(message)
56
+
57
+ def __repr__(self) -> str:
58
+ return f"RetryExhaustedError(attempts={self.attempts})"
59
+
60
+
61
+ @dataclass
62
+ class RetryConfig:
63
+ """Configuration for retry behavior.
64
+
65
+ Attributes:
66
+ max_attempts: Maximum number of attempts (including first try).
67
+ base_delay: Initial delay in seconds before first retry.
68
+ max_delay: Maximum delay in seconds (caps exponential growth).
69
+ exponential_base: Base for exponential backoff (default 2).
70
+ jitter: Add random jitter to delays (0.0-1.0, default 0.1).
71
+ retry_on: Tuple of exception types to retry on.
72
+ """
73
+
74
+ max_attempts: int = 3
75
+ base_delay: float = 0.1
76
+ max_delay: float = 60.0
77
+ exponential_base: float = 2.0
78
+ jitter: float = 0.1
79
+ retry_on: tuple[type[Exception], ...] = field(default_factory=lambda: (Exception,))
80
+
81
+ def calculate_delay(self, attempt: int) -> float:
82
+ """Calculate delay for a given attempt number (1-indexed).
83
+
84
+ Uses exponential backoff with optional jitter:
85
+ delay = min(base_delay * (exponential_base ** (attempt - 1)), max_delay)
86
+ delay = delay * (1 + random.uniform(-jitter, jitter))
87
+ """
88
+ delay = self.base_delay * (self.exponential_base ** (attempt - 1))
89
+ delay = min(delay, self.max_delay)
90
+
91
+ if self.jitter > 0:
92
+ jitter_amount = delay * random.uniform(-self.jitter, self.jitter)
93
+ delay = max(0, delay + jitter_amount)
94
+
95
+ return delay
96
+
97
+
98
+ def with_retry(
99
+ max_attempts: int = 3,
100
+ base_delay: float = 0.1,
101
+ max_delay: float = 60.0,
102
+ exponential_base: float = 2.0,
103
+ jitter: float = 0.1,
104
+ retry_on: tuple[type[Exception], ...] = (Exception,),
105
+ *,
106
+ on_retry: Callable[[int, Exception], None] | None = None,
107
+ ) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]:
108
+ """Decorator for retrying async functions with exponential backoff.
109
+
110
+ Args:
111
+ max_attempts: Maximum number of attempts (including first try).
112
+ base_delay: Initial delay in seconds before first retry.
113
+ max_delay: Maximum delay in seconds (caps exponential growth).
114
+ exponential_base: Base for exponential backoff (default 2).
115
+ jitter: Add random jitter to delays (0.0-1.0, default 0.1).
116
+ retry_on: Tuple of exception types to retry on.
117
+ on_retry: Optional callback called on each retry (attempt, exception).
118
+
119
+ Returns:
120
+ Decorated async function with retry logic.
121
+
122
+ Example:
123
+ >>> @with_retry(max_attempts=3, retry_on=(ConnectionError, TimeoutError))
124
+ ... async def fetch():
125
+ ... return await api.get("/data")
126
+ >>>
127
+ >>> # With callback
128
+ >>> def log_retry(attempt, exc):
129
+ ... print(f"Retry {attempt}: {exc}")
130
+ >>>
131
+ >>> @with_retry(max_attempts=3, on_retry=log_retry)
132
+ ... async def fetch():
133
+ ... return await api.get("/data")
134
+ """
135
+ config = RetryConfig(
136
+ max_attempts=max_attempts,
137
+ base_delay=base_delay,
138
+ max_delay=max_delay,
139
+ exponential_base=exponential_base,
140
+ jitter=jitter,
141
+ retry_on=retry_on,
142
+ )
143
+
144
+ def decorator(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
145
+ @functools.wraps(fn)
146
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
147
+ last_exception: Exception | None = None
148
+
149
+ for attempt in range(1, config.max_attempts + 1):
150
+ try:
151
+ return await fn(*args, **kwargs)
152
+ except config.retry_on as e:
153
+ last_exception = e
154
+
155
+ if attempt == config.max_attempts:
156
+ # Last attempt failed, raise RetryExhaustedError
157
+ logger.warning(
158
+ "Retry exhausted after %d attempts for %s: %s",
159
+ attempt,
160
+ fn.__name__,
161
+ e,
162
+ )
163
+ raise RetryExhaustedError(
164
+ f"All {config.max_attempts} retry attempts exhausted",
165
+ attempts=attempt,
166
+ last_exception=e,
167
+ ) from e
168
+
169
+ # Calculate delay and wait
170
+ delay = config.calculate_delay(attempt)
171
+ logger.debug(
172
+ "Retry %d/%d for %s in %.3fs: %s",
173
+ attempt,
174
+ config.max_attempts,
175
+ fn.__name__,
176
+ delay,
177
+ e,
178
+ )
179
+
180
+ if on_retry:
181
+ on_retry(attempt, e)
182
+
183
+ await asyncio.sleep(delay)
184
+
185
+ # Should never reach here, but satisfy type checker
186
+ raise RetryExhaustedError(
187
+ "Retry loop completed without success",
188
+ attempts=config.max_attempts,
189
+ last_exception=last_exception,
190
+ )
191
+
192
+ return wrapper
193
+
194
+ return decorator
195
+
196
+
197
+ def retry_sync(
198
+ max_attempts: int = 3,
199
+ base_delay: float = 0.1,
200
+ max_delay: float = 60.0,
201
+ exponential_base: float = 2.0,
202
+ jitter: float = 0.1,
203
+ retry_on: tuple[type[Exception], ...] = (Exception,),
204
+ *,
205
+ on_retry: Callable[[int, Exception], None] | None = None,
206
+ ) -> Callable[[Callable[P, R]], Callable[P, R]]:
207
+ """Decorator for retrying sync functions with exponential backoff.
208
+
209
+ Same as with_retry but for synchronous functions.
210
+
211
+ Args:
212
+ max_attempts: Maximum number of attempts (including first try).
213
+ base_delay: Initial delay in seconds before first retry.
214
+ max_delay: Maximum delay in seconds.
215
+ exponential_base: Base for exponential backoff.
216
+ jitter: Add random jitter to delays.
217
+ retry_on: Tuple of exception types to retry on.
218
+ on_retry: Optional callback called on each retry.
219
+
220
+ Returns:
221
+ Decorated sync function with retry logic.
222
+ """
223
+ import time
224
+
225
+ config = RetryConfig(
226
+ max_attempts=max_attempts,
227
+ base_delay=base_delay,
228
+ max_delay=max_delay,
229
+ exponential_base=exponential_base,
230
+ jitter=jitter,
231
+ retry_on=retry_on,
232
+ )
233
+
234
+ def decorator(fn: Callable[P, R]) -> Callable[P, R]:
235
+ @functools.wraps(fn)
236
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
237
+ last_exception: Exception | None = None
238
+
239
+ for attempt in range(1, config.max_attempts + 1):
240
+ try:
241
+ return fn(*args, **kwargs)
242
+ except config.retry_on as e:
243
+ last_exception = e
244
+
245
+ if attempt == config.max_attempts:
246
+ logger.warning(
247
+ "Retry exhausted after %d attempts for %s: %s",
248
+ attempt,
249
+ fn.__name__,
250
+ e,
251
+ )
252
+ raise RetryExhaustedError(
253
+ f"All {config.max_attempts} retry attempts exhausted",
254
+ attempts=attempt,
255
+ last_exception=e,
256
+ ) from e
257
+
258
+ delay = config.calculate_delay(attempt)
259
+ logger.debug(
260
+ "Retry %d/%d for %s in %.3fs: %s",
261
+ attempt,
262
+ config.max_attempts,
263
+ fn.__name__,
264
+ delay,
265
+ e,
266
+ )
267
+
268
+ if on_retry:
269
+ on_retry(attempt, e)
270
+
271
+ time.sleep(delay)
272
+
273
+ raise RetryExhaustedError(
274
+ "Retry loop completed without success",
275
+ attempts=config.max_attempts,
276
+ last_exception=last_exception,
277
+ )
278
+
279
+ return wrapper
280
+
281
+ return decorator
282
+
283
+
284
+ __all__ = [
285
+ "RetryConfig",
286
+ "RetryExhaustedError",
287
+ "retry_sync",
288
+ "with_retry",
289
+ ]