mcp-hangar 0.2.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.
Files changed (160) hide show
  1. mcp_hangar/__init__.py +139 -0
  2. mcp_hangar/application/__init__.py +1 -0
  3. mcp_hangar/application/commands/__init__.py +67 -0
  4. mcp_hangar/application/commands/auth_commands.py +118 -0
  5. mcp_hangar/application/commands/auth_handlers.py +296 -0
  6. mcp_hangar/application/commands/commands.py +59 -0
  7. mcp_hangar/application/commands/handlers.py +189 -0
  8. mcp_hangar/application/discovery/__init__.py +21 -0
  9. mcp_hangar/application/discovery/discovery_metrics.py +283 -0
  10. mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
  11. mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
  12. mcp_hangar/application/discovery/security_validator.py +414 -0
  13. mcp_hangar/application/event_handlers/__init__.py +50 -0
  14. mcp_hangar/application/event_handlers/alert_handler.py +191 -0
  15. mcp_hangar/application/event_handlers/audit_handler.py +203 -0
  16. mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
  17. mcp_hangar/application/event_handlers/logging_handler.py +69 -0
  18. mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
  19. mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
  20. mcp_hangar/application/event_handlers/security_handler.py +604 -0
  21. mcp_hangar/application/mcp/tooling.py +158 -0
  22. mcp_hangar/application/ports/__init__.py +9 -0
  23. mcp_hangar/application/ports/observability.py +237 -0
  24. mcp_hangar/application/queries/__init__.py +52 -0
  25. mcp_hangar/application/queries/auth_handlers.py +237 -0
  26. mcp_hangar/application/queries/auth_queries.py +118 -0
  27. mcp_hangar/application/queries/handlers.py +227 -0
  28. mcp_hangar/application/read_models/__init__.py +11 -0
  29. mcp_hangar/application/read_models/provider_views.py +139 -0
  30. mcp_hangar/application/sagas/__init__.py +11 -0
  31. mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
  32. mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
  33. mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
  34. mcp_hangar/application/services/__init__.py +9 -0
  35. mcp_hangar/application/services/provider_service.py +208 -0
  36. mcp_hangar/application/services/traced_provider_service.py +211 -0
  37. mcp_hangar/bootstrap/runtime.py +328 -0
  38. mcp_hangar/context.py +178 -0
  39. mcp_hangar/domain/__init__.py +117 -0
  40. mcp_hangar/domain/contracts/__init__.py +57 -0
  41. mcp_hangar/domain/contracts/authentication.py +225 -0
  42. mcp_hangar/domain/contracts/authorization.py +229 -0
  43. mcp_hangar/domain/contracts/event_store.py +178 -0
  44. mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
  45. mcp_hangar/domain/contracts/persistence.py +383 -0
  46. mcp_hangar/domain/contracts/provider_runtime.py +146 -0
  47. mcp_hangar/domain/discovery/__init__.py +20 -0
  48. mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
  49. mcp_hangar/domain/discovery/discovered_provider.py +185 -0
  50. mcp_hangar/domain/discovery/discovery_service.py +412 -0
  51. mcp_hangar/domain/discovery/discovery_source.py +192 -0
  52. mcp_hangar/domain/events.py +433 -0
  53. mcp_hangar/domain/exceptions.py +525 -0
  54. mcp_hangar/domain/model/__init__.py +70 -0
  55. mcp_hangar/domain/model/aggregate.py +58 -0
  56. mcp_hangar/domain/model/circuit_breaker.py +152 -0
  57. mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
  58. mcp_hangar/domain/model/event_sourced_provider.py +423 -0
  59. mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
  60. mcp_hangar/domain/model/health_tracker.py +183 -0
  61. mcp_hangar/domain/model/load_balancer.py +185 -0
  62. mcp_hangar/domain/model/provider.py +810 -0
  63. mcp_hangar/domain/model/provider_group.py +656 -0
  64. mcp_hangar/domain/model/tool_catalog.py +105 -0
  65. mcp_hangar/domain/policies/__init__.py +19 -0
  66. mcp_hangar/domain/policies/provider_health.py +187 -0
  67. mcp_hangar/domain/repository.py +249 -0
  68. mcp_hangar/domain/security/__init__.py +85 -0
  69. mcp_hangar/domain/security/input_validator.py +710 -0
  70. mcp_hangar/domain/security/rate_limiter.py +387 -0
  71. mcp_hangar/domain/security/roles.py +237 -0
  72. mcp_hangar/domain/security/sanitizer.py +387 -0
  73. mcp_hangar/domain/security/secrets.py +501 -0
  74. mcp_hangar/domain/services/__init__.py +20 -0
  75. mcp_hangar/domain/services/audit_service.py +376 -0
  76. mcp_hangar/domain/services/image_builder.py +328 -0
  77. mcp_hangar/domain/services/provider_launcher.py +1046 -0
  78. mcp_hangar/domain/value_objects.py +1138 -0
  79. mcp_hangar/errors.py +818 -0
  80. mcp_hangar/fastmcp_server.py +1105 -0
  81. mcp_hangar/gc.py +134 -0
  82. mcp_hangar/infrastructure/__init__.py +79 -0
  83. mcp_hangar/infrastructure/async_executor.py +133 -0
  84. mcp_hangar/infrastructure/auth/__init__.py +37 -0
  85. mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
  86. mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
  87. mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
  88. mcp_hangar/infrastructure/auth/middleware.py +340 -0
  89. mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
  90. mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
  91. mcp_hangar/infrastructure/auth/projections.py +366 -0
  92. mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
  93. mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
  94. mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
  95. mcp_hangar/infrastructure/command_bus.py +112 -0
  96. mcp_hangar/infrastructure/discovery/__init__.py +110 -0
  97. mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
  98. mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
  99. mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
  100. mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
  101. mcp_hangar/infrastructure/event_bus.py +260 -0
  102. mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
  103. mcp_hangar/infrastructure/event_store.py +396 -0
  104. mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
  105. mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
  106. mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
  107. mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
  108. mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
  109. mcp_hangar/infrastructure/metrics_publisher.py +36 -0
  110. mcp_hangar/infrastructure/observability/__init__.py +10 -0
  111. mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
  112. mcp_hangar/infrastructure/persistence/__init__.py +33 -0
  113. mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
  114. mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
  115. mcp_hangar/infrastructure/persistence/database.py +333 -0
  116. mcp_hangar/infrastructure/persistence/database_common.py +330 -0
  117. mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
  118. mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
  119. mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
  120. mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
  121. mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
  122. mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
  123. mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
  124. mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
  125. mcp_hangar/infrastructure/query_bus.py +153 -0
  126. mcp_hangar/infrastructure/saga_manager.py +401 -0
  127. mcp_hangar/logging_config.py +209 -0
  128. mcp_hangar/metrics.py +1007 -0
  129. mcp_hangar/models.py +31 -0
  130. mcp_hangar/observability/__init__.py +54 -0
  131. mcp_hangar/observability/health.py +487 -0
  132. mcp_hangar/observability/metrics.py +319 -0
  133. mcp_hangar/observability/tracing.py +433 -0
  134. mcp_hangar/progress.py +542 -0
  135. mcp_hangar/retry.py +613 -0
  136. mcp_hangar/server/__init__.py +120 -0
  137. mcp_hangar/server/__main__.py +6 -0
  138. mcp_hangar/server/auth_bootstrap.py +340 -0
  139. mcp_hangar/server/auth_cli.py +335 -0
  140. mcp_hangar/server/auth_config.py +305 -0
  141. mcp_hangar/server/bootstrap.py +735 -0
  142. mcp_hangar/server/cli.py +161 -0
  143. mcp_hangar/server/config.py +224 -0
  144. mcp_hangar/server/context.py +215 -0
  145. mcp_hangar/server/http_auth_middleware.py +165 -0
  146. mcp_hangar/server/lifecycle.py +467 -0
  147. mcp_hangar/server/state.py +117 -0
  148. mcp_hangar/server/tools/__init__.py +16 -0
  149. mcp_hangar/server/tools/discovery.py +186 -0
  150. mcp_hangar/server/tools/groups.py +75 -0
  151. mcp_hangar/server/tools/health.py +301 -0
  152. mcp_hangar/server/tools/provider.py +939 -0
  153. mcp_hangar/server/tools/registry.py +320 -0
  154. mcp_hangar/server/validation.py +113 -0
  155. mcp_hangar/stdio_client.py +229 -0
  156. mcp_hangar-0.2.0.dist-info/METADATA +347 -0
  157. mcp_hangar-0.2.0.dist-info/RECORD +160 -0
  158. mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
  159. mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
  160. mcp_hangar-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,398 @@
1
+ """Provider configuration repository implementations.
2
+
3
+ Provides both in-memory and SQLite implementations of IProviderConfigRepository.
4
+ """
5
+
6
+ from datetime import datetime, timezone
7
+ import json
8
+ import threading
9
+ from typing import Dict, List, Optional
10
+
11
+ from ...domain.contracts.persistence import ConcurrentModificationError, PersistenceError, ProviderConfigSnapshot
12
+ from ...logging_config import get_logger
13
+ from .database import Database
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ class InMemoryProviderConfigRepository:
19
+ """In-memory implementation of provider config repository.
20
+
21
+ Useful for testing and development. Data is lost on restart.
22
+ Thread-safe implementation.
23
+ """
24
+
25
+ def __init__(self):
26
+ """Initialize empty in-memory repository."""
27
+ self._configs: Dict[str, ProviderConfigSnapshot] = {}
28
+ self._versions: Dict[str, int] = {}
29
+ self._lock = threading.RLock()
30
+
31
+ async def save(self, config: ProviderConfigSnapshot) -> None:
32
+ """Save provider configuration."""
33
+ with self._lock:
34
+ now = datetime.now(timezone.utc)
35
+
36
+ # Update timestamps
37
+ if config.provider_id in self._configs:
38
+ # Update existing
39
+ new_config = ProviderConfigSnapshot(
40
+ **{
41
+ **config.to_dict(),
42
+ "created_at": self._configs[config.provider_id].created_at,
43
+ "updated_at": now,
44
+ }
45
+ )
46
+ self._versions[config.provider_id] = self._versions.get(config.provider_id, 0) + 1
47
+ else:
48
+ # Create new
49
+ new_config = ProviderConfigSnapshot(
50
+ **{
51
+ **config.to_dict(),
52
+ "created_at": now,
53
+ "updated_at": now,
54
+ }
55
+ )
56
+ self._versions[config.provider_id] = 1
57
+
58
+ self._configs[config.provider_id] = new_config
59
+ logger.debug(f"Saved config for provider: {config.provider_id}")
60
+
61
+ async def get(self, provider_id: str) -> Optional[ProviderConfigSnapshot]:
62
+ """Retrieve provider configuration by ID."""
63
+ with self._lock:
64
+ return self._configs.get(provider_id)
65
+
66
+ async def get_all(self) -> List[ProviderConfigSnapshot]:
67
+ """Retrieve all provider configurations."""
68
+ with self._lock:
69
+ return list(self._configs.values())
70
+
71
+ async def delete(self, provider_id: str) -> bool:
72
+ """Delete provider configuration."""
73
+ with self._lock:
74
+ if provider_id in self._configs:
75
+ del self._configs[provider_id]
76
+ self._versions.pop(provider_id, None)
77
+ logger.debug(f"Deleted config for provider: {provider_id}")
78
+ return True
79
+ return False
80
+
81
+ async def exists(self, provider_id: str) -> bool:
82
+ """Check if provider configuration exists."""
83
+ with self._lock:
84
+ return provider_id in self._configs
85
+
86
+ def clear(self) -> None:
87
+ """Clear all configurations (for testing)."""
88
+ with self._lock:
89
+ self._configs.clear()
90
+ self._versions.clear()
91
+
92
+
93
+ class SQLiteProviderConfigRepository:
94
+ """SQLite implementation of provider config repository.
95
+
96
+ Provides durable storage with optimistic concurrency control.
97
+ """
98
+
99
+ def __init__(self, database: Database):
100
+ """Initialize with database connection.
101
+
102
+ Args:
103
+ database: Database instance for connections
104
+ """
105
+ self._db = database
106
+
107
+ async def save(self, config: ProviderConfigSnapshot) -> None:
108
+ """Save provider configuration with optimistic locking.
109
+
110
+ Args:
111
+ config: Provider configuration to save
112
+
113
+ Raises:
114
+ ConcurrentModificationError: If version conflict detected
115
+ PersistenceError: If save operation fails
116
+ """
117
+ try:
118
+ async with self._db.transaction() as conn:
119
+ # Check existing version
120
+ cursor = await conn.execute(
121
+ "SELECT version FROM provider_configs WHERE provider_id = ?",
122
+ (config.provider_id,),
123
+ )
124
+ row = await cursor.fetchone()
125
+
126
+ config_json = json.dumps(config.to_dict())
127
+ now = datetime.now(timezone.utc).isoformat()
128
+
129
+ if row is None:
130
+ # Insert new config
131
+ await conn.execute(
132
+ """
133
+ INSERT INTO provider_configs
134
+ (provider_id, mode, config_json, enabled, version, created_at, updated_at)
135
+ VALUES (?, ?, ?, ?, 1, ?, ?)
136
+ """,
137
+ (
138
+ config.provider_id,
139
+ config.mode,
140
+ config_json,
141
+ 1 if config.enabled else 0,
142
+ now,
143
+ now,
144
+ ),
145
+ )
146
+ logger.debug(f"Inserted new config for provider: {config.provider_id}")
147
+ else:
148
+ # Update existing config with version increment
149
+ current_version = row[0]
150
+ new_version = current_version + 1
151
+
152
+ result = await conn.execute(
153
+ """
154
+ UPDATE provider_configs
155
+ SET mode = ?, config_json = ?, enabled = ?,
156
+ version = ?, updated_at = ?
157
+ WHERE provider_id = ? AND version = ?
158
+ """,
159
+ (
160
+ config.mode,
161
+ config_json,
162
+ 1 if config.enabled else 0,
163
+ new_version,
164
+ now,
165
+ config.provider_id,
166
+ current_version,
167
+ ),
168
+ )
169
+
170
+ if result.rowcount == 0:
171
+ raise ConcurrentModificationError(
172
+ config.provider_id,
173
+ current_version,
174
+ current_version + 1,
175
+ )
176
+
177
+ logger.debug(
178
+ f"Updated config for provider: {config.provider_id} "
179
+ f"(version {current_version} -> {new_version})"
180
+ )
181
+
182
+ except ConcurrentModificationError:
183
+ raise
184
+ except Exception as e:
185
+ logger.error(f"Failed to save provider config: {e}")
186
+ raise PersistenceError(f"Failed to save provider config: {e}") from e
187
+
188
+ async def get(self, provider_id: str) -> Optional[ProviderConfigSnapshot]:
189
+ """Retrieve provider configuration by ID.
190
+
191
+ Args:
192
+ provider_id: Provider identifier
193
+
194
+ Returns:
195
+ Configuration snapshot if found, None otherwise
196
+ """
197
+ try:
198
+ async with self._db.connection() as conn:
199
+ cursor = await conn.execute(
200
+ "SELECT config_json FROM provider_configs WHERE provider_id = ?",
201
+ (provider_id,),
202
+ )
203
+ row = await cursor.fetchone()
204
+
205
+ if row is None:
206
+ return None
207
+
208
+ config_data = json.loads(row[0])
209
+ return ProviderConfigSnapshot.from_dict(config_data)
210
+
211
+ except Exception as e:
212
+ logger.error(f"Failed to get provider config: {e}")
213
+ raise PersistenceError(f"Failed to get provider config: {e}") from e
214
+
215
+ async def get_all(self) -> List[ProviderConfigSnapshot]:
216
+ """Retrieve all provider configurations.
217
+
218
+ Returns:
219
+ List of all stored configurations
220
+ """
221
+ try:
222
+ async with self._db.connection() as conn:
223
+ cursor = await conn.execute("SELECT config_json FROM provider_configs WHERE enabled = 1")
224
+ rows = await cursor.fetchall()
225
+
226
+ configs = []
227
+ for row in rows:
228
+ try:
229
+ config_data = json.loads(row[0])
230
+ configs.append(ProviderConfigSnapshot.from_dict(config_data))
231
+ except Exception as e:
232
+ logger.warning(f"Failed to deserialize config: {e}")
233
+ continue
234
+
235
+ return configs
236
+
237
+ except Exception as e:
238
+ logger.error(f"Failed to get all provider configs: {e}")
239
+ raise PersistenceError(f"Failed to get all provider configs: {e}") from e
240
+
241
+ async def delete(self, provider_id: str) -> bool:
242
+ """Delete provider configuration (soft delete by disabling).
243
+
244
+ Args:
245
+ provider_id: Provider identifier
246
+
247
+ Returns:
248
+ True if deleted, False if not found
249
+ """
250
+ try:
251
+ async with self._db.transaction() as conn:
252
+ # Soft delete - mark as disabled
253
+ result = await conn.execute(
254
+ """
255
+ UPDATE provider_configs
256
+ SET enabled = 0, updated_at = ?
257
+ WHERE provider_id = ? AND enabled = 1
258
+ """,
259
+ (datetime.now(timezone.utc).isoformat(), provider_id),
260
+ )
261
+
262
+ deleted = result.rowcount > 0
263
+ if deleted:
264
+ logger.debug(f"Soft-deleted config for provider: {provider_id}")
265
+
266
+ return deleted
267
+
268
+ except Exception as e:
269
+ logger.error(f"Failed to delete provider config: {e}")
270
+ raise PersistenceError(f"Failed to delete provider config: {e}") from e
271
+
272
+ async def hard_delete(self, provider_id: str) -> bool:
273
+ """Permanently delete provider configuration.
274
+
275
+ Use with caution - this removes all history.
276
+
277
+ Args:
278
+ provider_id: Provider identifier
279
+
280
+ Returns:
281
+ True if deleted, False if not found
282
+ """
283
+ try:
284
+ async with self._db.transaction() as conn:
285
+ result = await conn.execute(
286
+ "DELETE FROM provider_configs WHERE provider_id = ?",
287
+ (provider_id,),
288
+ )
289
+
290
+ deleted = result.rowcount > 0
291
+ if deleted:
292
+ logger.info(f"Hard-deleted config for provider: {provider_id}")
293
+
294
+ return deleted
295
+
296
+ except Exception as e:
297
+ logger.error(f"Failed to hard-delete provider config: {e}")
298
+ raise PersistenceError(f"Failed to hard-delete provider config: {e}") from e
299
+
300
+ async def exists(self, provider_id: str) -> bool:
301
+ """Check if provider configuration exists.
302
+
303
+ Args:
304
+ provider_id: Provider identifier
305
+
306
+ Returns:
307
+ True if exists and enabled, False otherwise
308
+ """
309
+ try:
310
+ async with self._db.connection() as conn:
311
+ cursor = await conn.execute(
312
+ "SELECT 1 FROM provider_configs WHERE provider_id = ? AND enabled = 1",
313
+ (provider_id,),
314
+ )
315
+ row = await cursor.fetchone()
316
+ return row is not None
317
+
318
+ except Exception as e:
319
+ logger.error(f"Failed to check provider existence: {e}")
320
+ raise PersistenceError(f"Failed to check provider existence: {e}") from e
321
+
322
+ async def get_with_version(self, provider_id: str) -> Optional[tuple[ProviderConfigSnapshot, int]]:
323
+ """Get configuration with its version for optimistic locking.
324
+
325
+ Args:
326
+ provider_id: Provider identifier
327
+
328
+ Returns:
329
+ Tuple of (config, version) if found, None otherwise
330
+ """
331
+ try:
332
+ async with self._db.connection() as conn:
333
+ cursor = await conn.execute(
334
+ "SELECT config_json, version FROM provider_configs WHERE provider_id = ?",
335
+ (provider_id,),
336
+ )
337
+ row = await cursor.fetchone()
338
+
339
+ if row is None:
340
+ return None
341
+
342
+ config_data = json.loads(row[0])
343
+ return (ProviderConfigSnapshot.from_dict(config_data), row[1])
344
+
345
+ except Exception as e:
346
+ logger.error(f"Failed to get provider config with version: {e}")
347
+ raise PersistenceError(f"Failed to get provider config with version: {e}") from e
348
+
349
+ async def update_last_started(self, provider_id: str) -> None:
350
+ """Update the last_started_at timestamp.
351
+
352
+ Args:
353
+ provider_id: Provider identifier
354
+ """
355
+ try:
356
+ async with self._db.transaction() as conn:
357
+ await conn.execute(
358
+ """
359
+ UPDATE provider_configs
360
+ SET last_started_at = ?, updated_at = ?
361
+ WHERE provider_id = ?
362
+ """,
363
+ (
364
+ datetime.now(timezone.utc).isoformat(),
365
+ datetime.now(timezone.utc).isoformat(),
366
+ provider_id,
367
+ ),
368
+ )
369
+
370
+ except Exception as e:
371
+ logger.error(f"Failed to update last_started_at: {e}")
372
+ # Non-critical operation, don't raise
373
+
374
+ async def update_failure_count(self, provider_id: str, consecutive_failures: int) -> None:
375
+ """Update the consecutive failure count.
376
+
377
+ Args:
378
+ provider_id: Provider identifier
379
+ consecutive_failures: Current failure count
380
+ """
381
+ try:
382
+ async with self._db.transaction() as conn:
383
+ await conn.execute(
384
+ """
385
+ UPDATE provider_configs
386
+ SET consecutive_failures = ?, updated_at = ?
387
+ WHERE provider_id = ?
388
+ """,
389
+ (
390
+ consecutive_failures,
391
+ datetime.now(timezone.utc).isoformat(),
392
+ provider_id,
393
+ ),
394
+ )
395
+
396
+ except Exception as e:
397
+ logger.error(f"Failed to update failure count: {e}")
398
+ # Non-critical operation, don't raise