tetra-rp 0.5.5__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.
@@ -0,0 +1,476 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ from typing import Any, Dict, List, Optional
5
+ from enum import Enum
6
+ from pydantic import (
7
+ field_serializer,
8
+ field_validator,
9
+ model_validator,
10
+ BaseModel,
11
+ Field,
12
+ )
13
+
14
+ from runpod.endpoint.runner import Job
15
+
16
+ from ..api.runpod import RunpodGraphQLClient
17
+ from ..utils.backoff import get_backoff_delay
18
+
19
+ from .cloud import runpod
20
+ from .base import DeployableResource
21
+ from .template import PodTemplate, KeyValuePair
22
+ from .gpu import GpuGroup
23
+ from .cpu import CpuInstanceType
24
+ from .environment import EnvironmentVars
25
+
26
+
27
+ # Environment variables are loaded from the .env file
28
+ def get_env_vars() -> Dict[str, str]:
29
+ """
30
+ Returns the environment variables from the .env file.
31
+ {
32
+ "KEY": "VALUE",
33
+ }
34
+ """
35
+ env_vars = EnvironmentVars()
36
+ return env_vars.get_env()
37
+
38
+
39
+ log = logging.getLogger(__name__)
40
+
41
+
42
+ CONSOLE_BASE_URL = os.environ.get("CONSOLE_BASE_URL", "https://console.runpod.io")
43
+ CONSOLE_URL = f"{CONSOLE_BASE_URL}/serverless/user/endpoint/%s"
44
+
45
+
46
+ class ServerlessScalerType(Enum):
47
+ QUEUE_DELAY = "QUEUE_DELAY"
48
+ REQUEST_COUNT = "REQUEST_COUNT"
49
+
50
+
51
+ class CudaVersion(Enum):
52
+ V11_8 = "11.8"
53
+ V12_0 = "12.0"
54
+ V12_1 = "12.1"
55
+ V12_2 = "12.2"
56
+ V12_3 = "12.3"
57
+ V12_4 = "12.4"
58
+ V12_5 = "12.5"
59
+ V12_6 = "12.6"
60
+ V12_7 = "12.7"
61
+ V12_8 = "12.8"
62
+
63
+
64
+ class ServerlessResource(DeployableResource):
65
+ """
66
+ Base class for GPU serverless resource
67
+ """
68
+
69
+ _input_only = {"id", "cudaVersions", "env", "gpus", "flashboot", "imageName"}
70
+
71
+ # === Input-only Fields ===
72
+ cudaVersions: Optional[List[CudaVersion]] = [] # for allowedCudaVersions
73
+ env: Optional[Dict[str, str]] = Field(default_factory=get_env_vars)
74
+ flashboot: Optional[bool] = True
75
+ gpus: Optional[List[GpuGroup]] = [GpuGroup.ANY] # for gpuIds
76
+ imageName: Optional[str] = "" # for template.imageName
77
+
78
+ # === Input Fields ===
79
+ executionTimeoutMs: Optional[int] = None
80
+ gpuCount: Optional[int] = 1
81
+ idleTimeout: Optional[int] = 5
82
+ instanceIds: Optional[List[CpuInstanceType]] = None
83
+ locations: Optional[str] = None
84
+ name: str
85
+ networkVolumeId: Optional[str] = None
86
+ scalerType: Optional[ServerlessScalerType] = ServerlessScalerType.QUEUE_DELAY
87
+ scalerValue: Optional[int] = 4
88
+ templateId: Optional[str] = None
89
+ workersMax: Optional[int] = 3
90
+ workersMin: Optional[int] = 0
91
+ workersPFBTarget: Optional[int] = None
92
+
93
+ # === Runtime Fields ===
94
+ activeBuildid: Optional[str] = None
95
+ aiKey: Optional[str] = None
96
+ allowedCudaVersions: Optional[str] = None
97
+ computeType: Optional[str] = None
98
+ createdAt: Optional[str] = None # TODO: use datetime
99
+ gpuIds: Optional[str] = ""
100
+ hubRelease: Optional[str] = None
101
+ repo: Optional[str] = None
102
+ template: Optional[PodTemplate] = None
103
+ userId: Optional[str] = None
104
+
105
+ def __str__(self) -> str:
106
+ return f"{self.__class__.__name__}:{self.id}"
107
+
108
+ @property
109
+ def url(self) -> str:
110
+ if not self.id:
111
+ raise ValueError("Missing self.id")
112
+ return CONSOLE_URL % self.id
113
+
114
+ @property
115
+ def endpoint(self) -> runpod.Endpoint:
116
+ """
117
+ Returns the Runpod endpoint object for this serverless resource.
118
+ """
119
+ if not self.id:
120
+ raise ValueError("Missing self.id")
121
+ return runpod.Endpoint(self.id)
122
+
123
+ @field_serializer("scalerType")
124
+ def serialize_scaler_type(
125
+ self, value: Optional[ServerlessScalerType]
126
+ ) -> Optional[str]:
127
+ """Convert ServerlessScalerType enum to string."""
128
+ return value.value if value is not None else None
129
+
130
+ @field_serializer("instanceIds")
131
+ def serialize_instance_ids(self, value: List[CpuInstanceType]) -> List[str]:
132
+ """Convert CpuInstanceType enums to strings."""
133
+ return [item.value if hasattr(item, "value") else str(item) for item in value]
134
+
135
+ @field_validator("gpus")
136
+ @classmethod
137
+ def validate_gpus(cls, value: List[GpuGroup]) -> List[GpuGroup]:
138
+ """Expand ANY to all GPU groups"""
139
+ if value == [GpuGroup.ANY]:
140
+ return GpuGroup.all()
141
+ return value
142
+
143
+ @model_validator(mode="after")
144
+ def sync_input_fields(self):
145
+ """Sync between temporary inputs and exported fields"""
146
+ if self.flashboot:
147
+ self.name += "-fb"
148
+
149
+ if self.instanceIds:
150
+ return self._sync_input_fields_cpu()
151
+ else:
152
+ return self._sync_input_fields_gpu()
153
+
154
+ def _sync_input_fields_gpu(self):
155
+ # GPU-specific fields
156
+ if self.gpus:
157
+ # Convert gpus list to gpuIds string
158
+ self.gpuIds = ",".join(gpu.value for gpu in self.gpus)
159
+ elif self.gpuIds:
160
+ # Convert gpuIds string to gpus list (from backend responses)
161
+ gpu_values = [v.strip() for v in self.gpuIds.split(",") if v.strip()]
162
+ self.gpus = [GpuGroup(value) for value in gpu_values]
163
+
164
+ if self.cudaVersions:
165
+ # Convert cudaVersions list to allowedCudaVersions string
166
+ self.allowedCudaVersions = ",".join(v.value for v in self.cudaVersions)
167
+ elif self.allowedCudaVersions:
168
+ # Convert allowedCudaVersions string to cudaVersions list (from backend responses)
169
+ version_values = [
170
+ v.strip() for v in self.allowedCudaVersions.split(",") if v.strip()
171
+ ]
172
+ self.cudaVersions = [CudaVersion(value) for value in version_values]
173
+
174
+ return self
175
+
176
+ def _sync_input_fields_cpu(self):
177
+ # Override GPU-specific fields for CPU
178
+ self.gpuCount = 0
179
+ self.allowedCudaVersions = ""
180
+ self.gpuIds = ""
181
+
182
+ return self
183
+
184
+ def is_deployed(self) -> bool:
185
+ """
186
+ Checks if the serverless resource is deployed and available.
187
+ """
188
+ try:
189
+ if not self.id:
190
+ return False
191
+
192
+ response = self.endpoint.health()
193
+ return response is not None
194
+ except Exception as e:
195
+ log.error(f"Error checking {self}: {e}")
196
+ return False
197
+
198
+ async def deploy(self) -> "DeployableResource":
199
+ """
200
+ Deploys the serverless resource using the provided configuration.
201
+ Returns a DeployableResource object.
202
+ """
203
+ try:
204
+ # If the resource is already deployed, return it
205
+ if self.is_deployed():
206
+ log.debug(f"{self} exists")
207
+ return self
208
+
209
+ async with RunpodGraphQLClient() as client:
210
+ payload = self.model_dump(exclude=self._input_only, exclude_none=True)
211
+ result = await client.create_endpoint(payload)
212
+
213
+ if endpoint := self.__class__(**result):
214
+ return endpoint
215
+
216
+ raise ValueError("Deployment failed, no endpoint was returned.")
217
+
218
+ except Exception as e:
219
+ log.error(f"{self} failed to deploy: {e}")
220
+ raise
221
+
222
+ async def is_ready_for_requests(self, give_up_threshold=10) -> bool:
223
+ """
224
+ Asynchronously checks if the serverless resource is ready to handle
225
+ requests by polling its health endpoint.
226
+
227
+ Args:
228
+ give_up_threshold (int, optional): The maximum number of polling
229
+ attempts before giving up and raising an error. Defaults to 10.
230
+
231
+ Returns:
232
+ bool: True if the serverless resource is ready for requests.
233
+
234
+ Raises:
235
+ ValueError: If the serverless resource is not deployed.
236
+ RuntimeError: If the health status is THROTTLED, UNHEALTHY, or UNKNOWN
237
+ after exceeding the give_up_threshold.
238
+ """
239
+ if not self.is_deployed():
240
+ raise ValueError("Serverless is not deployed")
241
+
242
+ log.debug(f"{self} | API /health")
243
+
244
+ current_pace = 0
245
+ attempt = 0
246
+
247
+ # Poll for health status
248
+ while True:
249
+ await asyncio.sleep(current_pace)
250
+
251
+ health = await asyncio.to_thread(self.endpoint.health)
252
+ health = ServerlessHealth(**health)
253
+
254
+ if health.is_ready:
255
+ return True
256
+ else:
257
+ # nothing changed, increase the gap
258
+ attempt += 1
259
+ indicator = "." * (attempt // 2) if attempt % 2 == 0 else ""
260
+ if indicator:
261
+ log.info(f"{self} | {indicator}")
262
+
263
+ status = health.workers.status
264
+ if status in [
265
+ Status.THROTTLED,
266
+ Status.UNHEALTHY,
267
+ Status.UNKNOWN,
268
+ ]:
269
+ log.debug(f"{self} | Health {status.value}")
270
+
271
+ if attempt >= give_up_threshold:
272
+ # Give up
273
+ raise RuntimeError(f"Health {status.value}")
274
+
275
+ # Adjust polling pace appropriately
276
+ current_pace = get_backoff_delay(attempt)
277
+
278
+ async def run_sync(self, payload: Dict[str, Any]) -> "JobOutput":
279
+ """
280
+ Executes a serverless endpoint request with the payload.
281
+ Returns a JobOutput object.
282
+ """
283
+ if not self.id:
284
+ raise ValueError("Serverless is not deployed")
285
+
286
+ def _fetch_job():
287
+ return self.endpoint.rp_client.post(
288
+ f"{self.id}/runsync", payload, timeout=60
289
+ )
290
+
291
+ try:
292
+ # log.debug(f"[{log_group}] Payload: {payload}")
293
+
294
+ # Poll until requests can be sent
295
+ await self.is_ready_for_requests()
296
+
297
+ log.info(f"{self} | API /run_sync")
298
+ response = await asyncio.to_thread(_fetch_job)
299
+ return JobOutput(**response)
300
+
301
+ except Exception as e:
302
+ health = await asyncio.to_thread(self.endpoint.health)
303
+ health = ServerlessHealth(**health)
304
+ log.info(f"{self} | Health {health.workers.status}")
305
+ log.error(f"{self} | Exception: {e}")
306
+ raise
307
+
308
+ async def run(self, payload: Dict[str, Any]) -> "JobOutput":
309
+ """
310
+ Executes a serverless endpoint async request with the payload.
311
+ Returns a JobOutput object.
312
+ """
313
+ if not self.id:
314
+ raise ValueError("Serverless is not deployed")
315
+
316
+ job: Optional[Job] = None
317
+
318
+ try:
319
+ # log.debug(f"[{self}] Payload: {payload}")
320
+
321
+ # Poll until requests can be sent
322
+ await self.is_ready_for_requests()
323
+
324
+ # Create a job using the endpoint
325
+ log.info(f"{self} | API /run")
326
+ job = await asyncio.to_thread(self.endpoint.run, request_input=payload)
327
+
328
+ log_subgroup = f"Job:{job.job_id}"
329
+
330
+ log.info(f"{self} | Started {log_subgroup}")
331
+
332
+ current_pace = 0
333
+ attempt = 0
334
+ job_status = Status.UNKNOWN
335
+ last_status = job_status
336
+
337
+ # Poll for job status
338
+ while True:
339
+ await asyncio.sleep(current_pace)
340
+
341
+ if await self.is_ready_for_requests():
342
+ # Check job status
343
+ job_status = await asyncio.to_thread(job.status)
344
+
345
+ if last_status == job_status:
346
+ # nothing changed, increase the gap
347
+ attempt += 1
348
+ indicator = "." * (attempt // 2) if attempt % 2 == 0 else ""
349
+ if indicator:
350
+ log.info(f"{log_subgroup} | {indicator}")
351
+ else:
352
+ # status changed, reset the gap
353
+ log.info(f"{log_subgroup} | Status: {job_status}")
354
+ attempt = 0
355
+
356
+ last_status = job_status
357
+
358
+ # Adjust polling pace appropriately
359
+ current_pace = get_backoff_delay(attempt)
360
+
361
+ if job_status in ("COMPLETED", "FAILED", "CANCELLED"):
362
+ response = await asyncio.to_thread(job._fetch_job)
363
+ return JobOutput(**response)
364
+
365
+ except Exception as e:
366
+ if job and job.job_id:
367
+ log.info(f"{self} | Cancelling job {job.job_id}")
368
+ await asyncio.to_thread(job.cancel)
369
+
370
+ log.error(f"{self} | Exception: {e}")
371
+ raise
372
+
373
+
374
+ class ServerlessEndpoint(ServerlessResource):
375
+ """
376
+ Represents a serverless endpoint distinct from a live serverless.
377
+ Inherits from ServerlessResource.
378
+ """
379
+
380
+ @model_validator(mode="after")
381
+ def set_serverless_template(self):
382
+ if not any([self.imageName, self.template, self.templateId]):
383
+ raise ValueError(
384
+ "Either imageName, template, or templateId must be provided"
385
+ )
386
+
387
+ if not self.templateId and not self.template:
388
+ self.template = PodTemplate(
389
+ name=self.resource_id,
390
+ imageName=self.imageName,
391
+ env=KeyValuePair.from_dict(self.env or get_env_vars()),
392
+ )
393
+
394
+ elif self.template:
395
+ self.template.name = f"{self.resource_id}__{self.template.resource_id}"
396
+ if self.imageName:
397
+ self.template.imageName = self.imageName
398
+ if self.env:
399
+ self.template.env = KeyValuePair.from_dict(self.env)
400
+
401
+ return self
402
+
403
+
404
+ class CpuServerlessEndpoint(ServerlessEndpoint):
405
+ """
406
+ Convenience class for CPU serverless endpoint.
407
+ Represents a CPU-only serverless endpoint distinct from a live serverless.
408
+ Inherits from ServerlessEndpoint.
409
+ """
410
+
411
+ instanceIds: Optional[List[CpuInstanceType]] = [CpuInstanceType.CPU3G_2_8]
412
+
413
+
414
+ class JobOutput(BaseModel):
415
+ id: str
416
+ workerId: str
417
+ status: str
418
+ delayTime: int
419
+ executionTime: int
420
+ output: Optional[Any] = None
421
+ error: Optional[str] = ""
422
+
423
+ def model_post_init(self, __context):
424
+ log_group = f"Worker:{self.workerId}"
425
+ log.info(f"{log_group} | Delay Time: {self.delayTime} ms")
426
+ log.info(f"{log_group} | Execution Time: {self.executionTime} ms")
427
+
428
+
429
+ class Status(str, Enum):
430
+ READY = "READY"
431
+ INITIALIZING = "INITIALIZING"
432
+ THROTTLED = "THROTTLED"
433
+ UNHEALTHY = "UNHEALTHY"
434
+ UNKNOWN = "UNKNOWN"
435
+
436
+
437
+ class WorkersHealth(BaseModel):
438
+ idle: int
439
+ initializing: int
440
+ ready: int
441
+ running: int
442
+ throttled: int
443
+ unhealthy: int
444
+
445
+ @property
446
+ def status(self) -> Status:
447
+ if self.ready or self.idle or self.running:
448
+ return Status.READY
449
+
450
+ if self.initializing:
451
+ return Status.INITIALIZING
452
+
453
+ if self.throttled:
454
+ return Status.THROTTLED
455
+
456
+ if self.unhealthy:
457
+ return Status.UNHEALTHY
458
+
459
+ return Status.UNKNOWN
460
+
461
+
462
+ class JobsHealth(BaseModel):
463
+ completed: int
464
+ failed: int
465
+ inProgress: int
466
+ inQueue: int
467
+ retried: int
468
+
469
+
470
+ class ServerlessHealth(BaseModel):
471
+ workers: WorkersHealth
472
+ jobs: JobsHealth
473
+
474
+ @property
475
+ def is_ready(self) -> bool:
476
+ return self.workers.status == Status.READY
@@ -0,0 +1,94 @@
1
+ import requests
2
+ from typing import Dict, List, Optional, Any
3
+ from pydantic import BaseModel, model_validator
4
+ from .base import BaseResource
5
+
6
+
7
+ class KeyValuePair(BaseModel):
8
+ key: str
9
+ value: str
10
+
11
+ @classmethod
12
+ def from_dict(cls, data: Dict[str, str]) -> "List[KeyValuePair]":
13
+ """
14
+ Create a list of KeyValuePair instances from a dictionary.
15
+ """
16
+ if not isinstance(data, dict):
17
+ raise ValueError("Input must be a dictionary.")
18
+
19
+ return [cls(key=key, value=value) for key, value in data.items()]
20
+
21
+
22
+ class PodTemplate(BaseResource):
23
+ advancedStart: Optional[bool] = False
24
+ config: Optional[Dict[str, Any]] = {}
25
+ containerDiskInGb: Optional[int] = 10
26
+ containerRegistryAuthId: Optional[str] = ""
27
+ dockerArgs: Optional[str] = ""
28
+ env: Optional[List[KeyValuePair]] = []
29
+ imageName: Optional[str] = ""
30
+ name: Optional[str] = ""
31
+ ports: Optional[str] = ""
32
+ startScript: Optional[str] = ""
33
+
34
+ @model_validator(mode="after")
35
+ def sync_input_fields(self):
36
+ self.name = f"{self.name}__{self.resource_id}"
37
+ return self
38
+
39
+
40
+ def update_system_dependencies(
41
+ template_id, token, system_dependencies, base_entry_cmd=None
42
+ ):
43
+ """
44
+ Updates Runpod template with system dependencies installed via apt-get,
45
+ and appends the app start command.
46
+
47
+ Args:
48
+ template_id (str): Runpod template ID.
49
+ token (str): Runpod API token.
50
+ system_dependencies (List[str]): List of apt packages to install.
51
+ base_entry_cmd (List[str]): The default command to run the app, e.g. ["uv", "run", "handler.py"]
52
+ Returns:
53
+ dict: API response JSON or error info.
54
+ """
55
+
56
+ # Compose apt-get install command if any packages specified
57
+ apt_cmd = ""
58
+ if system_dependencies:
59
+ joined_pkgs = " ".join(system_dependencies)
60
+ apt_cmd = f"apt-get update && apt-get install -y {joined_pkgs} && "
61
+
62
+ # Default start command if not provided
63
+ app_cmd = base_entry_cmd or ["uv", "run", "handler.py"]
64
+ app_cmd_str = " ".join(app_cmd)
65
+
66
+ # Full command to run in entrypoint shell
67
+ full_cmd = f"{apt_cmd}exec {app_cmd_str}"
68
+
69
+ payload = {
70
+ # other required fields like disk, env, image, etc, should be fetched or passed in real usage
71
+ "dockerEntrypoint": ["/bin/bash", "-c", full_cmd],
72
+ "dockerStartCmd": [],
73
+ # placeholder values, replace as needed or fetch from current template state
74
+ "containerDiskInGb": 50,
75
+ "containerRegistryAuthId": "",
76
+ "env": {},
77
+ "imageName": "your-image-name",
78
+ "isPublic": False,
79
+ "name": "your-template-name",
80
+ "ports": ["8888/http", "22/tcp"],
81
+ "readme": "",
82
+ "volumeInGb": 20,
83
+ "volumeMountPath": "/workspace",
84
+ }
85
+
86
+ headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
87
+
88
+ url = f"https://rest.runpod.io/v1/templates/{template_id}/update"
89
+ response = requests.post(url, json=payload, headers=headers)
90
+
91
+ try:
92
+ return response.json()
93
+ except Exception:
94
+ return {"error": "Invalid JSON response", "text": response.text}
@@ -0,0 +1,50 @@
1
+ from typing import Callable, Any, List, Union
2
+ from pydantic import BaseModel
3
+ from .gpu import GpuType, GpuTypeDetail
4
+ from .serverless import ServerlessEndpoint
5
+
6
+
7
+ """
8
+ Define the mapping for the methods and their return types
9
+ Only include methods from runpod.*
10
+ """
11
+ RUNPOD_TYPED_OPERATIONS = {
12
+ "get_gpus": List[GpuType],
13
+ "get_gpu": GpuTypeDetail,
14
+ "get_endpoints": List[ServerlessEndpoint],
15
+ }
16
+
17
+
18
+ def inquire(method: Callable, *args, **kwargs) -> Union[List[Any], Any]:
19
+ """
20
+ This function dynamically determines the return type of the provided method
21
+ based on a predefined mapping (`definitions`) and validates the result using
22
+ Pydantic models if applicable.
23
+
24
+ Refer to `RUNPOD_TYPED_OPERATIONS` for the mapping.
25
+
26
+ Example:
27
+ ----------
28
+ >>> import runpod
29
+ >>> inquire(runpod.get_gpus)
30
+ [
31
+ GpuType(id='NVIDIA A100 80GB', displayName='A100 80GB', memoryInGb=80),
32
+ GpuType(id='NVIDIA A100 40GB', displayName='A100 40GB', memoryInGb=40),
33
+ GpuType(id='NVIDIA A10', displayName='A10', memoryInGb=24)
34
+ ]
35
+ """
36
+ method_name = method.__name__
37
+ return_type = RUNPOD_TYPED_OPERATIONS.get(method_name)
38
+
39
+ raw_result = method(*args, **kwargs)
40
+
41
+ if hasattr(return_type, "__origin__") and return_type.__origin__ is list:
42
+ # List case
43
+ model_type = return_type.__args__[0]
44
+ if issubclass(model_type, BaseModel):
45
+ return [model_type.model_validate(item) for item in raw_result]
46
+ elif isinstance(return_type, type) and issubclass(return_type, BaseModel):
47
+ # Single object case
48
+ return return_type.model_validate(raw_result)
49
+ else:
50
+ raise ValueError(f"Unsupported return type for method '{method_name}'")
File without changes
@@ -0,0 +1,43 @@
1
+ import math
2
+ import random
3
+ from enum import Enum
4
+
5
+
6
+ class BackoffStrategy(str, Enum):
7
+ EXPONENTIAL = "exponential"
8
+ LINEAR = "linear"
9
+ LOGARITHMIC = "logarithmic"
10
+
11
+
12
+ def get_backoff_delay(
13
+ attempt: int,
14
+ base: float = 0.1,
15
+ max_seconds: float = 10.0,
16
+ jitter: float = 0.2,
17
+ strategy: BackoffStrategy = BackoffStrategy.EXPONENTIAL,
18
+ ) -> float:
19
+ """
20
+ Returns a backoff delay in seconds based on the number of attempts and strategy.
21
+
22
+ Parameters:
23
+ - attempt (int): The number of failed attempts or polls.
24
+ - base (float): The base delay time in seconds.
25
+ - max_seconds (float): The maximum delay.
26
+ - jitter (float): Random jitter as a fraction (e.g., 0.2 = ±20%). Prevent thundering herd
27
+ - strategy (BackoffStrategy): The backoff curve to apply.
28
+
29
+ Returns:
30
+ - float: The delay in seconds.
31
+ """
32
+ if strategy == BackoffStrategy.EXPONENTIAL:
33
+ delay = base * (2**attempt)
34
+ elif strategy == BackoffStrategy.LINEAR:
35
+ delay = base + (attempt * base)
36
+ elif strategy == BackoffStrategy.LOGARITHMIC:
37
+ delay = base * math.log2(attempt + 2)
38
+ else:
39
+ raise ValueError(f"Unsupported backoff strategy: {strategy}")
40
+
41
+ # Clamp to max and apply jitter
42
+ delay = min(delay, max_seconds)
43
+ return delay * random.uniform(1 - jitter, 1 + jitter)
@@ -0,0 +1,33 @@
1
+ from enum import Enum
2
+ from typing import Any
3
+ from pydantic import BaseModel
4
+
5
+
6
+ def normalize_for_json(obj: Any) -> Any:
7
+ """
8
+ Recursively normalizes an object for JSON serialization.
9
+
10
+ This function handles various data types and ensures that objects
11
+ are converted into JSON-serializable formats. It supports the following:
12
+ - `BaseModel` instances: Converts them to dictionaries using `model_dump()`.
13
+ - Dictionaries: Recursively normalizes their values.
14
+ - Lists: Recursively normalizes their elements.
15
+ - Tuples: Recursively normalizes their elements and returns a tuple.
16
+ - Other types: Returns the object as is.
17
+
18
+ Args:
19
+ obj (Any): The object to normalize.
20
+
21
+ Returns:
22
+ Any: A JSON-serializable representation of the input object.
23
+ """
24
+ if isinstance(obj, BaseModel):
25
+ return normalize_for_json(obj.model_dump())
26
+ elif isinstance(obj, Enum):
27
+ return obj.value
28
+ elif isinstance(obj, dict):
29
+ return {k: normalize_for_json(v) for k, v in obj.items()}
30
+ elif isinstance(obj, (list, tuple)):
31
+ return type(obj)(normalize_for_json(i) for i in obj)
32
+ else:
33
+ return obj