fraiseql-confiture 0.3.7__cp311-cp311-macosx_11_0_arm64.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.
- confiture/__init__.py +48 -0
- confiture/_core.cpython-311-darwin.so +0 -0
- confiture/cli/__init__.py +0 -0
- confiture/cli/dry_run.py +116 -0
- confiture/cli/lint_formatter.py +193 -0
- confiture/cli/main.py +1893 -0
- confiture/config/__init__.py +0 -0
- confiture/config/environment.py +263 -0
- confiture/core/__init__.py +51 -0
- confiture/core/anonymization/__init__.py +0 -0
- confiture/core/anonymization/audit.py +485 -0
- confiture/core/anonymization/benchmarking.py +372 -0
- confiture/core/anonymization/breach_notification.py +652 -0
- confiture/core/anonymization/compliance.py +617 -0
- confiture/core/anonymization/composer.py +298 -0
- confiture/core/anonymization/data_subject_rights.py +669 -0
- confiture/core/anonymization/factory.py +319 -0
- confiture/core/anonymization/governance.py +737 -0
- confiture/core/anonymization/performance.py +1092 -0
- confiture/core/anonymization/profile.py +284 -0
- confiture/core/anonymization/registry.py +195 -0
- confiture/core/anonymization/security/kms_manager.py +547 -0
- confiture/core/anonymization/security/lineage.py +888 -0
- confiture/core/anonymization/security/token_store.py +686 -0
- confiture/core/anonymization/strategies/__init__.py +41 -0
- confiture/core/anonymization/strategies/address.py +359 -0
- confiture/core/anonymization/strategies/credit_card.py +374 -0
- confiture/core/anonymization/strategies/custom.py +161 -0
- confiture/core/anonymization/strategies/date.py +218 -0
- confiture/core/anonymization/strategies/differential_privacy.py +398 -0
- confiture/core/anonymization/strategies/email.py +141 -0
- confiture/core/anonymization/strategies/format_preserving_encryption.py +310 -0
- confiture/core/anonymization/strategies/hash.py +150 -0
- confiture/core/anonymization/strategies/ip_address.py +235 -0
- confiture/core/anonymization/strategies/masking_retention.py +252 -0
- confiture/core/anonymization/strategies/name.py +298 -0
- confiture/core/anonymization/strategies/phone.py +119 -0
- confiture/core/anonymization/strategies/preserve.py +85 -0
- confiture/core/anonymization/strategies/redact.py +101 -0
- confiture/core/anonymization/strategies/salted_hashing.py +322 -0
- confiture/core/anonymization/strategies/text_redaction.py +183 -0
- confiture/core/anonymization/strategies/tokenization.py +334 -0
- confiture/core/anonymization/strategy.py +241 -0
- confiture/core/anonymization/syncer_audit.py +357 -0
- confiture/core/blue_green.py +683 -0
- confiture/core/builder.py +500 -0
- confiture/core/checksum.py +358 -0
- confiture/core/connection.py +184 -0
- confiture/core/differ.py +522 -0
- confiture/core/drift.py +564 -0
- confiture/core/dry_run.py +182 -0
- confiture/core/health.py +313 -0
- confiture/core/hooks/__init__.py +87 -0
- confiture/core/hooks/base.py +232 -0
- confiture/core/hooks/context.py +146 -0
- confiture/core/hooks/execution_strategies.py +57 -0
- confiture/core/hooks/observability.py +220 -0
- confiture/core/hooks/phases.py +53 -0
- confiture/core/hooks/registry.py +295 -0
- confiture/core/large_tables.py +775 -0
- confiture/core/linting/__init__.py +70 -0
- confiture/core/linting/composer.py +192 -0
- confiture/core/linting/libraries/__init__.py +17 -0
- confiture/core/linting/libraries/gdpr.py +168 -0
- confiture/core/linting/libraries/general.py +184 -0
- confiture/core/linting/libraries/hipaa.py +144 -0
- confiture/core/linting/libraries/pci_dss.py +104 -0
- confiture/core/linting/libraries/sox.py +120 -0
- confiture/core/linting/schema_linter.py +491 -0
- confiture/core/linting/versioning.py +151 -0
- confiture/core/locking.py +389 -0
- confiture/core/migration_generator.py +298 -0
- confiture/core/migrator.py +882 -0
- confiture/core/observability/__init__.py +44 -0
- confiture/core/observability/audit.py +323 -0
- confiture/core/observability/logging.py +187 -0
- confiture/core/observability/metrics.py +174 -0
- confiture/core/observability/tracing.py +192 -0
- confiture/core/pg_version.py +418 -0
- confiture/core/pool.py +406 -0
- confiture/core/risk/__init__.py +39 -0
- confiture/core/risk/predictor.py +188 -0
- confiture/core/risk/scoring.py +248 -0
- confiture/core/rollback_generator.py +388 -0
- confiture/core/schema_analyzer.py +769 -0
- confiture/core/schema_to_schema.py +590 -0
- confiture/core/security/__init__.py +32 -0
- confiture/core/security/logging.py +201 -0
- confiture/core/security/validation.py +416 -0
- confiture/core/signals.py +371 -0
- confiture/core/syncer.py +540 -0
- confiture/exceptions.py +192 -0
- confiture/integrations/__init__.py +0 -0
- confiture/models/__init__.py +24 -0
- confiture/models/lint.py +193 -0
- confiture/models/migration.py +265 -0
- confiture/models/schema.py +203 -0
- confiture/models/sql_file_migration.py +225 -0
- confiture/scenarios/__init__.py +36 -0
- confiture/scenarios/compliance.py +586 -0
- confiture/scenarios/ecommerce.py +199 -0
- confiture/scenarios/financial.py +253 -0
- confiture/scenarios/healthcare.py +315 -0
- confiture/scenarios/multi_tenant.py +340 -0
- confiture/scenarios/saas.py +295 -0
- confiture/testing/FRAMEWORK_API.md +722 -0
- confiture/testing/__init__.py +100 -0
- confiture/testing/fixtures/__init__.py +11 -0
- confiture/testing/fixtures/data_validator.py +229 -0
- confiture/testing/fixtures/migration_runner.py +167 -0
- confiture/testing/fixtures/schema_snapshotter.py +352 -0
- confiture/testing/frameworks/__init__.py +10 -0
- confiture/testing/frameworks/mutation.py +587 -0
- confiture/testing/frameworks/performance.py +479 -0
- confiture/testing/loader.py +225 -0
- confiture/testing/pytest/__init__.py +38 -0
- confiture/testing/pytest_plugin.py +190 -0
- confiture/testing/sandbox.py +304 -0
- confiture/testing/utils/__init__.py +0 -0
- fraiseql_confiture-0.3.7.dist-info/METADATA +438 -0
- fraiseql_confiture-0.3.7.dist-info/RECORD +124 -0
- fraiseql_confiture-0.3.7.dist-info/WHEEL +4 -0
- fraiseql_confiture-0.3.7.dist-info/entry_points.txt +4 -0
- fraiseql_confiture-0.3.7.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Hook event categorization.
|
|
2
|
+
|
|
3
|
+
Three categories with distinct semantics:
|
|
4
|
+
1. LIFECYCLE EVENTS - Fired before/after specific operations
|
|
5
|
+
2. STATE EVENTS - Fired when migration enters/leaves a state
|
|
6
|
+
3. ALERT EVENTS - Fired when metrics cross thresholds
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from enum import Enum
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HookPhase(Enum):
|
|
15
|
+
"""Lifecycle events - operation boundaries."""
|
|
16
|
+
|
|
17
|
+
BEFORE_ANALYZE_SCHEMA = "before_analyze_schema"
|
|
18
|
+
AFTER_ANALYZE_SCHEMA = "after_analyze_schema"
|
|
19
|
+
BEFORE_DIFF_SCHEMAS = "before_diff_schemas"
|
|
20
|
+
AFTER_DIFF_SCHEMAS = "after_diff_schemas"
|
|
21
|
+
BEFORE_PLAN_MIGRATION = "before_plan_migration"
|
|
22
|
+
AFTER_PLAN_MIGRATION = "after_plan_migration"
|
|
23
|
+
BEFORE_DRY_RUN = "before_dry_run"
|
|
24
|
+
AFTER_DRY_RUN = "after_dry_run"
|
|
25
|
+
BEFORE_EXECUTE = "before_execute"
|
|
26
|
+
AFTER_EXECUTE = "after_execute"
|
|
27
|
+
BEFORE_VALIDATE = "before_validate"
|
|
28
|
+
AFTER_VALIDATE = "after_validate"
|
|
29
|
+
BEFORE_ROLLBACK = "before_rollback"
|
|
30
|
+
AFTER_ROLLBACK = "after_rollback"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HookEvent(Enum):
|
|
34
|
+
"""State change events - observable state transitions."""
|
|
35
|
+
|
|
36
|
+
MIGRATION_STARTED = "migration_started"
|
|
37
|
+
MIGRATION_PAUSED = "migration_paused"
|
|
38
|
+
MIGRATION_RESUMED = "migration_resumed"
|
|
39
|
+
MIGRATION_COMPLETED = "migration_completed"
|
|
40
|
+
MIGRATION_FAILED = "migration_failed"
|
|
41
|
+
MIGRATION_ROLLED_BACK = "migration_rolled_back"
|
|
42
|
+
MIGRATION_CANCELLED = "migration_cancelled"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class HookAlert(Enum):
|
|
46
|
+
"""Threshold alerts - reactive to metric crossings."""
|
|
47
|
+
|
|
48
|
+
DATA_ANOMALY_DETECTED = "data_anomaly_detected"
|
|
49
|
+
LOCK_TIMEOUT_EXCEEDED = "lock_timeout_exceeded"
|
|
50
|
+
PERFORMANCE_DEGRADED = "performance_degraded"
|
|
51
|
+
MEMORY_THRESHOLD_EXCEEDED = "memory_threshold_exceeded"
|
|
52
|
+
LONG_TRANSACTION_DETECTED = "long_transaction_detected"
|
|
53
|
+
CONNECTION_POOL_EXHAUSTED = "connection_pool_exhausted"
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Hook registry and execution engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from typing import Any, Generic, TypeVar
|
|
9
|
+
|
|
10
|
+
from .base import Hook, HookResult
|
|
11
|
+
from .context import HookContext
|
|
12
|
+
from .execution_strategies import (
|
|
13
|
+
HookErrorStrategy,
|
|
14
|
+
HookExecutionStrategy,
|
|
15
|
+
HookPhaseConfig,
|
|
16
|
+
)
|
|
17
|
+
from .observability import (
|
|
18
|
+
CircuitBreaker,
|
|
19
|
+
HookExecutionError,
|
|
20
|
+
HookExecutionEvent,
|
|
21
|
+
HookExecutionResult,
|
|
22
|
+
HookExecutionStatus,
|
|
23
|
+
HookExecutionTracer,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
T = TypeVar("T")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _extract_phase_value(phase: Any) -> str:
|
|
32
|
+
"""Extract string value from phase enum or convert to string.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
phase: A phase/event/alert that may be an enum or string.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
String representation of the phase value.
|
|
39
|
+
"""
|
|
40
|
+
return phase.value if hasattr(phase, "value") else str(phase)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class HookRegistry(Generic[T]):
|
|
44
|
+
"""Manage hook registration and execution."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, execution_config: dict[Any, HookPhaseConfig] | None = None):
|
|
47
|
+
self.hooks: dict[str, list[Hook]] = {} # phase/event/alert -> hooks
|
|
48
|
+
self.execution_config = execution_config or {}
|
|
49
|
+
self.circuit_breakers: dict[str, CircuitBreaker] = {}
|
|
50
|
+
self.execution_log: list[HookExecutionEvent] = []
|
|
51
|
+
self.tracer = HookExecutionTracer()
|
|
52
|
+
|
|
53
|
+
def register(self, phase_key: str | Any, hook: Hook) -> None:
|
|
54
|
+
"""Register a hook for a phase/event/alert."""
|
|
55
|
+
phase_value = _extract_phase_value(phase_key)
|
|
56
|
+
|
|
57
|
+
if phase_value not in self.hooks:
|
|
58
|
+
self.hooks[phase_value] = []
|
|
59
|
+
|
|
60
|
+
self.hooks[phase_value].append(hook)
|
|
61
|
+
|
|
62
|
+
# Create circuit breaker for this hook
|
|
63
|
+
if hook.id not in self.circuit_breakers:
|
|
64
|
+
self.circuit_breakers[hook.id] = CircuitBreaker(hook.id)
|
|
65
|
+
|
|
66
|
+
logger.info(f"Registered hook {hook.name} ({hook.id}) for phase {phase_value}")
|
|
67
|
+
|
|
68
|
+
async def trigger(
|
|
69
|
+
self,
|
|
70
|
+
phase: Any, # HookPhase | HookEvent | HookAlert
|
|
71
|
+
context: HookContext[T],
|
|
72
|
+
) -> HookExecutionResult:
|
|
73
|
+
"""Trigger hooks for a phase/event/alert."""
|
|
74
|
+
phase_value = _extract_phase_value(phase)
|
|
75
|
+
config = self.execution_config.get(phase, HookPhaseConfig(phase=phase))
|
|
76
|
+
hooks = self.hooks.get(phase_value, [])
|
|
77
|
+
|
|
78
|
+
if not hooks:
|
|
79
|
+
return HookExecutionResult(phase=phase_value, hooks_executed=0)
|
|
80
|
+
|
|
81
|
+
# Execute according to strategy
|
|
82
|
+
if config.execution_strategy == HookExecutionStrategy.SEQUENTIAL:
|
|
83
|
+
return await self._execute_sequential(context, hooks, config)
|
|
84
|
+
elif config.execution_strategy == HookExecutionStrategy.PARALLEL:
|
|
85
|
+
return await self._execute_parallel(context, hooks, config)
|
|
86
|
+
elif config.execution_strategy == HookExecutionStrategy.PARALLEL_WITH_DEPS:
|
|
87
|
+
return await self._execute_dag(context, hooks, config)
|
|
88
|
+
|
|
89
|
+
return HookExecutionResult(phase=phase_value, hooks_executed=0)
|
|
90
|
+
|
|
91
|
+
async def _execute_sequential(
|
|
92
|
+
self,
|
|
93
|
+
context: HookContext[T],
|
|
94
|
+
hooks: list[Hook],
|
|
95
|
+
config: HookPhaseConfig,
|
|
96
|
+
) -> HookExecutionResult:
|
|
97
|
+
"""Execute hooks one-by-one."""
|
|
98
|
+
results = []
|
|
99
|
+
|
|
100
|
+
for hook in sorted(hooks, key=lambda h: h.priority):
|
|
101
|
+
try:
|
|
102
|
+
result = await self._execute_hook_with_timeout(hook, context, config)
|
|
103
|
+
results.append(result)
|
|
104
|
+
|
|
105
|
+
# Check if we should fail fast
|
|
106
|
+
if (
|
|
107
|
+
result.status == HookExecutionStatus.FAILED
|
|
108
|
+
and config.error_strategy == HookErrorStrategy.FAIL_FAST
|
|
109
|
+
):
|
|
110
|
+
raise HookExecutionError(f"Hook {hook.name} failed: {result.error}")
|
|
111
|
+
|
|
112
|
+
except Exception as e:
|
|
113
|
+
if config.error_strategy == HookErrorStrategy.FAIL_SAFE:
|
|
114
|
+
logger.error(f"Hook {hook.name} failed: {e}")
|
|
115
|
+
results.append(
|
|
116
|
+
HookExecutionEvent(
|
|
117
|
+
execution_id=context.execution_id,
|
|
118
|
+
hook_id=hook.id,
|
|
119
|
+
phase=_extract_phase_value(config.phase),
|
|
120
|
+
status=HookExecutionStatus.FAILED,
|
|
121
|
+
error=str(e),
|
|
122
|
+
duration_ms=0,
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
elif config.error_strategy == HookErrorStrategy.RETRY:
|
|
126
|
+
result = await self._retry_hook(hook, context, config)
|
|
127
|
+
results.append(result)
|
|
128
|
+
else:
|
|
129
|
+
raise
|
|
130
|
+
|
|
131
|
+
return HookExecutionResult(
|
|
132
|
+
phase=_extract_phase_value(config.phase),
|
|
133
|
+
hooks_executed=len(results),
|
|
134
|
+
results=results,
|
|
135
|
+
total_duration_ms=sum(r.duration_ms for r in results),
|
|
136
|
+
failed_count=sum(1 for r in results if r.status == HookExecutionStatus.FAILED),
|
|
137
|
+
timeout_count=sum(1 for r in results if r.status == HookExecutionStatus.TIMEOUT),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
async def _execute_parallel(
|
|
141
|
+
self,
|
|
142
|
+
context: HookContext[T],
|
|
143
|
+
hooks: list[Hook],
|
|
144
|
+
config: HookPhaseConfig,
|
|
145
|
+
) -> HookExecutionResult:
|
|
146
|
+
"""Execute hooks in parallel."""
|
|
147
|
+
# Limit parallelism
|
|
148
|
+
semaphore = asyncio.Semaphore(config.max_parallel_hooks)
|
|
149
|
+
|
|
150
|
+
async def execute_with_semaphore(hook: Hook) -> HookExecutionEvent:
|
|
151
|
+
async with semaphore:
|
|
152
|
+
return await self._execute_hook_with_timeout(hook, context, config)
|
|
153
|
+
|
|
154
|
+
tasks = [execute_with_semaphore(hook) for hook in hooks]
|
|
155
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
156
|
+
|
|
157
|
+
# Process results
|
|
158
|
+
failed = [
|
|
159
|
+
r for r in results if isinstance(r, Exception) or r.status == HookExecutionStatus.FAILED
|
|
160
|
+
]
|
|
161
|
+
if failed and config.error_strategy == HookErrorStrategy.FAIL_FAST:
|
|
162
|
+
raise HookExecutionError(f"{len(failed)} hooks failed in parallel execution")
|
|
163
|
+
|
|
164
|
+
return HookExecutionResult(
|
|
165
|
+
phase=_extract_phase_value(config.phase),
|
|
166
|
+
hooks_executed=len(results),
|
|
167
|
+
results=[r for r in results if not isinstance(r, Exception)],
|
|
168
|
+
total_duration_ms=sum(r.duration_ms for r in results if not isinstance(r, Exception)),
|
|
169
|
+
failed_count=sum(
|
|
170
|
+
1
|
|
171
|
+
for r in results
|
|
172
|
+
if not isinstance(r, Exception) and r.status == HookExecutionStatus.FAILED
|
|
173
|
+
),
|
|
174
|
+
timeout_count=sum(
|
|
175
|
+
1
|
|
176
|
+
for r in results
|
|
177
|
+
if not isinstance(r, Exception) and r.status == HookExecutionStatus.TIMEOUT
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
async def _execute_dag(
|
|
182
|
+
self,
|
|
183
|
+
context: HookContext[T],
|
|
184
|
+
hooks: list[Hook],
|
|
185
|
+
config: HookPhaseConfig,
|
|
186
|
+
) -> HookExecutionResult:
|
|
187
|
+
"""Execute hooks with dependency resolution."""
|
|
188
|
+
# For now, fall back to sequential execution
|
|
189
|
+
# Full DAG implementation would go here
|
|
190
|
+
return await self._execute_sequential(context, hooks, config)
|
|
191
|
+
|
|
192
|
+
async def _execute_hook_with_timeout(
|
|
193
|
+
self,
|
|
194
|
+
hook: Hook,
|
|
195
|
+
context: HookContext[T],
|
|
196
|
+
config: HookPhaseConfig,
|
|
197
|
+
) -> HookExecutionEvent:
|
|
198
|
+
"""Execute single hook with timeout enforcement."""
|
|
199
|
+
start = datetime.now(UTC)
|
|
200
|
+
circuit_breaker = self.circuit_breakers.get(hook.id)
|
|
201
|
+
|
|
202
|
+
if circuit_breaker and circuit_breaker.is_open:
|
|
203
|
+
return HookExecutionEvent(
|
|
204
|
+
execution_id=context.execution_id,
|
|
205
|
+
hook_id=hook.id,
|
|
206
|
+
phase=_extract_phase_value(config.phase),
|
|
207
|
+
status=HookExecutionStatus.SKIPPED,
|
|
208
|
+
reason="Circuit breaker open",
|
|
209
|
+
duration_ms=0,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
result: HookResult = await asyncio.wait_for(
|
|
214
|
+
hook.execute(context),
|
|
215
|
+
timeout=config.timeout_per_hook_ms / 1000,
|
|
216
|
+
)
|
|
217
|
+
duration = (datetime.now(UTC) - start).total_seconds() * 1000
|
|
218
|
+
|
|
219
|
+
event = HookExecutionEvent(
|
|
220
|
+
execution_id=context.execution_id,
|
|
221
|
+
hook_id=hook.id,
|
|
222
|
+
phase=_extract_phase_value(config.phase),
|
|
223
|
+
status=HookExecutionStatus.COMPLETED,
|
|
224
|
+
duration_ms=int(duration),
|
|
225
|
+
rows_affected=result.rows_affected,
|
|
226
|
+
stats=result.stats,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if circuit_breaker:
|
|
230
|
+
circuit_breaker.record_success()
|
|
231
|
+
|
|
232
|
+
self.tracer.record_execution(event)
|
|
233
|
+
return event
|
|
234
|
+
|
|
235
|
+
except TimeoutError:
|
|
236
|
+
if circuit_breaker:
|
|
237
|
+
circuit_breaker.record_failure()
|
|
238
|
+
return HookExecutionEvent(
|
|
239
|
+
execution_id=context.execution_id,
|
|
240
|
+
hook_id=hook.id,
|
|
241
|
+
phase=_extract_phase_value(config.phase),
|
|
242
|
+
status=HookExecutionStatus.TIMEOUT,
|
|
243
|
+
reason=f"Exceeded {config.timeout_per_hook_ms}ms timeout",
|
|
244
|
+
duration_ms=config.timeout_per_hook_ms,
|
|
245
|
+
)
|
|
246
|
+
except Exception as e:
|
|
247
|
+
if circuit_breaker:
|
|
248
|
+
circuit_breaker.record_failure()
|
|
249
|
+
return HookExecutionEvent(
|
|
250
|
+
execution_id=context.execution_id,
|
|
251
|
+
hook_id=hook.id,
|
|
252
|
+
phase=_extract_phase_value(config.phase),
|
|
253
|
+
status=HookExecutionStatus.FAILED,
|
|
254
|
+
error=str(e),
|
|
255
|
+
duration_ms=int((datetime.now(UTC) - start).total_seconds() * 1000),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
async def _retry_hook(
|
|
259
|
+
self,
|
|
260
|
+
hook: Hook,
|
|
261
|
+
context: HookContext[T],
|
|
262
|
+
config: HookPhaseConfig,
|
|
263
|
+
) -> HookExecutionEvent:
|
|
264
|
+
"""Retry hook with exponential backoff."""
|
|
265
|
+
if not config.retry_config:
|
|
266
|
+
return await self._execute_hook_with_timeout(hook, context, config)
|
|
267
|
+
|
|
268
|
+
retry_config = config.retry_config
|
|
269
|
+
last_error = None
|
|
270
|
+
|
|
271
|
+
for attempt in range(retry_config.max_attempts):
|
|
272
|
+
try:
|
|
273
|
+
return await self._execute_hook_with_timeout(hook, context, config)
|
|
274
|
+
except Exception as e:
|
|
275
|
+
last_error = e
|
|
276
|
+
if attempt < retry_config.max_attempts - 1:
|
|
277
|
+
delay_ms = min(
|
|
278
|
+
retry_config.initial_delay_ms * (retry_config.backoff_multiplier**attempt),
|
|
279
|
+
retry_config.max_delay_ms,
|
|
280
|
+
)
|
|
281
|
+
logger.warning(
|
|
282
|
+
f"Hook {hook.name} failed, retrying in {delay_ms}ms "
|
|
283
|
+
f"(attempt {attempt + 1}/{retry_config.max_attempts})"
|
|
284
|
+
)
|
|
285
|
+
await asyncio.sleep(delay_ms / 1000)
|
|
286
|
+
|
|
287
|
+
# All retries failed
|
|
288
|
+
return HookExecutionEvent(
|
|
289
|
+
execution_id=context.execution_id,
|
|
290
|
+
hook_id=hook.id,
|
|
291
|
+
phase=_extract_phase_value(config.phase),
|
|
292
|
+
status=HookExecutionStatus.FAILED,
|
|
293
|
+
error=f"Failed after {retry_config.max_attempts} attempts: {last_error}",
|
|
294
|
+
duration_ms=0,
|
|
295
|
+
)
|