svc-infra 0.1.595__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.
- svc_infra/__init__.py +58 -2
- svc_infra/apf_payments/models.py +68 -38
- svc_infra/apf_payments/provider/__init__.py +2 -2
- svc_infra/apf_payments/provider/aiydan.py +39 -23
- svc_infra/apf_payments/provider/base.py +8 -3
- svc_infra/apf_payments/provider/registry.py +3 -5
- svc_infra/apf_payments/provider/stripe.py +74 -52
- svc_infra/apf_payments/schemas.py +84 -83
- svc_infra/apf_payments/service.py +27 -16
- svc_infra/apf_payments/settings.py +12 -11
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +34 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +240 -0
- svc_infra/api/fastapi/apf_payments/router.py +94 -73
- svc_infra/api/fastapi/apf_payments/setup.py +10 -9
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +1 -3
- svc_infra/api/fastapi/auth/add.py +14 -15
- svc_infra/api/fastapi/auth/gaurd.py +32 -20
- svc_infra/api/fastapi/auth/mfa/models.py +3 -4
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +13 -9
- svc_infra/api/fastapi/auth/mfa/router.py +9 -8
- svc_infra/api/fastapi/auth/mfa/security.py +4 -7
- svc_infra/api/fastapi/auth/mfa/utils.py +5 -3
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -3
- svc_infra/api/fastapi/auth/routers/apikey_router.py +19 -21
- svc_infra/api/fastapi/auth/routers/oauth_router.py +98 -52
- svc_infra/api/fastapi/auth/routers/session_router.py +6 -5
- svc_infra/api/fastapi/auth/security.py +25 -15
- svc_infra/api/fastapi/auth/sender.py +5 -0
- svc_infra/api/fastapi/auth/settings.py +18 -19
- svc_infra/api/fastapi/auth/state.py +5 -4
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +71 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/__init__.py +5 -1
- svc_infra/api/fastapi/db/http.py +10 -9
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +35 -30
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +39 -21
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +62 -25
- svc_infra/api/fastapi/db/sql/crud_router.py +205 -30
- svc_infra/api/fastapi/db/sql/session.py +19 -2
- svc_infra/api/fastapi/db/sql/users.py +18 -9
- svc_infra/api/fastapi/dependencies/ratelimit.py +76 -14
- svc_infra/api/fastapi/docs/add.py +163 -0
- svc_infra/api/fastapi/docs/landing.py +6 -6
- svc_infra/api/fastapi/docs/scoped.py +75 -36
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +2 -2
- svc_infra/api/fastapi/dual/protected.py +123 -10
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +18 -8
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +59 -7
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +2 -2
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/exceptions.py +2 -5
- svc_infra/api/fastapi/middleware/errors/handlers.py +50 -10
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +95 -0
- svc_infra/api/fastapi/middleware/idempotency.py +190 -68
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +39 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
- svc_infra/api/fastapi/middleware/ratelimit_store.py +45 -13
- svc_infra/api/fastapi/middleware/request_id.py +24 -10
- svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
- svc_infra/api/fastapi/middleware/timeout.py +176 -0
- svc_infra/api/fastapi/object_router.py +1060 -0
- svc_infra/api/fastapi/openapi/apply.py +4 -3
- svc_infra/api/fastapi/openapi/conventions.py +13 -6
- svc_infra/api/fastapi/openapi/mutators.py +144 -17
- svc_infra/api/fastapi/openapi/pipeline.py +2 -2
- svc_infra/api/fastapi/openapi/responses.py +4 -6
- svc_infra/api/fastapi/openapi/security.py +1 -1
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +47 -32
- svc_infra/api/fastapi/routers/__init__.py +16 -10
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +167 -54
- svc_infra/api/fastapi/tenancy/add.py +20 -0
- svc_infra/api/fastapi/tenancy/context.py +113 -0
- svc_infra/api/fastapi/versioned.py +102 -0
- svc_infra/app/README.md +5 -5
- svc_infra/app/__init__.py +3 -1
- svc_infra/app/env.py +70 -4
- svc_infra/app/logging/add.py +10 -2
- svc_infra/app/logging/filter.py +1 -1
- svc_infra/app/logging/formats.py +13 -5
- svc_infra/app/root.py +3 -3
- svc_infra/billing/__init__.py +40 -0
- svc_infra/billing/async_service.py +167 -0
- svc_infra/billing/jobs.py +231 -0
- svc_infra/billing/models.py +146 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +34 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +21 -5
- svc_infra/cache/add.py +167 -0
- svc_infra/cache/backend.py +9 -7
- svc_infra/cache/decorators.py +75 -20
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +26 -6
- svc_infra/cache/recache.py +26 -27
- svc_infra/cache/resources.py +6 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/ttl.py +2 -3
- svc_infra/cache/utils.py +4 -3
- svc_infra/cli/__init__.py +44 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +39 -2
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +18 -14
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +9 -10
- svc_infra/cli/cmds/db/ops_cmds.py +267 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +97 -29
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +13 -13
- svc_infra/cli/cmds/docs/docs_cmds.py +139 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +110 -0
- svc_infra/cli/cmds/health/__init__.py +179 -0
- svc_infra/cli/cmds/health/health_cmds.py +8 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +42 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +31 -13
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/cli/foundation/runner.py +4 -5
- svc_infra/cli/foundation/typer_bootstrap.py +1 -2
- svc_infra/data/__init__.py +83 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +56 -0
- svc_infra/data/erasure.py +46 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +56 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +14 -13
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +2 -0
- svc_infra/db/nosql/constants.py +1 -1
- svc_infra/db/nosql/core.py +19 -5
- svc_infra/db/nosql/indexes.py +12 -9
- svc_infra/db/nosql/management.py +4 -4
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +21 -4
- svc_infra/db/nosql/mongo/settings.py +1 -1
- svc_infra/db/nosql/repository.py +46 -27
- svc_infra/db/nosql/resource.py +28 -16
- svc_infra/db/nosql/scaffold.py +14 -12
- svc_infra/db/nosql/service.py +2 -1
- svc_infra/db/nosql/service_with_hooks.py +4 -3
- svc_infra/db/nosql/utils.py +4 -4
- svc_infra/db/ops.py +380 -0
- svc_infra/db/outbox.py +105 -0
- svc_infra/db/sql/apikey.py +34 -15
- svc_infra/db/sql/authref.py +8 -6
- svc_infra/db/sql/constants.py +5 -1
- svc_infra/db/sql/core.py +13 -13
- svc_infra/db/sql/management.py +5 -6
- svc_infra/db/sql/repository.py +92 -26
- svc_infra/db/sql/resource.py +18 -12
- svc_infra/db/sql/scaffold.py +11 -11
- svc_infra/db/sql/service.py +2 -1
- svc_infra/db/sql/service_with_hooks.py +4 -3
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
- svc_infra/db/sql/tenant.py +80 -0
- svc_infra/db/sql/uniq.py +8 -7
- svc_infra/db/sql/uniq_hooks.py +12 -11
- svc_infra/db/sql/utils.py +105 -47
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/db/utils.py +3 -3
- svc_infra/deploy/__init__.py +531 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +263 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +262 -0
- svc_infra/dx/__init__.py +58 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +68 -0
- svc_infra/exceptions.py +141 -0
- svc_infra/health/__init__.py +863 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +101 -0
- svc_infra/jobs/__init__.py +79 -0
- svc_infra/jobs/builtins/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +93 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +49 -0
- svc_infra/jobs/queue.py +106 -0
- svc_infra/jobs/redis_queue.py +242 -0
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/scheduler.py +53 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/loaders/__init__.py +186 -0
- svc_infra/loaders/base.py +143 -0
- svc_infra/loaders/github.py +309 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +229 -0
- svc_infra/logging/__init__.py +375 -0
- svc_infra/mcp/__init__.py +82 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +68 -11
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +6 -7
- svc_infra/obs/metrics/asgi.py +8 -7
- svc_infra/obs/metrics/base.py +13 -13
- svc_infra/obs/metrics/http.py +3 -3
- svc_infra/obs/metrics/sqlalchemy.py +14 -13
- svc_infra/obs/metrics.py +9 -8
- svc_infra/resilience/__init__.py +44 -0
- svc_infra/resilience/circuit_breaker.py +328 -0
- svc_infra/resilience/retry.py +289 -0
- svc_infra/security/__init__.py +167 -0
- svc_infra/security/add.py +213 -0
- svc_infra/security/audit.py +97 -18
- svc_infra/security/audit_service.py +10 -9
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +14 -7
- svc_infra/security/jwt_rotation.py +78 -29
- svc_infra/security/lockout.py +23 -16
- svc_infra/security/models.py +77 -44
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +12 -12
- svc_infra/security/passwords.py +3 -3
- svc_infra/security/permissions.py +31 -7
- svc_infra/security/session.py +7 -8
- svc_infra/security/signed_cookies.py +26 -6
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +250 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +331 -0
- svc_infra/storage/backends/memory.py +213 -0
- svc_infra/storage/backends/s3.py +334 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +181 -0
- svc_infra/storage/settings.py +193 -0
- svc_infra/testing/__init__.py +682 -0
- svc_infra/utils.py +170 -5
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +327 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +69 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +139 -0
- svc_infra/websocket/client.py +283 -0
- svc_infra/websocket/config.py +57 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +343 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-1.1.0.dist-info/LICENSE +21 -0
- svc_infra-1.1.0.dist-info/METADATA +362 -0
- svc_infra-1.1.0.dist-info/RECORD +364 -0
- svc_infra-0.1.595.dist-info/METADATA +0 -80
- svc_infra-0.1.595.dist-info/RECORD +0 -253
- {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.595.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
|
+
]
|