spring-ready-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,521 @@
1
+ """
2
+ FastAPI integration for Spring-Ready Python.
3
+ Adds actuator endpoints and integrates with Eureka/Config Server.
4
+ """
5
+
6
+ import logging
7
+ from typing import Optional
8
+ from fastapi import FastAPI, Response, Request, Body
9
+ from fastapi.responses import JSONResponse, PlainTextResponse
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+
12
+ from ..actuator import (
13
+ HealthEndpoint,
14
+ InfoEndpoint,
15
+ PrometheusEndpoint,
16
+ ActuatorDiscoveryEndpoint,
17
+ MetricsEndpoint,
18
+ EnvEndpoint,
19
+ LoggersEndpoint,
20
+ MappingsEndpoint,
21
+ ThreadDumpEndpoint,
22
+ HttpTraceEndpoint,
23
+ HttpExchangesEndpoint,
24
+ LogfileEndpoint,
25
+ RefreshEndpoint,
26
+ BeansEndpoint,
27
+ ConfigPropsEndpoint,
28
+ ScheduledTasksEndpoint,
29
+ HeapdumpEndpoint,
30
+ CachesEndpoint,
31
+ AuditEventsEndpoint,
32
+ create_default_health_endpoint,
33
+ create_default_info_endpoint,
34
+ create_default_prometheus_endpoint,
35
+ create_default_discovery_endpoint,
36
+ create_default_metrics_endpoint,
37
+ create_default_env_endpoint,
38
+ create_default_loggers_endpoint,
39
+ create_default_mappings_endpoint,
40
+ create_default_threaddump_endpoint,
41
+ create_default_httptrace_endpoint,
42
+ create_default_httpexchanges_endpoint,
43
+ create_default_logfile_endpoint,
44
+ create_default_refresh_endpoint,
45
+ create_default_beans_endpoint,
46
+ create_default_configprops_endpoint,
47
+ create_default_scheduledtasks_endpoint,
48
+ create_default_heapdump_endpoint,
49
+ create_default_caches_endpoint,
50
+ create_default_auditevents_endpoint
51
+ )
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+
56
+ class FastAPIActuatorIntegration:
57
+ """
58
+ Integrates Spring Boot Actuator endpoints with FastAPI.
59
+
60
+ Adds the following endpoints:
61
+ - GET /actuator - Discovery endpoint with HAL JSON
62
+ - GET /actuator/health - Health status
63
+ - GET /actuator/info - Application information
64
+ - GET /actuator/prometheus - Prometheus metrics
65
+ - GET /actuator/metrics - Metrics list
66
+ - GET /actuator/metrics/{name} - Individual metric
67
+ - GET /actuator/env - Environment variables
68
+ - GET /actuator/env/{property} - Single property
69
+ - GET /actuator/loggers - All loggers
70
+ - GET /actuator/loggers/{name} - Single logger
71
+ - POST /actuator/loggers/{name} - Set logger level
72
+ - GET /actuator/mappings - Request mappings
73
+ - GET /actuator/threaddump - Thread dump
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ app: FastAPI,
79
+ base_url: str,
80
+ health_endpoint: Optional[HealthEndpoint] = None,
81
+ info_endpoint: Optional[InfoEndpoint] = None,
82
+ prometheus_endpoint: Optional[PrometheusEndpoint] = None,
83
+ discovery_endpoint: Optional[ActuatorDiscoveryEndpoint] = None,
84
+ metrics_endpoint: Optional[MetricsEndpoint] = None,
85
+ env_endpoint: Optional[EnvEndpoint] = None,
86
+ loggers_endpoint: Optional[LoggersEndpoint] = None,
87
+ mappings_endpoint: Optional[MappingsEndpoint] = None,
88
+ threaddump_endpoint: Optional[ThreadDumpEndpoint] = None,
89
+ httptrace_endpoint: Optional[HttpTraceEndpoint] = None,
90
+ httpexchanges_endpoint: Optional[HttpExchangesEndpoint] = None,
91
+ logfile_endpoint: Optional[LogfileEndpoint] = None,
92
+ refresh_endpoint: Optional[RefreshEndpoint] = None,
93
+ beans_endpoint: Optional[BeansEndpoint] = None,
94
+ configprops_endpoint: Optional[ConfigPropsEndpoint] = None,
95
+ scheduledtasks_endpoint: Optional[ScheduledTasksEndpoint] = None,
96
+ heapdump_endpoint: Optional[HeapdumpEndpoint] = None,
97
+ caches_endpoint: Optional[CachesEndpoint] = None,
98
+ auditevents_endpoint: Optional[AuditEventsEndpoint] = None,
99
+ enable_cors: bool = True
100
+ ):
101
+ """
102
+ Args:
103
+ app: FastAPI application
104
+ base_url: Base URL for the application (e.g., "http://localhost:8080")
105
+ health_endpoint: Health endpoint (creates default if None)
106
+ info_endpoint: Info endpoint (creates default if None)
107
+ prometheus_endpoint: Prometheus endpoint (creates default if None)
108
+ discovery_endpoint: Discovery endpoint (creates default if None)
109
+ metrics_endpoint: Metrics endpoint (creates default if None)
110
+ env_endpoint: Environment endpoint (creates default if None)
111
+ loggers_endpoint: Loggers endpoint (creates default if None)
112
+ mappings_endpoint: Mappings endpoint (creates default if None)
113
+ threaddump_endpoint: Thread dump endpoint (creates default if None)
114
+ httptrace_endpoint: HTTP trace endpoint (creates default if None)
115
+ httpexchanges_endpoint: HTTP exchanges endpoint (creates default if None)
116
+ logfile_endpoint: Logfile endpoint (creates default if None)
117
+ refresh_endpoint: Refresh endpoint (creates default if None)
118
+ enable_cors: Whether to enable CORS for actuator endpoints (default: True)
119
+ """
120
+ self.app = app
121
+ self.base_url = base_url
122
+ self.health_endpoint = health_endpoint or create_default_health_endpoint()
123
+ self.info_endpoint = info_endpoint or create_default_info_endpoint()
124
+ self.prometheus_endpoint = prometheus_endpoint or create_default_prometheus_endpoint()
125
+ self.discovery_endpoint = discovery_endpoint or create_default_discovery_endpoint(base_url)
126
+ self.metrics_endpoint = metrics_endpoint or create_default_metrics_endpoint()
127
+ self.env_endpoint = env_endpoint or create_default_env_endpoint()
128
+ self.loggers_endpoint = loggers_endpoint or create_default_loggers_endpoint()
129
+ self.mappings_endpoint = mappings_endpoint or create_default_mappings_endpoint(app)
130
+ self.threaddump_endpoint = threaddump_endpoint or create_default_threaddump_endpoint()
131
+ self.httptrace_endpoint = httptrace_endpoint or create_default_httptrace_endpoint()
132
+ self.httpexchanges_endpoint = httpexchanges_endpoint or create_default_httpexchanges_endpoint()
133
+ self.logfile_endpoint = logfile_endpoint or create_default_logfile_endpoint()
134
+ self.refresh_endpoint = refresh_endpoint or create_default_refresh_endpoint()
135
+ self.beans_endpoint = beans_endpoint or create_default_beans_endpoint(app)
136
+ self.configprops_endpoint = configprops_endpoint or create_default_configprops_endpoint()
137
+ self.scheduledtasks_endpoint = scheduledtasks_endpoint or create_default_scheduledtasks_endpoint()
138
+ self.heapdump_endpoint = heapdump_endpoint or create_default_heapdump_endpoint()
139
+ self.caches_endpoint = caches_endpoint or create_default_caches_endpoint()
140
+ self.auditevents_endpoint = auditevents_endpoint or create_default_auditevents_endpoint()
141
+
142
+ # NOTE: Middleware is now added in core.py during __init__ (before app starts)
143
+ # This avoids the "Cannot add middleware after startup" error
144
+
145
+ if enable_cors:
146
+ self._enable_cors()
147
+
148
+ self._register_endpoints()
149
+
150
+ def _enable_cors(self) -> None:
151
+ """Enable CORS for actuator endpoints"""
152
+ self.app.add_middleware(
153
+ CORSMiddleware,
154
+ allow_origins=["*"], # Allow all origins for Spring Boot Admin compatibility
155
+ allow_credentials=True,
156
+ allow_methods=["GET", "POST", "OPTIONS", "DELETE"],
157
+ allow_headers=["*"],
158
+ )
159
+ logger.info("CORS enabled for actuator endpoints")
160
+
161
+ def _add_options_handler(self, path: str) -> None:
162
+ """Add OPTIONS handler for a specific path to support CORS preflight"""
163
+ @self.app.options(path, tags=["actuator"], include_in_schema=False)
164
+ async def options_handler():
165
+ """Handle OPTIONS preflight request"""
166
+ return Response(status_code=200)
167
+
168
+ def _register_endpoints(self) -> None:
169
+ """Register actuator endpoints with FastAPI"""
170
+
171
+ # Discovery endpoint - lists all available actuator endpoints
172
+ @self.app.get("/actuator", tags=["actuator"], include_in_schema=False)
173
+ async def actuator_discovery():
174
+ """Actuator discovery endpoint with HAL JSON"""
175
+ return JSONResponse(content=self.discovery_endpoint.to_dict())
176
+
177
+ self._add_options_handler("/actuator")
178
+
179
+ # Health endpoint
180
+ @self.app.get("/actuator/health", tags=["actuator"], include_in_schema=False)
181
+ async def health():
182
+ """Health check endpoint"""
183
+ health_data = self.health_endpoint.check()
184
+ status_code = 503 if health_data["status"] == "DOWN" else 200
185
+ return JSONResponse(content=health_data, status_code=status_code)
186
+
187
+ self._add_options_handler("/actuator/health")
188
+
189
+ # Info endpoint
190
+ @self.app.get("/actuator/info", tags=["actuator"], include_in_schema=False)
191
+ async def info():
192
+ """Application info endpoint"""
193
+ return JSONResponse(content=self.info_endpoint.get_info())
194
+
195
+ self._add_options_handler("/actuator/info")
196
+
197
+ # Prometheus endpoint
198
+ @self.app.get("/actuator/prometheus", tags=["actuator"], include_in_schema=False)
199
+ async def prometheus():
200
+ """Prometheus metrics endpoint"""
201
+ metrics = self.prometheus_endpoint.get_metrics()
202
+ return Response(
203
+ content=metrics,
204
+ media_type=self.prometheus_endpoint.content_type
205
+ )
206
+
207
+ self._add_options_handler("/actuator/prometheus")
208
+
209
+ # Metrics endpoint - list all metrics
210
+ @self.app.get("/actuator/metrics", tags=["actuator"], include_in_schema=False)
211
+ async def metrics_list():
212
+ """List all available metrics"""
213
+ return JSONResponse(content=self.metrics_endpoint.get_metric_names())
214
+
215
+ self._add_options_handler("/actuator/metrics")
216
+
217
+ # Metrics endpoint - get individual metric
218
+ @self.app.get("/actuator/metrics/{metric_name}", tags=["actuator"], include_in_schema=False)
219
+ async def metrics_detail(metric_name: str):
220
+ """Get individual metric details"""
221
+ metric = self.metrics_endpoint.get_metric(metric_name)
222
+ if metric is None:
223
+ return JSONResponse(
224
+ content={"error": f"Metric '{metric_name}' not found"},
225
+ status_code=404
226
+ )
227
+ return JSONResponse(content=metric)
228
+
229
+ # Environment endpoint - all environment variables
230
+ @self.app.get("/actuator/env", tags=["actuator"], include_in_schema=False)
231
+ async def env_all():
232
+ """Get all environment variables (sanitized)"""
233
+ return JSONResponse(content=self.env_endpoint.get_environment())
234
+
235
+ self._add_options_handler("/actuator/env")
236
+
237
+ # Environment endpoint - single property
238
+ @self.app.get("/actuator/env/{property_name:path}", tags=["actuator"], include_in_schema=False)
239
+ async def env_property(property_name: str):
240
+ """Get single environment property"""
241
+ prop = self.env_endpoint.get_property(property_name)
242
+ if prop is None:
243
+ return JSONResponse(
244
+ content={"error": f"Property '{property_name}' not found"},
245
+ status_code=404
246
+ )
247
+ return JSONResponse(content=prop)
248
+
249
+ # Loggers endpoint - all loggers
250
+ @self.app.get("/actuator/loggers", tags=["actuator"], include_in_schema=False)
251
+ async def loggers_all():
252
+ """Get all loggers"""
253
+ return JSONResponse(content=self.loggers_endpoint.get_all_loggers())
254
+
255
+ self._add_options_handler("/actuator/loggers")
256
+
257
+ # Loggers endpoint - single logger
258
+ @self.app.get("/actuator/loggers/{logger_name:path}", tags=["actuator"], include_in_schema=False)
259
+ async def loggers_single(logger_name: str):
260
+ """Get single logger"""
261
+ logger_info = self.loggers_endpoint.get_logger(logger_name)
262
+ if logger_info is None:
263
+ return JSONResponse(
264
+ content={"error": f"Logger '{logger_name}' not found"},
265
+ status_code=404
266
+ )
267
+ return JSONResponse(content=logger_info)
268
+
269
+ # Loggers endpoint - set logger level
270
+ @self.app.post("/actuator/loggers/{logger_name:path}", tags=["actuator"], include_in_schema=False)
271
+ async def loggers_set_level(logger_name: str, body: dict = Body(...)):
272
+ """Set logger level"""
273
+ configured_level = body.get("configuredLevel")
274
+ success = self.loggers_endpoint.set_logger_level(logger_name, configured_level)
275
+ if not success:
276
+ return JSONResponse(
277
+ content={"error": f"Failed to set logger level for '{logger_name}'"},
278
+ status_code=400
279
+ )
280
+ return Response(status_code=204)
281
+
282
+ # Mappings endpoint
283
+ @self.app.get("/actuator/mappings", tags=["actuator"], include_in_schema=False)
284
+ async def mappings():
285
+ """Get request mappings"""
286
+ return JSONResponse(content=self.mappings_endpoint.get_mappings())
287
+
288
+ self._add_options_handler("/actuator/mappings")
289
+
290
+ # Thread dump endpoint
291
+ @self.app.get("/actuator/threaddump", tags=["actuator"], include_in_schema=False)
292
+ async def threaddump():
293
+ """Get thread dump"""
294
+ return JSONResponse(content=self.threaddump_endpoint.get_thread_dump())
295
+
296
+ self._add_options_handler("/actuator/threaddump")
297
+
298
+ # HTTP Trace endpoint (older Spring Boot versions)
299
+ @self.app.get("/actuator/httptrace", tags=["actuator"], include_in_schema=False)
300
+ async def httptrace():
301
+ """Get HTTP trace (request/response history)"""
302
+ return JSONResponse(content=self.httptrace_endpoint.get_traces())
303
+
304
+ self._add_options_handler("/actuator/httptrace")
305
+
306
+ # HTTP Exchanges endpoint (newer Spring Boot 2.2+)
307
+ @self.app.get("/actuator/httpexchanges", tags=["actuator"], include_in_schema=False)
308
+ async def httpexchanges():
309
+ """Get HTTP exchanges (request/response history)"""
310
+ return JSONResponse(content=self.httpexchanges_endpoint.get_exchanges())
311
+
312
+ self._add_options_handler("/actuator/httpexchanges")
313
+
314
+ # Dump/Trace endpoint (alias for threaddump, some Spring Boot Admin versions use this)
315
+ @self.app.get("/actuator/dump", tags=["actuator"], include_in_schema=False)
316
+ async def dump():
317
+ """Get thread dump (alias)"""
318
+ return JSONResponse(content=self.threaddump_endpoint.get_thread_dump())
319
+
320
+ self._add_options_handler("/actuator/dump")
321
+
322
+ # Trace endpoint (simple trace, for older versions)
323
+ @self.app.get("/actuator/trace", tags=["actuator"], include_in_schema=False)
324
+ async def trace():
325
+ """Get trace information"""
326
+ return JSONResponse(content=self.httptrace_endpoint.get_traces())
327
+
328
+ self._add_options_handler("/actuator/trace")
329
+
330
+ # Logfile endpoint - returns application log file
331
+ @self.app.get("/actuator/logfile", tags=["actuator"], include_in_schema=False)
332
+ async def logfile(request: Request):
333
+ """Get application log file"""
334
+ # Check if logfile is available
335
+ if not self.logfile_endpoint.is_available():
336
+ return JSONResponse(
337
+ content={"error": "Log file not configured or not available"},
338
+ status_code=404
339
+ )
340
+
341
+ # Get Range header if present
342
+ range_header = request.headers.get("Range")
343
+
344
+ # Get log file content
345
+ content, content_range, status_code = self.logfile_endpoint.get_logfile(range_header)
346
+
347
+ if status_code == 404:
348
+ return JSONResponse(
349
+ content={"error": "Log file not found"},
350
+ status_code=404
351
+ )
352
+ elif status_code == 416:
353
+ return Response(status_code=416) # Range Not Satisfiable
354
+ elif status_code == 500:
355
+ return JSONResponse(
356
+ content={"error": "Error reading log file"},
357
+ status_code=500
358
+ )
359
+
360
+ # Return log file content
361
+ headers = {}
362
+ if content_range:
363
+ headers["Content-Range"] = content_range
364
+
365
+ return Response(
366
+ content=content,
367
+ media_type="text/plain; charset=UTF-8",
368
+ status_code=status_code,
369
+ headers=headers
370
+ )
371
+
372
+ self._add_options_handler("/actuator/logfile")
373
+
374
+ # Refresh endpoint - reload configuration from Config Server
375
+ @self.app.post("/actuator/refresh", tags=["actuator"], include_in_schema=False)
376
+ async def refresh():
377
+ """Refresh configuration from Config Server"""
378
+ changed_keys = self.refresh_endpoint.refresh()
379
+ return JSONResponse(content=changed_keys)
380
+
381
+ self._add_options_handler("/actuator/refresh")
382
+
383
+ # Beans endpoint
384
+ @self.app.get("/actuator/beans", tags=["actuator"], include_in_schema=False)
385
+ async def beans():
386
+ """Get application beans/components"""
387
+ return JSONResponse(content=self.beans_endpoint.get_beans())
388
+
389
+ self._add_options_handler("/actuator/beans")
390
+
391
+ # ConfigProps endpoint - all configuration properties
392
+ @self.app.get("/actuator/configprops", tags=["actuator"], include_in_schema=False)
393
+ async def configprops_all():
394
+ """Get all configuration properties"""
395
+ return JSONResponse(content=self.configprops_endpoint.get_config_props())
396
+
397
+ self._add_options_handler("/actuator/configprops")
398
+
399
+ # ConfigProps endpoint - filtered by prefix
400
+ @self.app.get("/actuator/configprops/{prefix}", tags=["actuator"], include_in_schema=False)
401
+ async def configprops_prefix(prefix: str):
402
+ """Get configuration properties by prefix"""
403
+ props = self.configprops_endpoint.get_config_props_by_prefix(prefix)
404
+ if not props.get("contexts", {}).get("application", {}).get("beans"):
405
+ return JSONResponse(content={}, status_code=404)
406
+ return JSONResponse(content=props)
407
+
408
+ # ScheduledTasks endpoint
409
+ @self.app.get("/actuator/scheduledtasks", tags=["actuator"], include_in_schema=False)
410
+ async def scheduledtasks():
411
+ """Get scheduled tasks"""
412
+ return JSONResponse(content=self.scheduledtasks_endpoint.get_scheduled_tasks())
413
+
414
+ self._add_options_handler("/actuator/scheduledtasks")
415
+
416
+ # Heapdump endpoint - returns memory statistics (JSON, not binary)
417
+ @self.app.get("/actuator/heapdump", tags=["actuator"], include_in_schema=False)
418
+ async def heapdump():
419
+ """Get memory statistics"""
420
+ return JSONResponse(content=self.heapdump_endpoint.get_memory_stats())
421
+
422
+ self._add_options_handler("/actuator/heapdump")
423
+
424
+ # Caches endpoint - all caches
425
+ @self.app.get("/actuator/caches", tags=["actuator"], include_in_schema=False)
426
+ async def caches_all():
427
+ """Get all caches"""
428
+ return JSONResponse(content=self.caches_endpoint.get_caches())
429
+
430
+ self._add_options_handler("/actuator/caches")
431
+
432
+ # Caches endpoint - single cache
433
+ @self.app.get("/actuator/caches/{cache_name}", tags=["actuator"], include_in_schema=False)
434
+ async def caches_single(cache_name: str, cacheManager: Optional[str] = None):
435
+ """Get single cache"""
436
+ cache = self.caches_endpoint.get_cache(cache_name, cacheManager)
437
+ if cache is None:
438
+ return JSONResponse(
439
+ content={"error": f"Cache '{cache_name}' not found"},
440
+ status_code=404
441
+ )
442
+ return JSONResponse(content=cache)
443
+
444
+ # Caches endpoint - evict all
445
+ @self.app.delete("/actuator/caches", tags=["actuator"], include_in_schema=False)
446
+ async def caches_evict_all():
447
+ """Evict all caches"""
448
+ self.caches_endpoint.evict_all_caches()
449
+ return Response(status_code=204)
450
+
451
+ # Caches endpoint - evict single cache
452
+ @self.app.delete("/actuator/caches/{cache_name}", tags=["actuator"], include_in_schema=False)
453
+ async def caches_evict(cache_name: str, cacheManager: Optional[str] = None):
454
+ """Evict single cache"""
455
+ success = self.caches_endpoint.evict_cache(cache_name, cacheManager)
456
+ if not success:
457
+ return JSONResponse(
458
+ content={"error": f"Cache '{cache_name}' not found"},
459
+ status_code=404
460
+ )
461
+ return Response(status_code=204)
462
+
463
+ # AuditEvents endpoint
464
+ @self.app.get("/actuator/auditevents", tags=["actuator"], include_in_schema=False)
465
+ async def auditevents(
466
+ principal: Optional[str] = None,
467
+ after: Optional[str] = None,
468
+ type: Optional[str] = None
469
+ ):
470
+ """Get audit events with optional filtering"""
471
+ return JSONResponse(
472
+ content=self.auditevents_endpoint.get_events(
473
+ principal=principal,
474
+ after=after,
475
+ event_type=type
476
+ )
477
+ )
478
+
479
+ self._add_options_handler("/actuator/auditevents")
480
+
481
+ # OpenAPI v3 endpoint (Spring Boot Admin compatibility)
482
+ @self.app.get("/v3/api-docs", tags=["openapi"])
483
+ async def openapi_v3():
484
+ """OpenAPI v3 specification (Spring Boot Admin compatibility)"""
485
+ return JSONResponse(content=self.app.openapi())
486
+
487
+ self._add_options_handler("/v3/api-docs")
488
+
489
+ logger.info("Actuator endpoints registered with OPTIONS support: discovery, health, info, prometheus, metrics, env, loggers, mappings, threaddump, httptrace, httpexchanges, dump, trace, logfile, refresh, beans, configprops, scheduledtasks, heapdump, caches, auditevents, v3/api-docs")
490
+
491
+
492
+ def add_actuator_endpoints(
493
+ app: FastAPI,
494
+ base_url: str,
495
+ health_endpoint: Optional[HealthEndpoint] = None,
496
+ info_endpoint: Optional[InfoEndpoint] = None,
497
+ prometheus_endpoint: Optional[PrometheusEndpoint] = None,
498
+ enable_cors: bool = True
499
+ ) -> FastAPIActuatorIntegration:
500
+ """
501
+ Add Spring Boot Actuator endpoints to FastAPI app.
502
+
503
+ Args:
504
+ app: FastAPI application
505
+ base_url: Base URL for the application (e.g., "http://localhost:8080")
506
+ health_endpoint: Custom health endpoint (optional)
507
+ info_endpoint: Custom info endpoint (optional)
508
+ prometheus_endpoint: Custom prometheus endpoint (optional)
509
+ enable_cors: Enable CORS for actuator endpoints (default: True)
510
+
511
+ Returns:
512
+ FastAPIActuatorIntegration instance
513
+ """
514
+ return FastAPIActuatorIntegration(
515
+ app=app,
516
+ base_url=base_url,
517
+ health_endpoint=health_endpoint,
518
+ info_endpoint=info_endpoint,
519
+ prometheus_endpoint=prometheus_endpoint,
520
+ enable_cors=enable_cors
521
+ )
spring_ready/retry.py ADDED
@@ -0,0 +1,97 @@
1
+ """
2
+ Exponential backoff retry logic.
3
+ Matches Spring Cloud Config's retry behavior.
4
+ """
5
+
6
+ import time
7
+ import logging
8
+ from typing import Callable, TypeVar, Optional
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ T = TypeVar('T')
13
+
14
+
15
+ class RetryConfig:
16
+ """Configuration for exponential backoff retry"""
17
+
18
+ def __init__(
19
+ self,
20
+ max_attempts: int = 6,
21
+ initial_interval: float = 1.0,
22
+ max_interval: float = 2.0,
23
+ multiplier: float = 1.1
24
+ ):
25
+ """
26
+ Args:
27
+ max_attempts: Maximum number of retry attempts
28
+ initial_interval: Initial retry interval in seconds
29
+ max_interval: Maximum retry interval in seconds
30
+ multiplier: Multiplier for exponential backoff
31
+ """
32
+ self.max_attempts = max_attempts
33
+ self.initial_interval = initial_interval
34
+ self.max_interval = max_interval
35
+ self.multiplier = multiplier
36
+
37
+
38
+ def retry_with_backoff(
39
+ func: Callable[[], T],
40
+ config: RetryConfig,
41
+ operation_name: str,
42
+ fail_fast: bool = True
43
+ ) -> Optional[T]:
44
+ """
45
+ Execute function with exponential backoff retry.
46
+
47
+ Args:
48
+ func: Function to execute
49
+ config: Retry configuration
50
+ operation_name: Name of operation for logging
51
+ fail_fast: If True, raise exception after max attempts. If False, return None.
52
+
53
+ Returns:
54
+ Result of func() if successful, None if fail_fast=False and all retries exhausted
55
+
56
+ Raises:
57
+ Last exception if fail_fast=True and all retries exhausted
58
+ """
59
+ last_exception = None
60
+ interval = config.initial_interval
61
+
62
+ for attempt in range(1, config.max_attempts + 1):
63
+ try:
64
+ logger.debug(f"{operation_name}: Attempt {attempt}/{config.max_attempts}")
65
+ result = func()
66
+
67
+ if attempt > 1:
68
+ logger.info(f"{operation_name}: Succeeded on attempt {attempt}")
69
+
70
+ return result
71
+
72
+ except Exception as e:
73
+ last_exception = e
74
+
75
+ if attempt == config.max_attempts:
76
+ logger.error(
77
+ f"{operation_name}: Failed after {config.max_attempts} attempts. "
78
+ f"Last error: {e}"
79
+ )
80
+ if fail_fast:
81
+ raise
82
+ return None
83
+
84
+ logger.warning(
85
+ f"{operation_name}: Attempt {attempt} failed: {e}. "
86
+ f"Retrying in {interval:.2f}s..."
87
+ )
88
+
89
+ time.sleep(interval)
90
+
91
+ # Exponential backoff with max limit
92
+ interval = min(interval * config.multiplier, config.max_interval)
93
+
94
+ # Should not reach here, but just in case
95
+ if fail_fast and last_exception:
96
+ raise last_exception
97
+ return None