dory-processor-sdk 0.0.1__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.
- dory/__init__.py +101 -0
- dory/auth/__init__.py +10 -0
- dory/auth/oauth2.py +153 -0
- dory/auto_instrument.py +142 -0
- dory/cli/__init__.py +5 -0
- dory/cli/main.py +137 -0
- dory/cli/templates.py +123 -0
- dory/config/__init__.py +23 -0
- dory/config/defaults.py +24 -0
- dory/config/loader.py +430 -0
- dory/config/presets.py +73 -0
- dory/config/schema.py +84 -0
- dory/core/__init__.py +27 -0
- dory/core/app.py +434 -0
- dory/core/context.py +209 -0
- dory/core/lifecycle.py +214 -0
- dory/core/meta.py +121 -0
- dory/core/modes.py +479 -0
- dory/core/processor.py +564 -0
- dory/core/signals.py +122 -0
- dory/decorators.py +142 -0
- dory/edge/__init__.py +88 -0
- dory/edge/adaptive.py +644 -0
- dory/edge/detector.py +546 -0
- dory/edge/fencing.py +488 -0
- dory/edge/heartbeat.py +598 -0
- dory/edge/role.py +419 -0
- dory/errors/__init__.py +139 -0
- dory/errors/classification.py +362 -0
- dory/errors/codes.py +498 -0
- dory/geo/__init__.py +40 -0
- dory/geo/geolocalizer.py +1034 -0
- dory/health/__init__.py +12 -0
- dory/health/probes.py +210 -0
- dory/health/server.py +635 -0
- dory/k8s/__init__.py +80 -0
- dory/k8s/annotation_watcher.py +184 -0
- dory/k8s/client.py +251 -0
- dory/k8s/labels.py +505 -0
- dory/k8s/pod_metadata.py +182 -0
- dory/logging/__init__.py +9 -0
- dory/logging/logger.py +148 -0
- dory/metrics/__init__.py +7 -0
- dory/metrics/collector.py +301 -0
- dory/middleware/__init__.py +46 -0
- dory/middleware/connection_tracker.py +608 -0
- dory/middleware/request_id.py +325 -0
- dory/middleware/request_tracker.py +511 -0
- dory/migration/__init__.py +33 -0
- dory/migration/configmap.py +232 -0
- dory/migration/s3_store.py +594 -0
- dory/migration/serialization.py +135 -0
- dory/migration/state_manager.py +286 -0
- dory/migration/transfer.py +382 -0
- dory/monitoring/__init__.py +29 -0
- dory/monitoring/opentelemetry.py +489 -0
- dory/output/__init__.py +31 -0
- dory/output/envelope.py +137 -0
- dory/output/formatter.py +113 -0
- dory/output/rabbitmq.py +632 -0
- dory/output/routing.py +318 -0
- dory/output/validator.py +199 -0
- dory/py.typed +2 -0
- dory/recovery/__init__.py +60 -0
- dory/recovery/golden_image.py +487 -0
- dory/recovery/golden_snapshot.py +713 -0
- dory/recovery/golden_validator.py +518 -0
- dory/recovery/partial_recovery.py +482 -0
- dory/recovery/recovery_decision.py +242 -0
- dory/recovery/restart_detector.py +142 -0
- dory/recovery/state_validator.py +183 -0
- dory/resilience/__init__.py +45 -0
- dory/resilience/circuit_breaker.py +457 -0
- dory/resilience/retry.py +389 -0
- dory/simple.py +342 -0
- dory/types.py +68 -0
- dory/utils/__init__.py +31 -0
- dory/utils/errors.py +59 -0
- dory/utils/retry.py +115 -0
- dory/utils/timeout.py +80 -0
- dory_processor_sdk-0.0.1.dist-info/METADATA +424 -0
- dory_processor_sdk-0.0.1.dist-info/RECORD +86 -0
- dory_processor_sdk-0.0.1.dist-info/WHEEL +5 -0
- dory_processor_sdk-0.0.1.dist-info/entry_points.txt +2 -0
- dory_processor_sdk-0.0.1.dist-info/licenses/LICENSE +201 -0
- dory_processor_sdk-0.0.1.dist-info/top_level.txt +1 -0
dory/simple.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Simple function-based API for Dory processors.
|
|
3
|
+
|
|
4
|
+
For applications that don't need the full class-based API,
|
|
5
|
+
this module provides a simpler decorator-based approach.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from dory.simple import processor, state
|
|
9
|
+
|
|
10
|
+
counter = state(0)
|
|
11
|
+
|
|
12
|
+
@processor
|
|
13
|
+
async def main(ctx):
|
|
14
|
+
async for _ in ctx.run_loop(interval=1):
|
|
15
|
+
counter.value += 1
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import inspect
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any, Callable, Awaitable, TypeVar, Generic
|
|
22
|
+
|
|
23
|
+
from dory.core.app import DoryApp
|
|
24
|
+
from dory.core.processor import BaseProcessor
|
|
25
|
+
from dory.core.context import ExecutionContext
|
|
26
|
+
|
|
27
|
+
T = TypeVar('T')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class StateVar(Generic[T]):
|
|
32
|
+
"""
|
|
33
|
+
A state variable for function-based processors.
|
|
34
|
+
|
|
35
|
+
Usage:
|
|
36
|
+
counter = state(0)
|
|
37
|
+
sessions = state(dict) # Factory for mutable defaults
|
|
38
|
+
|
|
39
|
+
@processor
|
|
40
|
+
async def main(ctx):
|
|
41
|
+
counter.value += 1
|
|
42
|
+
sessions.value["user1"] = data
|
|
43
|
+
"""
|
|
44
|
+
_default: T | Callable[[], T]
|
|
45
|
+
_value: T = field(default=None, init=False, repr=False)
|
|
46
|
+
_initialized: bool = field(default=False, init=False)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def value(self) -> T:
|
|
50
|
+
"""Get the current value."""
|
|
51
|
+
if not self._initialized:
|
|
52
|
+
if callable(self._default):
|
|
53
|
+
self._value = self._default()
|
|
54
|
+
else:
|
|
55
|
+
self._value = self._default
|
|
56
|
+
self._initialized = True
|
|
57
|
+
return self._value
|
|
58
|
+
|
|
59
|
+
@value.setter
|
|
60
|
+
def value(self, new_value: T) -> None:
|
|
61
|
+
"""Set a new value."""
|
|
62
|
+
self._value = new_value
|
|
63
|
+
self._initialized = True
|
|
64
|
+
|
|
65
|
+
def reset(self) -> None:
|
|
66
|
+
"""Reset to default value."""
|
|
67
|
+
self._initialized = False
|
|
68
|
+
|
|
69
|
+
def get(self) -> T:
|
|
70
|
+
"""Alias for value property."""
|
|
71
|
+
return self.value
|
|
72
|
+
|
|
73
|
+
def set(self, new_value: T) -> None:
|
|
74
|
+
"""Alias for value setter."""
|
|
75
|
+
self.value = new_value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def state(default: T | Callable[[], T] = None) -> StateVar[T]:
|
|
79
|
+
"""
|
|
80
|
+
Create a state variable for function-based processors.
|
|
81
|
+
|
|
82
|
+
State variables are automatically saved and restored during migrations.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
default: Default value or factory function for mutable defaults
|
|
86
|
+
|
|
87
|
+
Usage:
|
|
88
|
+
# Simple values
|
|
89
|
+
counter = state(0)
|
|
90
|
+
name = state("default")
|
|
91
|
+
|
|
92
|
+
# Mutable values (use factory)
|
|
93
|
+
data = state(dict) # Creates new dict each time
|
|
94
|
+
items = state(list) # Creates new list each time
|
|
95
|
+
|
|
96
|
+
@processor
|
|
97
|
+
async def main(ctx):
|
|
98
|
+
counter.value += 1
|
|
99
|
+
data.value["key"] = "value"
|
|
100
|
+
"""
|
|
101
|
+
return StateVar(_default=default)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# Global registry of state variables for current processor
|
|
105
|
+
_state_registry: dict[str, StateVar] = {}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _register_state(name: str, var: StateVar) -> None:
|
|
109
|
+
"""Register a state variable (called by processor decorator)."""
|
|
110
|
+
_state_registry[name] = var
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _get_all_state() -> dict[str, Any]:
|
|
114
|
+
"""Get all state values."""
|
|
115
|
+
return {name: var.value for name, var in _state_registry.items()}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _set_all_state(values: dict[str, Any]) -> None:
|
|
119
|
+
"""Set all state values."""
|
|
120
|
+
for name, value in values.items():
|
|
121
|
+
if name in _state_registry:
|
|
122
|
+
_state_registry[name].value = value
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class FunctionProcessor(BaseProcessor):
|
|
126
|
+
"""
|
|
127
|
+
Wrapper that converts a function into a BaseProcessor.
|
|
128
|
+
|
|
129
|
+
Internal use by @processor decorator.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def __init__(
|
|
133
|
+
self,
|
|
134
|
+
func: Callable[[ExecutionContext], Awaitable[None]],
|
|
135
|
+
state_vars: dict[str, StateVar],
|
|
136
|
+
context: ExecutionContext | None = None,
|
|
137
|
+
):
|
|
138
|
+
super().__init__(context)
|
|
139
|
+
self._func = func
|
|
140
|
+
self._state_vars = state_vars
|
|
141
|
+
|
|
142
|
+
async def run(self) -> None:
|
|
143
|
+
"""Run the wrapped function."""
|
|
144
|
+
await self._func(self.context)
|
|
145
|
+
|
|
146
|
+
def get_state(self) -> dict:
|
|
147
|
+
"""Get state from registered state variables."""
|
|
148
|
+
return {name: var.value for name, var in self._state_vars.items()}
|
|
149
|
+
|
|
150
|
+
async def restore_state(self, state: dict) -> None:
|
|
151
|
+
"""Restore state to registered state variables."""
|
|
152
|
+
for name, value in state.items():
|
|
153
|
+
if name in self._state_vars:
|
|
154
|
+
self._state_vars[name].value = value
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class ContextWrapper:
|
|
158
|
+
"""
|
|
159
|
+
Wrapper around ExecutionContext with additional helpers for function-based API.
|
|
160
|
+
|
|
161
|
+
Provides run_loop() and other conveniences directly on ctx.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(self, context: ExecutionContext):
|
|
165
|
+
self._context = context
|
|
166
|
+
|
|
167
|
+
def __getattr__(self, name: str) -> Any:
|
|
168
|
+
"""Delegate to underlying context."""
|
|
169
|
+
return getattr(self._context, name)
|
|
170
|
+
|
|
171
|
+
async def run_loop(
|
|
172
|
+
self,
|
|
173
|
+
interval: float = 1.0,
|
|
174
|
+
check_migration: bool = True,
|
|
175
|
+
):
|
|
176
|
+
"""
|
|
177
|
+
Async iterator that yields until shutdown is requested.
|
|
178
|
+
|
|
179
|
+
Usage:
|
|
180
|
+
@processor
|
|
181
|
+
async def main(ctx):
|
|
182
|
+
async for i in ctx.run_loop(interval=1):
|
|
183
|
+
counter.value += 1
|
|
184
|
+
"""
|
|
185
|
+
iteration = 0
|
|
186
|
+
while not self._context.is_shutdown_requested():
|
|
187
|
+
yield iteration
|
|
188
|
+
iteration += 1
|
|
189
|
+
|
|
190
|
+
if check_migration and self._context.is_migration_imminent():
|
|
191
|
+
self._context.logger().info(
|
|
192
|
+
f"Migration imminent, completing iteration {iteration}"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
await asyncio.sleep(interval)
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def shutdown_requested(self) -> bool:
|
|
199
|
+
"""Check if shutdown is requested (simpler than is_shutdown_requested())."""
|
|
200
|
+
return self._context.is_shutdown_requested()
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def migration_imminent(self) -> bool:
|
|
204
|
+
"""Check if migration is imminent (simpler than is_migration_imminent())."""
|
|
205
|
+
return self._context.is_migration_imminent()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def processor(
|
|
209
|
+
func: Callable[[ContextWrapper], Awaitable[None]] | None = None,
|
|
210
|
+
*,
|
|
211
|
+
config_file: str | None = None,
|
|
212
|
+
log_level: str | None = None,
|
|
213
|
+
):
|
|
214
|
+
"""
|
|
215
|
+
Decorator to create a Dory processor from a simple async function.
|
|
216
|
+
|
|
217
|
+
This is the simplest way to create a Dory processor. Just decorate
|
|
218
|
+
your main async function and it handles everything else.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
func: The async function to wrap
|
|
222
|
+
config_file: Optional path to config file
|
|
223
|
+
log_level: Optional log level override
|
|
224
|
+
|
|
225
|
+
Usage:
|
|
226
|
+
# Minimal stateless processor
|
|
227
|
+
from dory.simple import processor
|
|
228
|
+
|
|
229
|
+
@processor
|
|
230
|
+
async def main(ctx):
|
|
231
|
+
while not ctx.shutdown_requested:
|
|
232
|
+
print("Working...")
|
|
233
|
+
await asyncio.sleep(1)
|
|
234
|
+
|
|
235
|
+
# With state
|
|
236
|
+
from dory.simple import processor, state
|
|
237
|
+
|
|
238
|
+
counter = state(0)
|
|
239
|
+
data = state(dict)
|
|
240
|
+
|
|
241
|
+
@processor
|
|
242
|
+
async def main(ctx):
|
|
243
|
+
async for i in ctx.run_loop(interval=1):
|
|
244
|
+
counter.value += 1
|
|
245
|
+
print(f"Count: {counter.value}")
|
|
246
|
+
|
|
247
|
+
# With config
|
|
248
|
+
@processor(config_file="dory.yaml", log_level="DEBUG")
|
|
249
|
+
async def main(ctx):
|
|
250
|
+
...
|
|
251
|
+
"""
|
|
252
|
+
def decorator(fn: Callable[[ContextWrapper], Awaitable[None]]):
|
|
253
|
+
# Collect state variables from the module
|
|
254
|
+
frame = inspect.currentframe()
|
|
255
|
+
if frame and frame.f_back and frame.f_back.f_back:
|
|
256
|
+
module_globals = frame.f_back.f_back.f_globals
|
|
257
|
+
state_vars = {
|
|
258
|
+
name: var
|
|
259
|
+
for name, var in module_globals.items()
|
|
260
|
+
if isinstance(var, StateVar)
|
|
261
|
+
}
|
|
262
|
+
else:
|
|
263
|
+
state_vars = {}
|
|
264
|
+
|
|
265
|
+
# Create wrapper function that wraps context
|
|
266
|
+
async def wrapped_func(context: ExecutionContext) -> None:
|
|
267
|
+
wrapper = ContextWrapper(context)
|
|
268
|
+
await fn(wrapper)
|
|
269
|
+
|
|
270
|
+
# Create processor class dynamically
|
|
271
|
+
class DynamicProcessor(FunctionProcessor):
|
|
272
|
+
def __init__(self, context: ExecutionContext | None = None):
|
|
273
|
+
super().__init__(wrapped_func, state_vars, context)
|
|
274
|
+
|
|
275
|
+
# Run immediately if this is the main module
|
|
276
|
+
if frame and frame.f_back and frame.f_back.f_back:
|
|
277
|
+
if module_globals.get("__name__") == "__main__":
|
|
278
|
+
DoryApp(
|
|
279
|
+
config_file=config_file,
|
|
280
|
+
log_level=log_level,
|
|
281
|
+
).run(DynamicProcessor)
|
|
282
|
+
|
|
283
|
+
return fn
|
|
284
|
+
|
|
285
|
+
if func is not None:
|
|
286
|
+
# Called without arguments: @processor
|
|
287
|
+
return decorator(func)
|
|
288
|
+
else:
|
|
289
|
+
# Called with arguments: @processor(config_file="...")
|
|
290
|
+
return decorator
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def run_processor(
|
|
294
|
+
func: Callable[[ContextWrapper], Awaitable[None]],
|
|
295
|
+
*,
|
|
296
|
+
config_file: str | None = None,
|
|
297
|
+
log_level: str | None = None,
|
|
298
|
+
) -> None:
|
|
299
|
+
"""
|
|
300
|
+
Run a function as a Dory processor.
|
|
301
|
+
|
|
302
|
+
Alternative to @processor decorator when you want explicit control.
|
|
303
|
+
|
|
304
|
+
Usage:
|
|
305
|
+
from dory.simple import run_processor, state
|
|
306
|
+
|
|
307
|
+
counter = state(0)
|
|
308
|
+
|
|
309
|
+
async def main(ctx):
|
|
310
|
+
async for _ in ctx.run_loop(interval=1):
|
|
311
|
+
counter.value += 1
|
|
312
|
+
|
|
313
|
+
if __name__ == "__main__":
|
|
314
|
+
run_processor(main)
|
|
315
|
+
"""
|
|
316
|
+
# Collect state variables from caller's module
|
|
317
|
+
frame = inspect.currentframe()
|
|
318
|
+
if frame and frame.f_back:
|
|
319
|
+
module_globals = frame.f_back.f_globals
|
|
320
|
+
state_vars = {
|
|
321
|
+
name: var
|
|
322
|
+
for name, var in module_globals.items()
|
|
323
|
+
if isinstance(var, StateVar)
|
|
324
|
+
}
|
|
325
|
+
else:
|
|
326
|
+
state_vars = {}
|
|
327
|
+
|
|
328
|
+
# Create wrapper function
|
|
329
|
+
async def wrapped_func(context: ExecutionContext) -> None:
|
|
330
|
+
wrapper = ContextWrapper(context)
|
|
331
|
+
await func(wrapper)
|
|
332
|
+
|
|
333
|
+
# Create processor class
|
|
334
|
+
class DynamicProcessor(FunctionProcessor):
|
|
335
|
+
def __init__(self, context: ExecutionContext | None = None):
|
|
336
|
+
super().__init__(wrapped_func, state_vars, context)
|
|
337
|
+
|
|
338
|
+
# Run
|
|
339
|
+
DoryApp(
|
|
340
|
+
config_file=config_file,
|
|
341
|
+
log_level=log_level,
|
|
342
|
+
).run(DynamicProcessor)
|
dory/types.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Type definitions for Dory SDK.
|
|
3
|
+
|
|
4
|
+
This module contains all type hints and type aliases used across the SDK.
|
|
5
|
+
No application-specific types should be defined here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import TypeVar, Callable, Awaitable, Any, Protocol
|
|
9
|
+
from enum import Enum, auto
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LifecycleState(Enum):
|
|
13
|
+
"""States in the processor lifecycle state machine."""
|
|
14
|
+
|
|
15
|
+
CREATED = auto() # Instance created, not yet started
|
|
16
|
+
STARTING = auto() # startup() in progress
|
|
17
|
+
RUNNING = auto() # run() in progress
|
|
18
|
+
SHUTTING_DOWN = auto() # shutdown() in progress
|
|
19
|
+
STOPPED = auto() # Gracefully stopped
|
|
20
|
+
FAILED = auto() # Error occurred
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class StateBackend(Enum):
|
|
24
|
+
"""Supported state storage backends."""
|
|
25
|
+
|
|
26
|
+
CONFIGMAP = "configmap" # Kubernetes ConfigMap (default, <1MB)
|
|
27
|
+
PVC = "pvc" # Persistent Volume Claim
|
|
28
|
+
S3 = "s3" # AWS S3 (for multi-cluster)
|
|
29
|
+
LOCAL = "local" # Local file (for testing)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RecoveryStrategy(Enum):
|
|
33
|
+
"""Recovery strategies after fault detection."""
|
|
34
|
+
|
|
35
|
+
RESTORE_STATE = "restore_state" # Try to restore from checkpoint
|
|
36
|
+
GOLDEN_IMAGE = "golden_image" # Start fresh, discard state
|
|
37
|
+
GOLDEN_WITH_BACKOFF = "golden_backoff" # Start fresh with delay
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FaultType(Enum):
|
|
41
|
+
"""Types of faults detected by SDK."""
|
|
42
|
+
|
|
43
|
+
CLEAN_EXIT = "clean_exit" # Normal termination
|
|
44
|
+
UNEXPECTED_CRASH = "crash" # Exit code non-zero
|
|
45
|
+
HUNG_PROCESS = "hung" # Liveness probe timeout
|
|
46
|
+
STATE_CORRUPTION = "state_corrupt" # State validation failed
|
|
47
|
+
STARTUP_FAILURE = "startup_fail" # Startup threw exception
|
|
48
|
+
HEALTH_CHECK_FAILURE = "health_fail" # Health endpoint returned error
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Type aliases
|
|
52
|
+
StateDict = dict[str, Any]
|
|
53
|
+
ConfigDict = dict[str, Any]
|
|
54
|
+
MetadataDict = dict[str, str]
|
|
55
|
+
|
|
56
|
+
# Callback types
|
|
57
|
+
ShutdownCallback = Callable[[], Awaitable[None]]
|
|
58
|
+
StateCallback = Callable[[], StateDict]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ProcessorProtocol(Protocol):
|
|
62
|
+
"""Protocol defining the processor interface."""
|
|
63
|
+
|
|
64
|
+
async def startup(self) -> None: ...
|
|
65
|
+
async def run(self) -> None: ...
|
|
66
|
+
async def shutdown(self) -> None: ...
|
|
67
|
+
def get_state(self) -> StateDict: ...
|
|
68
|
+
async def restore_state(self, state: StateDict) -> None: ...
|
dory/utils/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Utility modules for Dory SDK."""
|
|
2
|
+
|
|
3
|
+
from dory.utils.errors import (
|
|
4
|
+
DoryError,
|
|
5
|
+
DoryStartupError,
|
|
6
|
+
DoryShutdownError,
|
|
7
|
+
DoryStateError,
|
|
8
|
+
DoryConfigError,
|
|
9
|
+
DoryK8sError,
|
|
10
|
+
DoryHealthError,
|
|
11
|
+
DoryTimeoutError,
|
|
12
|
+
DoryValidationError,
|
|
13
|
+
)
|
|
14
|
+
from dory.utils.retry import retry_async, RetryConfig
|
|
15
|
+
from dory.utils.timeout import timeout_async, TimeoutConfig
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"DoryError",
|
|
19
|
+
"DoryStartupError",
|
|
20
|
+
"DoryShutdownError",
|
|
21
|
+
"DoryStateError",
|
|
22
|
+
"DoryConfigError",
|
|
23
|
+
"DoryK8sError",
|
|
24
|
+
"DoryHealthError",
|
|
25
|
+
"DoryTimeoutError",
|
|
26
|
+
"DoryValidationError",
|
|
27
|
+
"retry_async",
|
|
28
|
+
"RetryConfig",
|
|
29
|
+
"timeout_async",
|
|
30
|
+
"TimeoutConfig",
|
|
31
|
+
]
|
dory/utils/errors.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dory SDK Exception Classes.
|
|
3
|
+
|
|
4
|
+
All SDK-specific exceptions inherit from DoryError for easy catching.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DoryError(Exception):
|
|
9
|
+
"""Base exception for all Dory SDK errors."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str, cause: Exception | None = None):
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.message = message
|
|
14
|
+
self.cause = cause
|
|
15
|
+
|
|
16
|
+
def __str__(self) -> str:
|
|
17
|
+
if self.cause:
|
|
18
|
+
return f"{self.message}: {self.cause}"
|
|
19
|
+
return self.message
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DoryStartupError(DoryError):
|
|
23
|
+
"""Raised when processor startup fails."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DoryShutdownError(DoryError):
|
|
28
|
+
"""Raised when processor shutdown fails or times out."""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DoryStateError(DoryError):
|
|
33
|
+
"""Raised when state operations fail (snapshot, restore, validation)."""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DoryConfigError(DoryError):
|
|
38
|
+
"""Raised when configuration is invalid or missing."""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DoryK8sError(DoryError):
|
|
43
|
+
"""Raised when Kubernetes API operations fail."""
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DoryHealthError(DoryError):
|
|
48
|
+
"""Raised when health check operations fail."""
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class DoryTimeoutError(DoryError):
|
|
53
|
+
"""Raised when an operation times out."""
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class DoryValidationError(DoryError):
|
|
58
|
+
"""Raised when validation fails (state schema, config, etc.)."""
|
|
59
|
+
pass
|
dory/utils/retry.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Retry utilities with exponential backoff.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import functools
|
|
7
|
+
import random
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Callable, TypeVar, ParamSpec, Awaitable
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
P = ParamSpec("P")
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class RetryConfig:
|
|
20
|
+
"""Configuration for retry behavior."""
|
|
21
|
+
|
|
22
|
+
max_attempts: int = 3
|
|
23
|
+
base_delay: float = 1.0 # seconds
|
|
24
|
+
max_delay: float = 60.0 # seconds
|
|
25
|
+
exponential_base: float = 2.0
|
|
26
|
+
jitter: bool = True
|
|
27
|
+
retryable_exceptions: tuple[type[Exception], ...] = field(
|
|
28
|
+
default_factory=lambda: (Exception,)
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def calculate_delay(
|
|
33
|
+
attempt: int,
|
|
34
|
+
config: RetryConfig,
|
|
35
|
+
) -> float:
|
|
36
|
+
"""Calculate delay for next retry with exponential backoff."""
|
|
37
|
+
delay = config.base_delay * (config.exponential_base ** (attempt - 1))
|
|
38
|
+
delay = min(delay, config.max_delay)
|
|
39
|
+
|
|
40
|
+
if config.jitter:
|
|
41
|
+
# Add random jitter (0-25% of delay)
|
|
42
|
+
delay = delay * (1 + random.uniform(0, 0.25))
|
|
43
|
+
|
|
44
|
+
return delay
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def retry_async(
|
|
48
|
+
func: Callable[P, Awaitable[T]],
|
|
49
|
+
*args: P.args,
|
|
50
|
+
config: RetryConfig | None = None,
|
|
51
|
+
**kwargs: P.kwargs,
|
|
52
|
+
) -> T:
|
|
53
|
+
"""
|
|
54
|
+
Execute an async function with retry logic.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
func: Async function to execute
|
|
58
|
+
*args: Positional arguments for func
|
|
59
|
+
config: Retry configuration (uses defaults if None)
|
|
60
|
+
**kwargs: Keyword arguments for func
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Result of func
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
Last exception if all retries fail
|
|
67
|
+
"""
|
|
68
|
+
if config is None:
|
|
69
|
+
config = RetryConfig()
|
|
70
|
+
|
|
71
|
+
last_exception: Exception | None = None
|
|
72
|
+
|
|
73
|
+
for attempt in range(1, config.max_attempts + 1):
|
|
74
|
+
try:
|
|
75
|
+
return await func(*args, **kwargs)
|
|
76
|
+
except config.retryable_exceptions as e:
|
|
77
|
+
last_exception = e
|
|
78
|
+
|
|
79
|
+
if attempt == config.max_attempts:
|
|
80
|
+
logger.warning(
|
|
81
|
+
f"Retry exhausted after {attempt} attempts: {e}"
|
|
82
|
+
)
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
delay = calculate_delay(attempt, config)
|
|
86
|
+
logger.debug(
|
|
87
|
+
f"Retry attempt {attempt}/{config.max_attempts} failed: {e}. "
|
|
88
|
+
f"Retrying in {delay:.2f}s"
|
|
89
|
+
)
|
|
90
|
+
await asyncio.sleep(delay)
|
|
91
|
+
|
|
92
|
+
if last_exception:
|
|
93
|
+
raise last_exception
|
|
94
|
+
|
|
95
|
+
# Should never reach here, but satisfy type checker
|
|
96
|
+
raise RuntimeError("Retry logic error")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def with_retry(
|
|
100
|
+
config: RetryConfig | None = None,
|
|
101
|
+
) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]:
|
|
102
|
+
"""
|
|
103
|
+
Decorator to add retry logic to async functions.
|
|
104
|
+
|
|
105
|
+
Usage:
|
|
106
|
+
@with_retry(RetryConfig(max_attempts=5))
|
|
107
|
+
async def my_flaky_function():
|
|
108
|
+
...
|
|
109
|
+
"""
|
|
110
|
+
def decorator(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
|
|
111
|
+
@functools.wraps(func)
|
|
112
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
113
|
+
return await retry_async(func, *args, config=config, **kwargs)
|
|
114
|
+
return wrapper
|
|
115
|
+
return decorator
|
dory/utils/timeout.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Timeout utilities for async operations.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import functools
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Callable, TypeVar, ParamSpec, Awaitable
|
|
9
|
+
|
|
10
|
+
from dory.utils.errors import DoryTimeoutError
|
|
11
|
+
|
|
12
|
+
P = ParamSpec("P")
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class TimeoutConfig:
|
|
18
|
+
"""Configuration for timeout behavior."""
|
|
19
|
+
|
|
20
|
+
timeout_seconds: float
|
|
21
|
+
error_message: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def timeout_async(
|
|
25
|
+
func: Callable[P, Awaitable[T]],
|
|
26
|
+
*args: P.args,
|
|
27
|
+
timeout_seconds: float,
|
|
28
|
+
error_message: str | None = None,
|
|
29
|
+
**kwargs: P.kwargs,
|
|
30
|
+
) -> T:
|
|
31
|
+
"""
|
|
32
|
+
Execute an async function with a timeout.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
func: Async function to execute
|
|
36
|
+
*args: Positional arguments for func
|
|
37
|
+
timeout_seconds: Maximum time to wait
|
|
38
|
+
error_message: Custom error message on timeout
|
|
39
|
+
**kwargs: Keyword arguments for func
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Result of func
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
DoryTimeoutError: If function doesn't complete within timeout
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
return await asyncio.wait_for(
|
|
49
|
+
func(*args, **kwargs),
|
|
50
|
+
timeout=timeout_seconds,
|
|
51
|
+
)
|
|
52
|
+
except asyncio.TimeoutError:
|
|
53
|
+
msg = error_message or f"Operation timed out after {timeout_seconds}s"
|
|
54
|
+
raise DoryTimeoutError(msg)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def with_timeout(
|
|
58
|
+
timeout_seconds: float,
|
|
59
|
+
error_message: str | None = None,
|
|
60
|
+
) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]:
|
|
61
|
+
"""
|
|
62
|
+
Decorator to add timeout to async functions.
|
|
63
|
+
|
|
64
|
+
Usage:
|
|
65
|
+
@with_timeout(30, "Startup took too long")
|
|
66
|
+
async def startup():
|
|
67
|
+
...
|
|
68
|
+
"""
|
|
69
|
+
def decorator(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
|
|
70
|
+
@functools.wraps(func)
|
|
71
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
72
|
+
return await timeout_async(
|
|
73
|
+
func,
|
|
74
|
+
*args,
|
|
75
|
+
timeout_seconds=timeout_seconds,
|
|
76
|
+
error_message=error_message,
|
|
77
|
+
**kwargs,
|
|
78
|
+
)
|
|
79
|
+
return wrapper
|
|
80
|
+
return decorator
|