fin-infra 0.1.58__py3-none-any.whl → 0.1.60__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.
fin_infra/__init__.py CHANGED
@@ -4,8 +4,19 @@ Public surface is intentionally small at this stage. Import from submodules for
4
4
  specific domains (clients, models, markets, credit).
5
5
  """
6
6
 
7
+ from .exceptions import (
8
+ FinInfraError,
9
+ ProviderError,
10
+ ProviderNotFoundError,
11
+ ValidationError,
12
+ )
7
13
  from .version import __version__
8
14
 
9
15
  __all__ = [
10
16
  "__version__",
17
+ # Base errors
18
+ "FinInfraError",
19
+ "ProviderError",
20
+ "ProviderNotFoundError",
21
+ "ValidationError",
11
22
  ]
@@ -44,6 +44,7 @@ Examples:
44
44
 
45
45
  from collections import defaultdict
46
46
  from datetime import timedelta
47
+ from decimal import Decimal
47
48
  from typing import Optional
48
49
 
49
50
  from fin_infra.analytics.models import (
@@ -132,7 +133,7 @@ async def analyze_spending(
132
133
  ]
133
134
 
134
135
  # Calculate top merchants
135
- merchant_totals: dict[str, float] = defaultdict(float)
136
+ merchant_totals: dict[str, Decimal] = defaultdict(Decimal)
136
137
  for t in expense_transactions:
137
138
  merchant = _extract_merchant_name(t.description or "Unknown")
138
139
  merchant_totals[merchant] += abs(t.amount)
@@ -142,7 +143,7 @@ async def analyze_spending(
142
143
  ] # Top 10 merchants
143
144
 
144
145
  # Calculate category breakdown
145
- category_totals: dict[str, float] = defaultdict(float)
146
+ category_totals: dict[str, Decimal] = defaultdict(Decimal)
146
147
  for t in expense_transactions:
147
148
  category = _get_transaction_category(t)
148
149
  category_totals[category] += abs(t.amount)
@@ -261,7 +262,7 @@ def _get_transaction_category(transaction: Transaction) -> str:
261
262
 
262
263
  async def _calculate_spending_trends(
263
264
  user_id: str,
264
- current_category_totals: dict[str, float],
265
+ current_category_totals: dict[str, Decimal],
265
266
  current_period_days: int,
266
267
  banking_provider=None,
267
268
  categorization_provider=None,
@@ -288,7 +289,7 @@ async def _calculate_spending_trends(
288
289
  for category, current_amount in current_category_totals.items():
289
290
  # Mock: assume previous period was 10% lower on average
290
291
  # In reality, would fetch historical data
291
- previous_amount = current_amount * 0.9
292
+ previous_amount = current_amount * Decimal("0.9")
292
293
 
293
294
  change_percent = (
294
295
  ((current_amount - previous_amount) / previous_amount) * 100
@@ -311,7 +312,7 @@ async def _calculate_spending_trends(
311
312
 
312
313
  async def _detect_spending_anomalies(
313
314
  user_id: str,
314
- current_category_totals: dict[str, float],
315
+ current_category_totals: dict[str, Decimal],
315
316
  current_period_days: int,
316
317
  banking_provider=None,
317
318
  categorization_provider=None,
@@ -339,7 +340,7 @@ async def _detect_spending_anomalies(
339
340
  for category, current_amount in current_category_totals.items():
340
341
  # Mock: assume historical average is current amount * 0.8
341
342
  # In reality, would calculate from historical data
342
- average_amount = current_amount * 0.8
343
+ average_amount = current_amount * Decimal("0.8")
343
344
 
344
345
  deviation_percent = (
345
346
  ((current_amount - average_amount) / average_amount) * 100 if average_amount > 0 else 0
@@ -409,7 +410,7 @@ def _generate_mock_transactions(days: int) -> list[Transaction]:
409
410
  Transaction(
410
411
  id=f"mock_{i}",
411
412
  account_id="mock_account",
412
- amount=float(amount),
413
+ amount=Decimal(str(amount)),
413
414
  date=base_date - timedelta(days=days_ago),
414
415
  description=description,
415
416
  )
@@ -20,7 +20,7 @@ from pydantic import BaseModel, Field
20
20
 
21
21
  # ai-infra imports
22
22
  try:
23
- from ai_infra.llm import LLM
23
+ from ai_infra.llm import CoreLLM as LLM
24
24
  from ai_infra.llm.providers import Providers
25
25
  except ImportError:
26
26
  raise ImportError("ai-infra not installed. Install with: pip install ai-infra")
@@ -25,33 +25,21 @@ from tenacity import (
25
25
  )
26
26
 
27
27
  from fin_infra.credit.experian.auth import ExperianAuthManager
28
+ from fin_infra.exceptions import (
29
+ ExperianAPIError,
30
+ ExperianAuthError,
31
+ ExperianNotFoundError,
32
+ ExperianRateLimitError,
33
+ )
28
34
 
29
-
30
- class ExperianAPIError(Exception):
31
- """Base exception for Experian API errors."""
32
-
33
- def __init__(self, message: str, status_code: int | None = None, response: dict | None = None):
34
- super().__init__(message)
35
- self.status_code = status_code
36
- self.response = response
37
-
38
-
39
- class ExperianRateLimitError(ExperianAPIError):
40
- """Raised when rate limit is exceeded (429)."""
41
-
42
- pass
43
-
44
-
45
- class ExperianAuthError(ExperianAPIError):
46
- """Raised when authentication fails (401)."""
47
-
48
- pass
49
-
50
-
51
- class ExperianNotFoundError(ExperianAPIError):
52
- """Raised when user not found in bureau (404)."""
53
-
54
- pass
35
+ # Re-export for backward compatibility
36
+ __all__ = [
37
+ "ExperianAPIError",
38
+ "ExperianAuthError",
39
+ "ExperianNotFoundError",
40
+ "ExperianRateLimitError",
41
+ "ExperianClient",
42
+ ]
55
43
 
56
44
 
57
45
  class ExperianClient:
@@ -0,0 +1,613 @@
1
+ """Unified exception hierarchy for fin-infra.
2
+
3
+ This module provides a consistent exception hierarchy across all fin-infra components:
4
+ - Provider errors (API failures, authentication, rate limits)
5
+ - Normalization errors (currency, symbol resolution)
6
+ - Validation errors (data validation, compliance)
7
+ - Calculation errors (financial calculations)
8
+
9
+ All exceptions inherit from FinInfraError, allowing users to catch all library
10
+ errors with a single except clause.
11
+
12
+ Example:
13
+ try:
14
+ accounts = await banking.get_accounts(token)
15
+ except FinInfraError as e:
16
+ print(f"Error: {e}")
17
+ if e.hint:
18
+ print(f"Hint: {e.hint}")
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ from typing import Any
25
+
26
+
27
+ # =============================================================================
28
+ # Logging Helper
29
+ # =============================================================================
30
+
31
+
32
+ def log_exception(
33
+ logger: logging.Logger,
34
+ msg: str,
35
+ exc: Exception,
36
+ *,
37
+ level: str = "warning",
38
+ include_traceback: bool = True,
39
+ ) -> None:
40
+ """Log an exception with consistent formatting.
41
+
42
+ Use this helper instead of bare `except Exception:` blocks to ensure
43
+ all exceptions are properly logged with context.
44
+
45
+ Args:
46
+ logger: The logger instance to use
47
+ msg: Context message describing what operation failed
48
+ exc: The exception that was caught
49
+ level: Log level - "debug", "info", "warning", "error", "critical"
50
+ include_traceback: Whether to include full traceback (exc_info=True)
51
+
52
+ Example:
53
+ try:
54
+ result = await provider.get_data()
55
+ except Exception as e:
56
+ log_exception(logger, "Failed to fetch data from provider", e)
57
+ # Handle gracefully or re-raise
58
+ """
59
+ log_func = getattr(logger, level.lower(), logger.warning)
60
+ log_func(f"{msg}: {type(exc).__name__}: {exc}", exc_info=include_traceback)
61
+
62
+
63
+ # =============================================================================
64
+ # Base Error
65
+ # =============================================================================
66
+
67
+
68
+ class FinInfraError(Exception):
69
+ """Base exception for all fin-infra errors.
70
+
71
+ All fin-infra exceptions inherit from this, allowing users to catch
72
+ all library errors with a single except clause.
73
+
74
+ Attributes:
75
+ message: Human-readable error description
76
+ details: Additional context as key-value pairs
77
+ hint: Suggested fix or action
78
+ docs_url: Link to relevant documentation
79
+ """
80
+
81
+ def __init__(
82
+ self,
83
+ message: str,
84
+ *,
85
+ details: dict[str, Any] | None = None,
86
+ hint: str | None = None,
87
+ docs_url: str | None = None,
88
+ ):
89
+ self.message = message
90
+ self.details = details or {}
91
+ self.hint = hint
92
+ self.docs_url = docs_url
93
+
94
+ # Build full message
95
+ full_msg = message
96
+ if hint:
97
+ full_msg += f"\n Hint: {hint}"
98
+ if docs_url:
99
+ full_msg += f"\n Docs: {docs_url}"
100
+
101
+ super().__init__(full_msg)
102
+
103
+ def __repr__(self) -> str:
104
+ return f"{self.__class__.__name__}({self.message!r})"
105
+
106
+
107
+ # =============================================================================
108
+ # Provider Errors
109
+ # =============================================================================
110
+
111
+
112
+ class ProviderError(FinInfraError):
113
+ """Base error for provider operations (banking, brokerage, credit, etc.).
114
+
115
+ Raised when a financial data provider returns an error.
116
+ """
117
+
118
+ def __init__(
119
+ self,
120
+ message: str,
121
+ *,
122
+ provider: str | None = None,
123
+ status_code: int | None = None,
124
+ details: dict[str, Any] | None = None,
125
+ hint: str | None = None,
126
+ docs_url: str | None = None,
127
+ ):
128
+ self.provider = provider
129
+ self.status_code = status_code
130
+
131
+ # Add provider info to details
132
+ full_details = details or {}
133
+ if provider:
134
+ full_details["provider"] = provider
135
+ if status_code:
136
+ full_details["status_code"] = status_code
137
+
138
+ super().__init__(message, details=full_details, hint=hint, docs_url=docs_url)
139
+
140
+
141
+ class ProviderNotFoundError(ProviderError):
142
+ """Provider not found in registry."""
143
+
144
+ def __init__(
145
+ self,
146
+ provider_key: str,
147
+ *,
148
+ available_providers: list[str] | None = None,
149
+ details: dict[str, Any] | None = None,
150
+ ):
151
+ hint = None
152
+ if available_providers:
153
+ hint = f"Available providers: {', '.join(available_providers)}"
154
+
155
+ super().__init__(
156
+ f"Provider '{provider_key}' not found",
157
+ details=details,
158
+ hint=hint,
159
+ )
160
+ self.provider_key = provider_key
161
+ self.available_providers = available_providers
162
+
163
+
164
+ class ProviderAPIError(ProviderError):
165
+ """API error from a provider."""
166
+
167
+ def __init__(
168
+ self,
169
+ message: str,
170
+ *,
171
+ provider: str | None = None,
172
+ status_code: int | None = None,
173
+ error_type: str | None = None,
174
+ response: dict[str, Any] | None = None,
175
+ details: dict[str, Any] | None = None,
176
+ hint: str | None = None,
177
+ ):
178
+ self.error_type = error_type
179
+ self.response = response
180
+
181
+ full_details = details or {}
182
+ if error_type:
183
+ full_details["error_type"] = error_type
184
+ if response:
185
+ full_details["response"] = response
186
+
187
+ super().__init__(
188
+ message,
189
+ provider=provider,
190
+ status_code=status_code,
191
+ details=full_details,
192
+ hint=hint,
193
+ )
194
+
195
+
196
+ class ProviderAuthError(ProviderAPIError):
197
+ """Authentication failed with provider (401)."""
198
+
199
+ def __init__(
200
+ self,
201
+ message: str = "Authentication failed",
202
+ *,
203
+ provider: str | None = None,
204
+ details: dict[str, Any] | None = None,
205
+ ):
206
+ hint = "Check your API credentials"
207
+ if provider:
208
+ hint = f"Check your {provider.upper()} API credentials"
209
+
210
+ super().__init__(
211
+ message,
212
+ provider=provider,
213
+ status_code=401,
214
+ error_type="Unauthorized",
215
+ details=details,
216
+ hint=hint,
217
+ )
218
+
219
+
220
+ class ProviderRateLimitError(ProviderAPIError):
221
+ """Rate limit exceeded from provider (429)."""
222
+
223
+ def __init__(
224
+ self,
225
+ message: str = "Rate limit exceeded",
226
+ *,
227
+ provider: str | None = None,
228
+ retry_after: float | None = None,
229
+ details: dict[str, Any] | None = None,
230
+ ):
231
+ self.retry_after = retry_after
232
+
233
+ hint = "Wait and retry the request"
234
+ if retry_after:
235
+ hint = f"Retry after {retry_after} seconds"
236
+
237
+ full_details = details or {}
238
+ if retry_after:
239
+ full_details["retry_after"] = retry_after
240
+
241
+ super().__init__(
242
+ message,
243
+ provider=provider,
244
+ status_code=429,
245
+ error_type="Too Many Requests",
246
+ details=full_details,
247
+ hint=hint,
248
+ )
249
+
250
+
251
+ class ProviderNotFoundResourceError(ProviderAPIError):
252
+ """Resource not found at provider (404)."""
253
+
254
+ def __init__(
255
+ self,
256
+ message: str,
257
+ *,
258
+ provider: str | None = None,
259
+ resource_type: str | None = None,
260
+ resource_id: str | None = None,
261
+ details: dict[str, Any] | None = None,
262
+ ):
263
+ self.resource_type = resource_type
264
+ self.resource_id = resource_id
265
+
266
+ full_details = details or {}
267
+ if resource_type:
268
+ full_details["resource_type"] = resource_type
269
+ if resource_id:
270
+ full_details["resource_id"] = resource_id
271
+
272
+ super().__init__(
273
+ message,
274
+ provider=provider,
275
+ status_code=404,
276
+ error_type="Not Found",
277
+ details=full_details,
278
+ )
279
+
280
+
281
+ # =============================================================================
282
+ # Credit Provider Errors (Experian, Equifax, TransUnion)
283
+ # =============================================================================
284
+
285
+
286
+ class CreditError(ProviderError):
287
+ """Base error for credit bureau operations."""
288
+
289
+ pass
290
+
291
+
292
+ class ExperianAPIError(CreditError):
293
+ """Error from Experian API."""
294
+
295
+ def __init__(
296
+ self,
297
+ message: str,
298
+ *,
299
+ status_code: int | None = None,
300
+ response: dict[str, Any] | None = None,
301
+ details: dict[str, Any] | None = None,
302
+ hint: str | None = None,
303
+ ):
304
+ self.response = response
305
+
306
+ full_details = details or {}
307
+ if response:
308
+ full_details["response"] = response
309
+
310
+ super().__init__(
311
+ message,
312
+ provider="experian",
313
+ status_code=status_code,
314
+ details=full_details,
315
+ hint=hint,
316
+ )
317
+
318
+
319
+ class ExperianRateLimitError(ExperianAPIError):
320
+ """Experian rate limit exceeded (429)."""
321
+
322
+ def __init__(
323
+ self,
324
+ message: str = "Experian rate limit exceeded",
325
+ *,
326
+ status_code: int | None = 429,
327
+ response: dict[str, Any] | None = None,
328
+ retry_after: float | None = None,
329
+ details: dict[str, Any] | None = None,
330
+ ):
331
+ self.retry_after = retry_after
332
+
333
+ hint = "Wait and retry the request"
334
+ if retry_after:
335
+ hint = f"Retry after {retry_after} seconds"
336
+
337
+ super().__init__(
338
+ message,
339
+ status_code=status_code,
340
+ response=response,
341
+ details=details,
342
+ hint=hint,
343
+ )
344
+
345
+
346
+ class ExperianAuthError(ExperianAPIError):
347
+ """Experian authentication failed (401)."""
348
+
349
+ def __init__(
350
+ self,
351
+ message: str = "Experian authentication failed",
352
+ *,
353
+ status_code: int | None = 401,
354
+ response: dict[str, Any] | None = None,
355
+ details: dict[str, Any] | None = None,
356
+ ):
357
+ super().__init__(
358
+ message,
359
+ status_code=status_code,
360
+ response=response,
361
+ details=details,
362
+ hint="Check your EXPERIAN_CLIENT_ID and EXPERIAN_CLIENT_SECRET",
363
+ )
364
+
365
+
366
+ class ExperianNotFoundError(ExperianAPIError):
367
+ """User not found in Experian bureau (404)."""
368
+
369
+ def __init__(
370
+ self,
371
+ message: str = "User not found in credit bureau",
372
+ *,
373
+ status_code: int | None = 404,
374
+ response: dict[str, Any] | None = None,
375
+ user_id: str | None = None,
376
+ details: dict[str, Any] | None = None,
377
+ ):
378
+ self.user_id = user_id
379
+
380
+ full_details = details or {}
381
+ if user_id:
382
+ full_details["user_id"] = user_id
383
+
384
+ super().__init__(
385
+ message,
386
+ status_code=status_code,
387
+ response=response,
388
+ details=full_details,
389
+ )
390
+
391
+
392
+ # =============================================================================
393
+ # Normalization Errors
394
+ # =============================================================================
395
+
396
+
397
+ class NormalizationError(FinInfraError):
398
+ """Base error for normalization operations."""
399
+
400
+ pass
401
+
402
+
403
+ class CurrencyNotSupportedError(NormalizationError):
404
+ """Currency code not supported."""
405
+
406
+ def __init__(
407
+ self,
408
+ currency: str,
409
+ *,
410
+ supported_currencies: list[str] | None = None,
411
+ details: dict[str, Any] | None = None,
412
+ ):
413
+ hint = None
414
+ if supported_currencies:
415
+ # Show first 10 currencies as example
416
+ examples = supported_currencies[:10]
417
+ hint = f"Supported currencies include: {', '.join(examples)}..."
418
+
419
+ super().__init__(
420
+ f"Currency '{currency}' is not supported",
421
+ details=details,
422
+ hint=hint,
423
+ )
424
+ self.currency = currency
425
+ self.supported_currencies = supported_currencies
426
+
427
+
428
+ class SymbolNotFoundError(NormalizationError):
429
+ """Symbol could not be resolved."""
430
+
431
+ def __init__(
432
+ self,
433
+ identifier: str,
434
+ *,
435
+ source_format: str | None = None,
436
+ target_format: str | None = None,
437
+ details: dict[str, Any] | None = None,
438
+ ):
439
+ msg = f"Symbol '{identifier}' could not be resolved"
440
+ if source_format and target_format:
441
+ msg = f"Cannot convert '{identifier}' from {source_format} to {target_format}"
442
+
443
+ full_details = details or {}
444
+ if source_format:
445
+ full_details["source_format"] = source_format
446
+ if target_format:
447
+ full_details["target_format"] = target_format
448
+
449
+ super().__init__(msg, details=full_details)
450
+ self.identifier = identifier
451
+ self.source_format = source_format
452
+ self.target_format = target_format
453
+
454
+
455
+ class ExchangeRateAPIError(NormalizationError):
456
+ """Error from exchange rate API."""
457
+
458
+ def __init__(
459
+ self,
460
+ message: str,
461
+ *,
462
+ status_code: int | None = None,
463
+ details: dict[str, Any] | None = None,
464
+ ):
465
+ self.status_code = status_code
466
+
467
+ full_details = details or {}
468
+ if status_code:
469
+ full_details["status_code"] = status_code
470
+
471
+ super().__init__(message, details=full_details)
472
+
473
+
474
+ # =============================================================================
475
+ # Validation Errors
476
+ # =============================================================================
477
+
478
+
479
+ class ValidationError(FinInfraError):
480
+ """Base error for validation failures."""
481
+
482
+ pass
483
+
484
+
485
+ class ComplianceError(ValidationError):
486
+ """Compliance validation failed (FCRA, PCI-DSS, etc.)."""
487
+
488
+ def __init__(
489
+ self,
490
+ message: str,
491
+ *,
492
+ regulation: str | None = None,
493
+ details: dict[str, Any] | None = None,
494
+ ):
495
+ self.regulation = regulation
496
+
497
+ full_details = details or {}
498
+ if regulation:
499
+ full_details["regulation"] = regulation
500
+
501
+ super().__init__(message, details=full_details)
502
+
503
+
504
+ # =============================================================================
505
+ # Calculation Errors
506
+ # =============================================================================
507
+
508
+
509
+ class CalculationError(FinInfraError):
510
+ """Base error for financial calculation failures."""
511
+
512
+ pass
513
+
514
+
515
+ class InsufficientDataError(CalculationError):
516
+ """Not enough data to perform calculation."""
517
+
518
+ def __init__(
519
+ self,
520
+ message: str,
521
+ *,
522
+ required_fields: list[str] | None = None,
523
+ details: dict[str, Any] | None = None,
524
+ ):
525
+ self.required_fields = required_fields
526
+
527
+ full_details = details or {}
528
+ if required_fields:
529
+ full_details["required_fields"] = required_fields
530
+
531
+ hint = None
532
+ if required_fields:
533
+ hint = f"Required fields: {', '.join(required_fields)}"
534
+
535
+ super().__init__(message, details=full_details, hint=hint)
536
+
537
+
538
+ # =============================================================================
539
+ # Retry/Network Errors
540
+ # =============================================================================
541
+
542
+
543
+ class RetryError(FinInfraError):
544
+ """Retry limit exceeded after multiple attempts."""
545
+
546
+ def __init__(
547
+ self,
548
+ message: str = "Operation failed after max retries",
549
+ *,
550
+ attempts: int | None = None,
551
+ last_exception: Exception | None = None,
552
+ details: dict[str, Any] | None = None,
553
+ ):
554
+ self.attempts = attempts
555
+ self.last_exception = last_exception
556
+
557
+ full_details = details or {}
558
+ if attempts:
559
+ full_details["attempts"] = attempts
560
+ if last_exception:
561
+ full_details["last_exception"] = str(last_exception)
562
+
563
+ super().__init__(message, details=full_details)
564
+
565
+
566
+ # =============================================================================
567
+ # Convenience aliases for backward compatibility
568
+ # =============================================================================
569
+
570
+ # Keep short names for commonly used errors
571
+ APIError = ProviderAPIError
572
+ AuthError = ProviderAuthError
573
+ RateLimitError = ProviderRateLimitError
574
+ NotFoundError = ProviderNotFoundResourceError
575
+
576
+
577
+ __all__ = [
578
+ # Logging helper
579
+ "log_exception",
580
+ # Base
581
+ "FinInfraError",
582
+ # Provider errors
583
+ "ProviderError",
584
+ "ProviderNotFoundError",
585
+ "ProviderAPIError",
586
+ "ProviderAuthError",
587
+ "ProviderRateLimitError",
588
+ "ProviderNotFoundResourceError",
589
+ # Credit provider errors
590
+ "CreditError",
591
+ "ExperianAPIError",
592
+ "ExperianRateLimitError",
593
+ "ExperianAuthError",
594
+ "ExperianNotFoundError",
595
+ # Normalization errors
596
+ "NormalizationError",
597
+ "CurrencyNotSupportedError",
598
+ "SymbolNotFoundError",
599
+ "ExchangeRateAPIError",
600
+ # Validation errors
601
+ "ValidationError",
602
+ "ComplianceError",
603
+ # Calculation errors
604
+ "CalculationError",
605
+ "InsufficientDataError",
606
+ # Retry errors
607
+ "RetryError",
608
+ # Aliases
609
+ "APIError",
610
+ "AuthError",
611
+ "RateLimitError",
612
+ "NotFoundError",
613
+ ]
@@ -220,10 +220,15 @@ def add_investments(
220
220
  )
221
221
 
222
222
  # Call provider with resolved token
223
- holdings = await investment_provider.get_holdings(
224
- access_token=access_token,
225
- account_ids=request.account_ids,
226
- )
223
+ try:
224
+ holdings = await investment_provider.get_holdings(
225
+ access_token=access_token,
226
+ account_ids=request.account_ids,
227
+ )
228
+ except ValueError as e:
229
+ raise HTTPException(status_code=401, detail=str(e))
230
+ except Exception as e:
231
+ raise HTTPException(status_code=500, detail=f"Failed to fetch holdings: {e}")
227
232
  return holdings
228
233
 
229
234
  @router.post(
@@ -266,12 +271,17 @@ def add_investments(
266
271
  detail="No access token found. Please reconnect your accounts."
267
272
  )
268
273
 
269
- transactions = await investment_provider.get_transactions(
270
- access_token=access_token,
271
- start_date=request.start_date,
272
- end_date=request.end_date,
273
- account_ids=request.account_ids,
274
- )
274
+ try:
275
+ transactions = await investment_provider.get_transactions(
276
+ access_token=access_token,
277
+ start_date=request.start_date,
278
+ end_date=request.end_date,
279
+ account_ids=request.account_ids,
280
+ )
281
+ except ValueError as e:
282
+ raise HTTPException(status_code=401, detail=str(e))
283
+ except Exception as e:
284
+ raise HTTPException(status_code=500, detail=f"Failed to fetch transactions: {e}")
275
285
  return transactions
276
286
 
277
287
  @router.post(
@@ -306,9 +316,14 @@ def add_investments(
306
316
  detail="No access token found. Please reconnect your accounts."
307
317
  )
308
318
 
309
- accounts = await investment_provider.get_investment_accounts(
310
- access_token=access_token,
311
- )
319
+ try:
320
+ accounts = await investment_provider.get_investment_accounts(
321
+ access_token=access_token,
322
+ )
323
+ except ValueError as e:
324
+ raise HTTPException(status_code=401, detail=str(e))
325
+ except Exception as e:
326
+ raise HTTPException(status_code=500, detail=f"Failed to fetch accounts: {e}")
312
327
  return accounts
313
328
 
314
329
  @router.post(
@@ -344,10 +359,15 @@ def add_investments(
344
359
  )
345
360
 
346
361
  # Fetch holdings
347
- holdings = await investment_provider.get_holdings(
348
- access_token=access_token,
349
- account_ids=request.account_ids,
350
- )
362
+ try:
363
+ holdings = await investment_provider.get_holdings(
364
+ access_token=access_token,
365
+ account_ids=request.account_ids,
366
+ )
367
+ except ValueError as e:
368
+ raise HTTPException(status_code=401, detail=str(e))
369
+ except Exception as e:
370
+ raise HTTPException(status_code=500, detail=f"Failed to fetch allocation: {e}")
351
371
 
352
372
  # Calculate allocation using base provider helper
353
373
  allocation = investment_provider.calculate_allocation(holdings)
@@ -385,10 +405,15 @@ def add_investments(
385
405
  detail="No access token found. Please reconnect your accounts."
386
406
  )
387
407
 
388
- securities = await investment_provider.get_securities(
389
- access_token=access_token,
390
- security_ids=request.security_ids,
391
- )
408
+ try:
409
+ securities = await investment_provider.get_securities(
410
+ access_token=access_token,
411
+ security_ids=request.security_ids,
412
+ )
413
+ except ValueError as e:
414
+ raise HTTPException(status_code=401, detail=str(e))
415
+ except Exception as e:
416
+ raise HTTPException(status_code=500, detail=f"Failed to fetch securities: {e}")
392
417
  return securities
393
418
 
394
419
  # 5. Mount router
@@ -4,19 +4,17 @@ import logging
4
4
  from datetime import date as DateType
5
5
  from typing import Optional
6
6
 
7
+ from fin_infra.exceptions import CurrencyNotSupportedError, ExchangeRateAPIError
7
8
  from fin_infra.normalization.models import CurrencyConversionResult
8
- from fin_infra.normalization.providers.exchangerate import (
9
- ExchangeRateAPIError,
10
- ExchangeRateClient,
11
- )
12
-
13
- logger = logging.getLogger(__name__)
9
+ from fin_infra.normalization.providers.exchangerate import ExchangeRateClient
14
10
 
11
+ # Re-export for backward compatibility
12
+ __all__ = [
13
+ "CurrencyNotSupportedError",
14
+ "CurrencyConverter",
15
+ ]
15
16
 
16
- class CurrencyNotSupportedError(Exception):
17
- """Currency code not supported."""
18
-
19
- pass
17
+ logger = logging.getLogger(__name__)
20
18
 
21
19
 
22
20
  class CurrencyConverter:
@@ -6,13 +6,14 @@ from typing import Optional
6
6
 
7
7
  import httpx
8
8
 
9
+ from fin_infra.exceptions import ExchangeRateAPIError
9
10
  from fin_infra.normalization.models import ExchangeRate
10
11
 
11
-
12
- class ExchangeRateAPIError(Exception):
13
- """Exchange rate API error."""
14
-
15
- pass
12
+ # Re-export for backward compatibility
13
+ __all__ = [
14
+ "ExchangeRateAPIError",
15
+ "ExchangeRateClient",
16
+ ]
16
17
 
17
18
 
18
19
  class ExchangeRateClient:
@@ -3,6 +3,7 @@
3
3
  import logging
4
4
  from typing import Optional
5
5
 
6
+ from fin_infra.exceptions import SymbolNotFoundError
6
7
  from fin_infra.normalization.models import SymbolMetadata
7
8
  from fin_infra.normalization.providers import (
8
9
  CUSIP_TO_TICKER,
@@ -13,13 +14,13 @@ from fin_infra.normalization.providers import (
13
14
  TICKER_TO_ISIN,
14
15
  )
15
16
 
16
- logger = logging.getLogger(__name__)
17
-
17
+ # Re-export for backward compatibility
18
+ __all__ = [
19
+ "SymbolNotFoundError",
20
+ "SymbolResolver",
21
+ ]
18
22
 
19
- class SymbolNotFoundError(Exception):
20
- """Symbol could not be resolved."""
21
-
22
- pass
23
+ logger = logging.getLogger(__name__)
23
24
 
24
25
 
25
26
  class SymbolResolver:
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  import httpx
4
5
  from decimal import Decimal
5
6
  from datetime import datetime, timezone
@@ -7,6 +8,7 @@ from datetime import datetime, timezone
7
8
  from ..base import CryptoDataProvider
8
9
  from ...models import Quote, Candle
9
10
 
11
+ logger = logging.getLogger(__name__)
10
12
 
11
13
  _BASE = "https://api.coingecko.com/api/v3"
12
14
 
@@ -25,7 +27,8 @@ class CoinGeckoCryptoData(CryptoDataProvider):
25
27
  r.raise_for_status()
26
28
  data = r.json()
27
29
  price = data.get(_to_cg_id(base), {}).get(quote.lower(), 0)
28
- except Exception:
30
+ except Exception as e:
31
+ logger.warning("CoinGecko ticker fetch failed for %s: %s", symbol_pair, e)
29
32
  price = 0
30
33
  return Quote(
31
34
  symbol=f"{base}/{quote}", price=Decimal(str(price)), as_of=datetime.now(timezone.utc)
@@ -42,7 +45,8 @@ class CoinGeckoCryptoData(CryptoDataProvider):
42
45
  )
43
46
  r.raise_for_status()
44
47
  prices = r.json().get("prices", [])
45
- except Exception:
48
+ except Exception as e:
49
+ logger.warning("CoinGecko OHLCV fetch failed for %s: %s", symbol_pair, e)
46
50
  prices = []
47
51
  out: list[Candle] = []
48
52
  for p in prices[:limit]:
@@ -13,6 +13,8 @@ from __future__ import annotations
13
13
  import importlib
14
14
  from typing import Any, TypeVar
15
15
 
16
+ from fin_infra.exceptions import ProviderNotFoundError
17
+
16
18
  from .base import (
17
19
  BankingProvider,
18
20
  BrokerageProvider,
@@ -23,6 +25,15 @@ from .base import (
23
25
  TaxProvider,
24
26
  )
25
27
 
28
+ # Re-export for backward compatibility
29
+ __all__ = [
30
+ "ProviderNotFoundError",
31
+ "ProviderRegistry",
32
+ "PROVIDER_TYPES",
33
+ "PROVIDER_MODULES",
34
+ "DEFAULT_PROVIDERS",
35
+ ]
36
+
26
37
  T = TypeVar("T")
27
38
 
28
39
  # Provider domain to ABC mapping
@@ -77,12 +88,6 @@ DEFAULT_PROVIDERS = {
77
88
  }
78
89
 
79
90
 
80
- class ProviderNotFoundError(Exception):
81
- """Raised when a provider cannot be found or loaded."""
82
-
83
- pass
84
-
85
-
86
91
  class ProviderRegistry:
87
92
  """
88
93
  Registry for financial data providers.
@@ -7,6 +7,11 @@ no local HTTP/retry wrappers to avoid duplication.
7
7
  Scaffold utilities for template-based code generation are provided by svc-infra
8
8
  and should be imported from there:
9
9
  from svc_infra.utils import render_template, write, ensure_init_py
10
+
11
+ For async retry with exponential backoff:
12
+ from fin_infra.utils.retry import retry_async, RetryError
10
13
  """
11
14
 
12
- __all__ = []
15
+ from fin_infra.utils.retry import RetryError, retry_async
16
+
17
+ __all__ = ["RetryError", "retry_async"]
fin_infra/utils/retry.py CHANGED
@@ -4,11 +4,12 @@ import asyncio
4
4
  import random
5
5
  from typing import Awaitable, Callable, Iterable, TypeVar
6
6
 
7
- T = TypeVar("T")
7
+ from fin_infra.exceptions import RetryError
8
8
 
9
+ # Re-export for backward compatibility
10
+ __all__ = ["RetryError", "retry_async"]
9
11
 
10
- class RetryError(Exception):
11
- pass
12
+ T = TypeVar("T")
12
13
 
13
14
 
14
15
  async def retry_async(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: fin-infra
3
- Version: 0.1.58
3
+ Version: 0.1.60
4
4
  Summary: Financial infrastructure toolkit: banking connections, market data, credit, cashflows, and brokerage integrations
5
5
  License: MIT
6
6
  Keywords: finance,banking,plaid,brokerage,markets,credit,tax,cashflow,fintech,infra
@@ -1,4 +1,4 @@
1
- fin_infra/__init__.py,sha256=24O2WYSszXRiirFFlGEGRtf0lmBT9YI6wwGRW3Qo1Eg,254
1
+ fin_infra/__init__.py,sha256=5vcHN0u9CE43mnK2VgiMyWTiyANupUISawvxx-2AfqE,480
2
2
  fin_infra/__main__.py,sha256=1qNP7j0ffw0wFs1dBwDcJ9TNXlC6FcYuulzoV87pMi8,262
3
3
  fin_infra/analytics/__init__.py,sha256=aiuNnii0vc34XlNzrSiMbz33lzgmR7W1nHkmAEXavCY,1870
4
4
  fin_infra/analytics/add.py,sha256=a8ZcVc0-3gxq64IFs9fN7XVMayzE6RCCnBWV3zzc7GU,12832
@@ -10,7 +10,7 @@ fin_infra/analytics/projections.py,sha256=7cuG6w1KXq8sd3UNufu5aOcxG5n-foswrHqrgW
10
10
  fin_infra/analytics/rebalancing.py,sha256=K3S7KQiIU2LwyAwWN9VrSly4AOl24vN9tz_JX7I9FJ8,14642
11
11
  fin_infra/analytics/savings.py,sha256=tavIRZtu9FjCm-DeWg5f060GcsdgD-cl-vgKOnieOUw,7574
12
12
  fin_infra/analytics/scenarios.py,sha256=LE_dZVkbxxAx5sxitGhiOhZfWTlYtVbIvS9pEXkijLc,12246
13
- fin_infra/analytics/spending.py,sha256=ypgL52JOsneTsFa2_aFB9fVuu9QWQsImQYChtECeA4Y,25833
13
+ fin_infra/analytics/spending.py,sha256=Md8iOFKkvFbNmIGV56mVDTYJbZ7oNtK-rMAZiNX2j2E,25902
14
14
  fin_infra/banking/__init__.py,sha256=wva1SEyrH2po79YycQ_00ZyC2tVeuO3uYcyvudOW484,22267
15
15
  fin_infra/banking/history.py,sha256=1ufAwkTnXr-QJetFzJl4xA2e3dqd1-TkT8pf46MNfho,10630
16
16
  fin_infra/banking/utils.py,sha256=B2ebnTeUz-56l8XMBWnf2txFOr0bXIo3cKPio7_bhc4,15711
@@ -33,7 +33,7 @@ fin_infra/categorization/__init__.py,sha256=7551OjE668A_Bhm07QSTBkm4PD3uCOEwdz05
33
33
  fin_infra/categorization/add.py,sha256=jbxM51MyIFsAcleCMzP1I5jYV9EsKALzBCnuzKk76sc,6328
34
34
  fin_infra/categorization/ease.py,sha256=NudJBqFByS0YONPn_4O_Q7QYIiVCCgNbAhn-ugJpa0Y,5826
35
35
  fin_infra/categorization/engine.py,sha256=VxVuLym_RkKK0xpZrfLKuksFVoURmXICgdik7KpxXMs,12075
36
- fin_infra/categorization/llm_layer.py,sha256=JkzTTdlUWtiCBzKgNAGWvhR7Qt4-UVc12itD2BlxwlQ,12695
36
+ fin_infra/categorization/llm_layer.py,sha256=kRPtxLyIpCsYjH1NtaIL_WvJX5Y9tOsdDkYbFCTggSU,12706
37
37
  fin_infra/categorization/models.py,sha256=O8ceQOM0ljRh0jkmnjV7CK5Jyq1DI3lG07UTeeMheNg,5931
38
38
  fin_infra/categorization/rules.py,sha256=m3OogJY0hJe5BrmZqOvOKS2-HRdW4Y5jvvtlPDn9Pn8,12884
39
39
  fin_infra/categorization/taxonomy.py,sha256=qsgo7VJkM6GFBBOaTRHWP82vl5SinRKnMsj4ICarEyQ,13281
@@ -51,7 +51,7 @@ fin_infra/credit/__init__.py,sha256=cwCP_WlrG-0yb_L4zYsuzEsSalcfiCY9ItqXfD7Jx9E,
51
51
  fin_infra/credit/add.py,sha256=etRbqw15vzUQfvnMTmznZlLiKy2GVEe8ok08Ea3pjdE,8490
52
52
  fin_infra/credit/experian/__init__.py,sha256=g3IJGvDOMsnB0er0Uwdvl6hGKKTOazqJxSDnB2oIBm0,761
53
53
  fin_infra/credit/experian/auth.py,sha256=SHi3YNPFwEAS_SraAiAK7V-DEokgaq-7-eqkkBrcgMo,5562
54
- fin_infra/credit/experian/client.py,sha256=sxdoB9pyIntyZ9MKDN-x8tUuyllZSOq7KzOrHjeYs8s,8918
54
+ fin_infra/credit/experian/client.py,sha256=crIO37qBoC4wGWH4X_-2cSosf7hX6kfVDQU1NTH58HE,8615
55
55
  fin_infra/credit/experian/parser.py,sha256=7ptdLyTWWqHWqCo1CXn6L7XaIn9ZRRuOaATbFmMZZ64,7489
56
56
  fin_infra/credit/experian/provider.py,sha256=iG2cyftdc7c2pvKWVfeNd3vF_ylNayyhgyUG7Jnl1VI,13766
57
57
  fin_infra/credit/mock.py,sha256=xKWZk3fhuIYRfiZkNc9fbHUNViNKjmOLSj0MTI1f4ik,5356
@@ -64,6 +64,7 @@ fin_infra/documents/ease.py,sha256=rnxEIMjf6vLvD-h5WD4wM6PwmcB4iUtAtnvGxbFA5zA,9
64
64
  fin_infra/documents/models.py,sha256=5MK5Mvs7s6HfNuNldT4xxwLGV4z1f7vNJLwDD-jalgw,6889
65
65
  fin_infra/documents/ocr.py,sha256=cuXzrx6k3GIhiaB4-OMPyroB6GBdXuvXP7LAcs0ZV5o,9596
66
66
  fin_infra/documents/storage.py,sha256=GS_GtUXLMIYqe2yHb9IaQFloRER3xeQ8fla_loozP68,10177
67
+ fin_infra/exceptions.py,sha256=woCazH0RxnGcrmsSA3NMZF4Ygr2dtI4tfzKNiFZ10AA,16953
67
68
  fin_infra/goals/__init__.py,sha256=Vg8LKLlDoRiWHsJX7wu5Zcc-86NNLpHoLTjYVkGi2c4,2130
68
69
  fin_infra/goals/add.py,sha256=cNf0H7EzssMeCYHBWQPW4lHoz1uUWhGMVUUqGMKhNtk,20566
69
70
  fin_infra/goals/funding.py,sha256=6wn25N0VTYfKLzZWhEn0xdC0ft49qdElkQFc9IwmdPk,9334
@@ -79,7 +80,7 @@ fin_infra/insights/__init__.py,sha256=crIXNlztTCcYHNcEVMo8FwCTCUBwIK2wovb4HahzRY
79
80
  fin_infra/insights/aggregator.py,sha256=XG32mN5w5Nc4AZllmfl1esL4q44mFAf0Fvj9mWev_zk,10249
80
81
  fin_infra/insights/models.py,sha256=xov_YV8oBLJt3YdyVjbryRfcXqmGeGiPvZsZHSbvtl8,3202
81
82
  fin_infra/investments/__init__.py,sha256=UiWvTdKH7V9aaqZLunPT1_QGfXBAZbPk_w4QmeLWLqo,6324
82
- fin_infra/investments/add.py,sha256=XjIuXnGY1-tJWeDqsgTpbP2-ruh-Ulbu0IsSlyKRsqw,16416
83
+ fin_infra/investments/add.py,sha256=3cbjXbWoTuDglwk9U48X6768Etv1XLTWysdDPgsn7Yg,17658
83
84
  fin_infra/investments/ease.py,sha256=ocs7xvnZ1u8riFjH9KHi1yFEUF0lfuEcd-QMpsuiOu8,9229
84
85
  fin_infra/investments/models.py,sha256=NHnkvtMa1QYp_TpuuqT4u3cWEJi3OhW-1e-orMuR47o,16107
85
86
  fin_infra/investments/providers/__init__.py,sha256=V1eIzz6EnGJ-pq-9L3S2-evmcExF-YdZfd5P6JMyDtc,383
@@ -115,12 +116,12 @@ fin_infra/net_worth/scaffold_templates/models.py.tmpl,sha256=9BKsoD08RZbSdOm0wFT
115
116
  fin_infra/net_worth/scaffold_templates/repository.py.tmpl,sha256=DSErnNxeAe4pWeefARRK3bU0hHltqdIFffENfVwdd7c,12798
116
117
  fin_infra/net_worth/scaffold_templates/schemas.py.tmpl,sha256=VkFsxyZx4DFDhXDhn-7KT0IgrXCvgaS5ZdWbjyezWj0,4709
117
118
  fin_infra/normalization/__init__.py,sha256=-7EP_lTExQpoCtgsx1wD3j8aMH9y3SlFgHke3mWCQI8,6195
118
- fin_infra/normalization/currency_converter.py,sha256=Bw6i_yHwXSbJ2cWpuQim1xR_AEPewP3_OcATgXLbFJs,6995
119
+ fin_infra/normalization/currency_converter.py,sha256=uuu8ASa5ppEniWLEVEpiDxXjZzln9nopWrhrATcD6Z4,7058
119
120
  fin_infra/normalization/models.py,sha256=gNC9chpbQPRN58V2j__VEPVNReO1N8jH_AHObwGPWu0,1928
120
121
  fin_infra/normalization/providers/__init__.py,sha256=LFU1tB2hVO42Yrkw-IDpPexD4mIlxob9lRrJEeGYqpE,559
121
- fin_infra/normalization/providers/exchangerate.py,sha256=I9R2XS4V1oCrumtivmARrWsO9aqpzcP0_QLVKxP36UU,6222
122
+ fin_infra/normalization/providers/exchangerate.py,sha256=zOTcDYjKDeGpBjplnSB7XVQo_Zt6y0EdSIbzdziLkUs,6298
122
123
  fin_infra/normalization/providers/static_mappings.py,sha256=m14VHmTZipbqrgyE0ABToabVx-pDcyB577LNWrACEUM,6809
123
- fin_infra/normalization/symbol_resolver.py,sha256=HqixB9CYqb73Hv0utmltYMjAjxHtrMmDvE9KVuy7cYY,8064
124
+ fin_infra/normalization/symbol_resolver.py,sha256=M7Li7LFiH4xpvxXcYQlJyk0iqgqpwaj6zQKsTzWZzas,8130
124
125
  fin_infra/obs/__init__.py,sha256=kMMVl0fdwtJtZeKiusTuw0iO61Jo9-HNXsLmn3ffLRE,631
125
126
  fin_infra/obs/classifier.py,sha256=6R2q-w71tk7WfXF5MBPqawxogcj6tILKZPlkpRZNDfg,5083
126
127
  fin_infra/providers/__init__.py,sha256=jxhQm79T6DVXf7Wpy7luL-p50cE_IMUbjt4o3apzJQU,768
@@ -135,9 +136,9 @@ fin_infra/providers/identity/stripe_identity.py,sha256=JQGJRuQdWP5dWDcROgtz1Rrmp
135
136
  fin_infra/providers/market/alphavantage.py,sha256=srZdkf-frBuKyPTdWasMmVrpnh76BEBDXa-nsYtLzNc,8963
136
137
  fin_infra/providers/market/base.py,sha256=ljBzZTfjYQS9tXahmxFic7JQSZeyoiDMUZ1NY0R7yto,108
137
138
  fin_infra/providers/market/ccxt_crypto.py,sha256=sqWu-718mGi7gUTIZKX4huJlMNLEIhpApIRFTBP915g,1054
138
- fin_infra/providers/market/coingecko.py,sha256=nMAtCUZvFmAGmk6nltYcE4INFxj5xaDBofd_s9x300U,2467
139
+ fin_infra/providers/market/coingecko.py,sha256=F1Bwdk28xSsIaFEuT7lhT3F6Vkd0Lp-CMp1rnYiLfaE,2702
139
140
  fin_infra/providers/market/yahoo.py,sha256=FNhqkCFC0In-Z3zpzmuknEORHLRK5Evk2KSk0yysKjg,4954
140
- fin_infra/providers/registry.py,sha256=Qz_vKcTrCpDGRqIQkg6Vr_cXcjp3Z4wel0HBegt3FBc,8287
141
+ fin_infra/providers/registry.py,sha256=yPFmHHaSQERXZTcGkdXAtMU7rL7VwAzW4FOr14o6KS8,8409
141
142
  fin_infra/providers/tax/__init__.py,sha256=Tq2gLyTXL_U_ht6r7HXgaDMCAPylgcRD2ZN-COjSSQU,207
142
143
  fin_infra/providers/tax/irs.py,sha256=f7l6w0byprBszTlCB4ef60K8GrYV-03Dicl1a1Q2oVk,4701
143
144
  fin_infra/providers/tax/mock.py,sha256=35QulDz-fmgXyibPt1cpMhL0WgGWeziOwHnlEd1QRd0,14415
@@ -168,13 +169,12 @@ fin_infra/settings.py,sha256=xitpBQJmuvSy9prQhvXOW1scbwB1KAyGD8XqYgU_hQU,1388
168
169
  fin_infra/tax/__init__.py,sha256=NXUjV-k-rw4774pookY3UOwEXYRQauJze6Yift5RjW0,6107
169
170
  fin_infra/tax/add.py,sha256=xmy0hXsWzEj5p-_9A5hkljFjF_FpnbCQQZ5e8FPChBI,14568
170
171
  fin_infra/tax/tlh.py,sha256=QFnepLuJW8L71SccRTE54eN5ILyDGs1XNG5p-aVM_b8,21543
171
- fin_infra/utils/__init__.py,sha256=ejo_7IRD7wgQh_y9ANlIIRS2owtqiRbYgiw0sPxmBg0,457
172
+ fin_infra/utils/__init__.py,sha256=gKacLSWMAis--pasd8AuVN7ap0e9Z1TjRGur0J23EDo,648
172
173
  fin_infra/utils/http.py,sha256=wgXo5amXyzAX49v_lRUvp4Xxq8nodX32CMJyWl6u89I,568
173
- fin_infra/utils/retry.py,sha256=VxT4ssP4r8Krl3KThvI-opPMhGCpZUCH4rUyit1LEUk,967
174
- fin_infra/utils.py,sha256=VxT4ssP4r8Krl3KThvI-opPMhGCpZUCH4rUyit1LEUk,967
174
+ fin_infra/utils/retry.py,sha256=p4i4heGdHkLsqLHuHY4riwOkuLjbbfbUE8cA4t3UAgQ,1052
175
175
  fin_infra/version.py,sha256=4t_crzhrLum--oyowUMxtjBTzUtWp7oRTF22ewEvJG4,49
176
- fin_infra-0.1.58.dist-info/LICENSE,sha256=wK-Ya7Ylxa38dSIZRhvNj1ZVLIrHC-BAI8v38PNADiA,1061
177
- fin_infra-0.1.58.dist-info/METADATA,sha256=YnC_rmHC_X1zvZu2BKMVDXDKABUbI5GeODWLWnLXSqQ,10182
178
- fin_infra-0.1.58.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
179
- fin_infra-0.1.58.dist-info/entry_points.txt,sha256=Sr1uikvALZMeKm-DIkeKG4L9c4SNqysXGO_IRF8_9eU,53
180
- fin_infra-0.1.58.dist-info/RECORD,,
176
+ fin_infra-0.1.60.dist-info/LICENSE,sha256=wK-Ya7Ylxa38dSIZRhvNj1ZVLIrHC-BAI8v38PNADiA,1061
177
+ fin_infra-0.1.60.dist-info/METADATA,sha256=EmECBGkQ-fNJIJrOuC52HMfQm4HBUXFoJ16CvQOXYNM,10182
178
+ fin_infra-0.1.60.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
179
+ fin_infra-0.1.60.dist-info/entry_points.txt,sha256=Sr1uikvALZMeKm-DIkeKG4L9c4SNqysXGO_IRF8_9eU,53
180
+ fin_infra-0.1.60.dist-info/RECORD,,
fin_infra/utils.py DELETED
@@ -1,36 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import asyncio
4
- import random
5
- from typing import Awaitable, Callable, Iterable, TypeVar
6
-
7
- T = TypeVar("T")
8
-
9
-
10
- class RetryError(Exception):
11
- pass
12
-
13
-
14
- async def retry_async(
15
- func: Callable[[], Awaitable[T]],
16
- *,
17
- attempts: int = 3,
18
- base_delay: float = 0.2,
19
- jitter: float = 0.1,
20
- retry_on: Iterable[type[BaseException]] = (Exception,),
21
- ) -> T:
22
- """Simple async retry with exponential backoff and jitter.
23
-
24
- Not provider-specific; callers should keep idempotency in mind.
25
- """
26
- last_exc: BaseException | None = None
27
- for i in range(attempts):
28
- try:
29
- return await func()
30
- except tuple(retry_on) as exc: # type: ignore[misc]
31
- last_exc = exc
32
- if i == attempts - 1:
33
- break
34
- delay = (2**i) * base_delay + random.uniform(0, jitter)
35
- await asyncio.sleep(delay)
36
- raise RetryError("Retry attempts exhausted") from last_exc