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.
Files changed (86) hide show
  1. dory/__init__.py +101 -0
  2. dory/auth/__init__.py +10 -0
  3. dory/auth/oauth2.py +153 -0
  4. dory/auto_instrument.py +142 -0
  5. dory/cli/__init__.py +5 -0
  6. dory/cli/main.py +137 -0
  7. dory/cli/templates.py +123 -0
  8. dory/config/__init__.py +23 -0
  9. dory/config/defaults.py +24 -0
  10. dory/config/loader.py +430 -0
  11. dory/config/presets.py +73 -0
  12. dory/config/schema.py +84 -0
  13. dory/core/__init__.py +27 -0
  14. dory/core/app.py +434 -0
  15. dory/core/context.py +209 -0
  16. dory/core/lifecycle.py +214 -0
  17. dory/core/meta.py +121 -0
  18. dory/core/modes.py +479 -0
  19. dory/core/processor.py +564 -0
  20. dory/core/signals.py +122 -0
  21. dory/decorators.py +142 -0
  22. dory/edge/__init__.py +88 -0
  23. dory/edge/adaptive.py +644 -0
  24. dory/edge/detector.py +546 -0
  25. dory/edge/fencing.py +488 -0
  26. dory/edge/heartbeat.py +598 -0
  27. dory/edge/role.py +419 -0
  28. dory/errors/__init__.py +139 -0
  29. dory/errors/classification.py +362 -0
  30. dory/errors/codes.py +498 -0
  31. dory/geo/__init__.py +40 -0
  32. dory/geo/geolocalizer.py +1034 -0
  33. dory/health/__init__.py +12 -0
  34. dory/health/probes.py +210 -0
  35. dory/health/server.py +635 -0
  36. dory/k8s/__init__.py +80 -0
  37. dory/k8s/annotation_watcher.py +184 -0
  38. dory/k8s/client.py +251 -0
  39. dory/k8s/labels.py +505 -0
  40. dory/k8s/pod_metadata.py +182 -0
  41. dory/logging/__init__.py +9 -0
  42. dory/logging/logger.py +148 -0
  43. dory/metrics/__init__.py +7 -0
  44. dory/metrics/collector.py +301 -0
  45. dory/middleware/__init__.py +46 -0
  46. dory/middleware/connection_tracker.py +608 -0
  47. dory/middleware/request_id.py +325 -0
  48. dory/middleware/request_tracker.py +511 -0
  49. dory/migration/__init__.py +33 -0
  50. dory/migration/configmap.py +232 -0
  51. dory/migration/s3_store.py +594 -0
  52. dory/migration/serialization.py +135 -0
  53. dory/migration/state_manager.py +286 -0
  54. dory/migration/transfer.py +382 -0
  55. dory/monitoring/__init__.py +29 -0
  56. dory/monitoring/opentelemetry.py +489 -0
  57. dory/output/__init__.py +31 -0
  58. dory/output/envelope.py +137 -0
  59. dory/output/formatter.py +113 -0
  60. dory/output/rabbitmq.py +632 -0
  61. dory/output/routing.py +318 -0
  62. dory/output/validator.py +199 -0
  63. dory/py.typed +2 -0
  64. dory/recovery/__init__.py +60 -0
  65. dory/recovery/golden_image.py +487 -0
  66. dory/recovery/golden_snapshot.py +713 -0
  67. dory/recovery/golden_validator.py +518 -0
  68. dory/recovery/partial_recovery.py +482 -0
  69. dory/recovery/recovery_decision.py +242 -0
  70. dory/recovery/restart_detector.py +142 -0
  71. dory/recovery/state_validator.py +183 -0
  72. dory/resilience/__init__.py +45 -0
  73. dory/resilience/circuit_breaker.py +457 -0
  74. dory/resilience/retry.py +389 -0
  75. dory/simple.py +342 -0
  76. dory/types.py +68 -0
  77. dory/utils/__init__.py +31 -0
  78. dory/utils/errors.py +59 -0
  79. dory/utils/retry.py +115 -0
  80. dory/utils/timeout.py +80 -0
  81. dory_processor_sdk-0.0.1.dist-info/METADATA +424 -0
  82. dory_processor_sdk-0.0.1.dist-info/RECORD +86 -0
  83. dory_processor_sdk-0.0.1.dist-info/WHEEL +5 -0
  84. dory_processor_sdk-0.0.1.dist-info/entry_points.txt +2 -0
  85. dory_processor_sdk-0.0.1.dist-info/licenses/LICENSE +201 -0
  86. 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