alpha-engine-lib 0.47.0__tar.gz → 0.48.0__tar.gz

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 (97) hide show
  1. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/PKG-INFO +6 -2
  2. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/README.md +4 -0
  3. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/pyproject.toml +2 -2
  4. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/__init__.py +1 -1
  5. alpha_engine_lib-0.48.0/src/alpha_engine_lib/http_retry.py +199 -0
  6. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib.egg-info/PKG-INFO +6 -2
  7. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib.egg-info/SOURCES.txt +2 -0
  8. alpha_engine_lib-0.48.0/tests/test_http_retry.py +199 -0
  9. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/setup.cfg +0 -0
  10. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/agent_schemas.py +0 -0
  11. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/alerts.py +0 -0
  12. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/anthropic_payload.py +0 -0
  13. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/arcticdb.py +0 -0
  14. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/artifact_freshness.py +0 -0
  15. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/collector_results.py +0 -0
  16. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/cost.py +0 -0
  17. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/dates.py +0 -0
  18. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/decision_capture.py +0 -0
  19. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/ec2_spot.py +0 -0
  20. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/email_sender.py +0 -0
  21. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/eval_artifacts.py +0 -0
  22. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/locks.py +0 -0
  23. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/logging.py +0 -0
  24. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/model_pricing.yaml +0 -0
  25. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/pillars.py +0 -0
  26. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/pipeline_status/__init__.py +0 -0
  27. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/pipeline_status/read.py +0 -0
  28. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/pipeline_status/registry.py +0 -0
  29. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/pipeline_status/templates.py +0 -0
  30. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/preflight.py +0 -0
  31. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/quant/__init__.py +0 -0
  32. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/quant/attribution.py +0 -0
  33. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/quant/factor_risk.py +0 -0
  34. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/quant/factor_risk_xs.py +0 -0
  35. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/quant/returns.py +0 -0
  36. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/quant/risk_measures.py +0 -0
  37. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/quant/riskstats.py +0 -0
  38. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/rag/__init__.py +0 -0
  39. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/rag/db.py +0 -0
  40. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/rag/embeddings.py +0 -0
  41. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/rag/migrations/0001_content_tsv.sql +0 -0
  42. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/rag/rerank.py +0 -0
  43. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/rag/retrieval.py +0 -0
  44. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/rag/schema.sql +0 -0
  45. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/reconcile.py +0 -0
  46. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/secrets.py +0 -0
  47. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/sources/__init__.py +0 -0
  48. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/sources/protocols.py +0 -0
  49. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/ssm_dispatcher.py +0 -0
  50. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/ssm_log_capture.py +0 -0
  51. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/telegram.py +0 -0
  52. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/trading_calendar.py +0 -0
  53. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/transparency.py +0 -0
  54. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/transparency_inventory.yaml +0 -0
  55. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib/universe.py +0 -0
  56. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib.egg-info/dependency_links.txt +0 -0
  57. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib.egg-info/requires.txt +0 -0
  58. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/src/alpha_engine_lib.egg-info/top_level.txt +0 -0
  59. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_agent_schemas.py +0 -0
  60. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_alerts.py +0 -0
  61. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_anthropic_payload.py +0 -0
  62. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_arcticdb.py +0 -0
  63. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_artifact_freshness.py +0 -0
  64. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_collector_results.py +0 -0
  65. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_cost.py +0 -0
  66. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_dates.py +0 -0
  67. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_decision_capture.py +0 -0
  68. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_ec2_spot.py +0 -0
  69. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_email_sender.py +0 -0
  70. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_eval_artifacts.py +0 -0
  71. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_locks.py +0 -0
  72. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_logging.py +0 -0
  73. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_pillars.py +0 -0
  74. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_pipeline_status_read.py +0 -0
  75. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_pipeline_status_registry.py +0 -0
  76. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_pipeline_status_templates.py +0 -0
  77. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_preflight.py +0 -0
  78. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_quant_attribution.py +0 -0
  79. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_quant_factor_risk.py +0 -0
  80. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_quant_factor_risk_xs.py +0 -0
  81. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_quant_returns.py +0 -0
  82. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_quant_risk_measures.py +0 -0
  83. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_quant_riskstats.py +0 -0
  84. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_rag.py +0 -0
  85. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_rag_rerank.py +0 -0
  86. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_rag_retrieval_hybrid.py +0 -0
  87. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_reconcile.py +0 -0
  88. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_secrets.py +0 -0
  89. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_sources_protocols.py +0 -0
  90. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_ssm_dispatcher.py +0 -0
  91. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_ssm_log_capture.py +0 -0
  92. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_telegram.py +0 -0
  93. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_trading_calendar.py +0 -0
  94. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_transparency.py +0 -0
  95. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_universe.py +0 -0
  96. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_version_bump_workflow.py +0 -0
  97. {alpha_engine_lib-0.47.0 → alpha_engine_lib-0.48.0}/tests/test_version_pin.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpha-engine-lib
3
- Version: 0.47.0
4
- Summary: Shared utilities for the Alpha Engine modules: preflight, logging, ArcticDB, dates, decision capture, cost telemetry, Anthropic payload chokepoint, artifact freshness, RAG, agent schemas, SSM secrets, Telegram + SNS alerts, EC2 spot resilience, SSM log-capture, SSM dispatcher, Step-Functions execution-state projection, and S3-conditional-PUT writer locks. Full surface documented in README.
3
+ Version: 0.48.0
4
+ Summary: Shared utilities for the Alpha Engine modules: preflight, logging, ArcticDB, dates, decision capture, cost telemetry, Anthropic payload chokepoint, artifact freshness, RAG, agent schemas, SSM secrets, Telegram + SNS alerts, EC2 spot resilience, SSM log-capture, SSM dispatcher, Step-Functions execution-state projection, S3-conditional-PUT writer locks, and bounded-backoff HTTP retry. Full surface documented in README.
5
5
  Author: Brian McMahon
6
6
  License: Proprietary
7
7
  Requires-Python: >=3.9
@@ -265,6 +265,10 @@ The shared institutional-analytics engine: pure, front-end- and data-source-agno
265
265
  - **`quant.returns`** — `xirr` (money-weighted, Newton + bisection), `time_weighted_return` (GIPS), `cumulative_return`, `annualize` (stdlib).
266
266
  - **`quant.attribution`** — single-period Brinson-Fachler decomposition (`brinson_fachler`) + multi-period Cariño linking (`link_periods`) (stdlib).
267
267
 
268
+ ### `http_retry` — bounded-backoff transient-API retry chokepoint
269
+
270
+ `request_with_retry(url, *, params, session, transient_status, ...)` returns the final `requests.Response` after retrying the transient class — 429 + 5xx responses (honoring `Retry-After`) and `Timeout`/`ConnectionError` network errors — with exponential backoff + full jitter; an exhausted network error raises `HttpRetryError` (api-key-scrubbed), while a persistent transient-status response is returned for the caller to interpret (so a 403, not in the transient set, is handed back for e.g. polygon's `PolygonForbiddenError` conversion). Also exposes the low-level `backoff_delay(attempt, *, base, cap, retry_after)` and `scrub_api_keys(msg)` (masks `api_key=`/`apiKey=` querystring values) for consumers with bespoke loops (the rate-limited `polygon_client` keeps its own loop + 403 + JSON parse and reuses just the delay math + scrubber). Consolidates the four mirrored alpha-engine-data retry sites (FRED fetch, polygon client, preflight reachability, FRED repair) into one policy so they stop drifting (L4499). Stdlib + `requests` only.
271
+
268
272
  ```python
269
273
  from alpha_engine_lib.quant.risk_measures import historical_cvar
270
274
  from alpha_engine_lib.quant.factor_risk import estimate_factor_model, portfolio_risk
@@ -230,6 +230,10 @@ The shared institutional-analytics engine: pure, front-end- and data-source-agno
230
230
  - **`quant.returns`** — `xirr` (money-weighted, Newton + bisection), `time_weighted_return` (GIPS), `cumulative_return`, `annualize` (stdlib).
231
231
  - **`quant.attribution`** — single-period Brinson-Fachler decomposition (`brinson_fachler`) + multi-period Cariño linking (`link_periods`) (stdlib).
232
232
 
233
+ ### `http_retry` — bounded-backoff transient-API retry chokepoint
234
+
235
+ `request_with_retry(url, *, params, session, transient_status, ...)` returns the final `requests.Response` after retrying the transient class — 429 + 5xx responses (honoring `Retry-After`) and `Timeout`/`ConnectionError` network errors — with exponential backoff + full jitter; an exhausted network error raises `HttpRetryError` (api-key-scrubbed), while a persistent transient-status response is returned for the caller to interpret (so a 403, not in the transient set, is handed back for e.g. polygon's `PolygonForbiddenError` conversion). Also exposes the low-level `backoff_delay(attempt, *, base, cap, retry_after)` and `scrub_api_keys(msg)` (masks `api_key=`/`apiKey=` querystring values) for consumers with bespoke loops (the rate-limited `polygon_client` keeps its own loop + 403 + JSON parse and reuses just the delay math + scrubber). Consolidates the four mirrored alpha-engine-data retry sites (FRED fetch, polygon client, preflight reachability, FRED repair) into one policy so they stop drifting (L4499). Stdlib + `requests` only.
236
+
233
237
  ```python
234
238
  from alpha_engine_lib.quant.risk_measures import historical_cvar
235
239
  from alpha_engine_lib.quant.factor_risk import estimate_factor_model, portfolio_risk
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "alpha-engine-lib"
7
- version = "0.47.0"
8
- description = "Shared utilities for the Alpha Engine modules: preflight, logging, ArcticDB, dates, decision capture, cost telemetry, Anthropic payload chokepoint, artifact freshness, RAG, agent schemas, SSM secrets, Telegram + SNS alerts, EC2 spot resilience, SSM log-capture, SSM dispatcher, Step-Functions execution-state projection, and S3-conditional-PUT writer locks. Full surface documented in README."
7
+ version = "0.48.0"
8
+ description = "Shared utilities for the Alpha Engine modules: preflight, logging, ArcticDB, dates, decision capture, cost telemetry, Anthropic payload chokepoint, artifact freshness, RAG, agent schemas, SSM secrets, Telegram + SNS alerts, EC2 spot resilience, SSM log-capture, SSM dispatcher, Step-Functions execution-state projection, S3-conditional-PUT writer locks, and bounded-backoff HTTP retry. Full surface documented in README."
9
9
  readme = "README.md"
10
10
  # EC2 still runs Python 3.9 on the always-on micro instance (boto3 drops
11
11
  # 3.9 support 2026-04-29, so upgrade is on the near-term roadmap). All
@@ -1,3 +1,3 @@
1
1
  """alpha-engine-lib — shared utilities for Alpha Engine modules."""
2
2
 
3
- __version__ = "0.47.0"
3
+ __version__ = "0.48.0"
@@ -0,0 +1,199 @@
1
+ """Bounded-backoff HTTP retry primitive — the transient external-API
2
+ resilience chokepoint (L4499).
3
+
4
+ Consolidates the backoff + full-jitter + ``Retry-After`` + api-key-scrub
5
+ retry idiom that was mirrored across four alpha-engine-data sites:
6
+
7
+ * ``collectors/daily_closes.py::_fred_get_with_retry`` (L4480)
8
+ * ``polygon_client.py::_get`` / ``_backoff`` (L4496)
9
+ * ``preflight.py::_reachability_get`` (L4494)
10
+ * ``collectors/daily_closes_fred_repair.py::_fetch_fred_range``
11
+
12
+ Each had its own copy of "exponential backoff + full jitter, honor
13
+ ``Retry-After``, retry the transient class, scrub the api-key from the
14
+ error before logging/raising, then fail loud." This module is the single
15
+ source of truth for that policy so the four callsites stop drifting.
16
+
17
+ Two layers are exported:
18
+
19
+ * :func:`request_with_retry` — the full GET-with-retry for the plain
20
+ callsites (FRED fetch, preflight probe, FRED repair). Returns the final
21
+ ``requests.Response``; the caller still owns status interpretation
22
+ (``raise_for_status`` / special-casing a 403), so genuinely different
23
+ consumers compose it without a leaky mega-config.
24
+ * :func:`backoff_delay` + :func:`scrub_api_keys` — the low-level pieces for
25
+ a consumer with bespoke control flow (the rate-limited ``polygon_client``
26
+ keeps its own loop + 403 handling + JSON parse + rate limiter, but shares
27
+ the delay math and the scrubber).
28
+
29
+ Design note (anti-over-engineering): this is deliberately NOT a
30
+ pluggable-everything HTTP framework. It captures the one invariant the four
31
+ sites share; consumers whose semantics diverge (polygon's 403 + rate limiter)
32
+ reuse the primitives rather than being forced through a generic loop.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import logging as _logging
38
+ import random as _random
39
+ import re
40
+ import time as _time
41
+ from typing import Callable, Iterable
42
+
43
+ import requests
44
+
45
+ _DEFAULT_LOGGER = _logging.getLogger(__name__)
46
+
47
+ # Transient HTTP status class: 429 (rate limit) + the retryable 5xx. A 4xx
48
+ # other than 429 is a deterministic client error — retrying it is pointless,
49
+ # so it is NOT in the default set and is returned to the caller as-is.
50
+ DEFAULT_TRANSIENT_STATUS: "frozenset[int]" = frozenset({429, 500, 502, 503, 504})
51
+
52
+ # Mask FRED ``api_key=`` (snake) and polygon ``apiKey=`` (camel) querystring
53
+ # VALUES — both leak via ``requests`` exception ``str()`` (the effective URL)
54
+ # and via hand-built error strings. Mirrors the per-repo scrubbers this module
55
+ # replaces; complements ``alpha_engine_lib.logging.SecretsRedactingFilter``
56
+ # (which catches token-shaped secrets, not query-param api keys).
57
+ _API_KEY_RE = re.compile(r"(?:api_key|apiKey)=[^&\s]+")
58
+
59
+
60
+ def scrub_api_keys(msg: object) -> str:
61
+ """Mask ``api_key=...`` / ``apiKey=...`` querystring values in a string.
62
+
63
+ Preserves the key NAME (so logs still show *which* param) and the value
64
+ delimiter, replacing only the secret value with ``***``. Idempotent.
65
+ """
66
+ return _API_KEY_RE.sub(lambda m: m.group(0).split("=", 1)[0] + "=***", str(msg))
67
+
68
+
69
+ class HttpRetryError(RuntimeError):
70
+ """Raised when all attempts are exhausted on a transient NETWORK error
71
+ (``requests.Timeout`` / ``requests.ConnectionError``) or a non-transient
72
+ ``RequestException``.
73
+
74
+ The message is api-key-scrubbed. The originating exception is preserved
75
+ as ``__cause__`` (and on ``.last_exc``); ``.label`` / ``.attempts`` carry
76
+ context for callers that want to re-wrap (e.g. preflight's
77
+ ``RuntimeError(... unreachable ...)``).
78
+ """
79
+
80
+ def __init__(self, label: str, attempts: int, last_exc: BaseException) -> None:
81
+ self.label = label
82
+ self.attempts = attempts
83
+ self.last_exc = last_exc
84
+ super().__init__(
85
+ scrub_api_keys(
86
+ f"{label or 'request'} failed after {attempts} attempt(s): {last_exc}"
87
+ )
88
+ )
89
+
90
+
91
+ def backoff_delay(
92
+ attempt: int,
93
+ *,
94
+ base: float = 1.0,
95
+ cap: float = 30.0,
96
+ retry_after: "str | float | None" = None,
97
+ rng: "_random.Random | None" = None,
98
+ ) -> float:
99
+ """Full-jitter exponential backoff: ``min(base*2**attempt + U(0, base), cap)``.
100
+
101
+ ``attempt`` is 0-indexed. Honors a server ``Retry-After`` (seconds, str or
102
+ float) when supplied — a numeric value replaces the exponential term (still
103
+ + jitter, still capped); a non-numeric ``Retry-After`` (HTTP-date form)
104
+ falls back to the exponential term. ``rng`` is injectable for deterministic
105
+ tests.
106
+ """
107
+ wait: "float | None" = None
108
+ if retry_after is not None:
109
+ try:
110
+ wait = float(retry_after)
111
+ except (TypeError, ValueError):
112
+ wait = None
113
+ if wait is None:
114
+ wait = base * (2 ** attempt)
115
+ jitter = (rng or _random).uniform(0, base)
116
+ return min(wait + jitter, cap)
117
+
118
+
119
+ def request_with_retry(
120
+ url: str,
121
+ *,
122
+ method: str = "GET",
123
+ params: "dict | None" = None,
124
+ session: "requests.Session | None" = None,
125
+ timeout: float = 15.0,
126
+ max_attempts: int = 3,
127
+ backoff_base: float = 1.0,
128
+ backoff_cap: float = 30.0,
129
+ transient_status: Iterable[int] = DEFAULT_TRANSIENT_STATUS,
130
+ retry_network: bool = True,
131
+ honor_retry_after: bool = True,
132
+ scrub: Callable[[object], str] = scrub_api_keys,
133
+ logger: "_logging.Logger | None" = None,
134
+ label: str = "",
135
+ sleep: Callable[[float], None] = _time.sleep,
136
+ ) -> requests.Response:
137
+ """``method`` ``url`` with bounded backoff + full jitter on the transient
138
+ class, returning the final :class:`requests.Response`.
139
+
140
+ Retries:
141
+ * responses whose status is in ``transient_status`` (default 429 + 5xx),
142
+ honoring ``Retry-After`` when ``honor_retry_after``; and
143
+ * (when ``retry_network``) ``requests.Timeout`` / ``ConnectionError``.
144
+
145
+ Terminal behavior:
146
+ * a transient-status response that survives ``max_attempts`` is
147
+ **returned** — the caller decides whether to ``raise_for_status`` or
148
+ special-case it (e.g. a 403, which is NOT in the transient set, is
149
+ returned immediately for the caller to convert); and
150
+ * an exhausted NETWORK error (or a non-transient ``RequestException``
151
+ such as a bad URL) raises :class:`HttpRetryError` (scrubbed).
152
+
153
+ ``scrub`` is applied to every error string logged or raised. ``session``
154
+ lets a caller reuse a session (e.g. one carrying auth query params).
155
+ ``sleep`` is injectable for tests. ``max_attempts`` must be >= 1.
156
+ """
157
+ if max_attempts < 1:
158
+ raise ValueError(f"max_attempts must be >= 1, got {max_attempts}")
159
+ log = logger or _DEFAULT_LOGGER
160
+ transient = frozenset(transient_status)
161
+ requester = (session or requests).request
162
+ resp: "requests.Response | None" = None
163
+ for attempt in range(max_attempts):
164
+ last = attempt == max_attempts - 1
165
+ try:
166
+ resp = requester(method, url, params=params or {}, timeout=timeout)
167
+ except (requests.Timeout, requests.ConnectionError) as exc:
168
+ if not retry_network or last:
169
+ raise HttpRetryError(label, attempt + 1, exc) from exc
170
+ delay = backoff_delay(attempt, base=backoff_base, cap=backoff_cap)
171
+ log.warning(
172
+ "%s transient %s — backing off %.1fs (attempt %d/%d)",
173
+ label or url, type(exc).__name__, delay, attempt + 1, max_attempts,
174
+ )
175
+ sleep(delay)
176
+ continue
177
+ except requests.RequestException as exc:
178
+ # Non-transient (bad URL / too many redirects / invalid schema) —
179
+ # retrying a deterministic error is pointless; fail loud now.
180
+ raise HttpRetryError(label, attempt + 1, exc) from exc
181
+
182
+ if resp.status_code in transient and not last:
183
+ retry_after = resp.headers.get("Retry-After") if honor_retry_after else None
184
+ delay = backoff_delay(
185
+ attempt, base=backoff_base, cap=backoff_cap, retry_after=retry_after,
186
+ )
187
+ log.warning(
188
+ "%s HTTP %d — backing off %.1fs (attempt %d/%d)",
189
+ label or url, resp.status_code, delay, attempt + 1, max_attempts,
190
+ )
191
+ sleep(delay)
192
+ continue
193
+ return resp
194
+
195
+ # Loop exhausted on transient-status responses: return the last one for the
196
+ # caller to interpret (network exhaustion already raised above). resp is
197
+ # non-None because max_attempts >= 1 guarantees at least one assignment.
198
+ assert resp is not None
199
+ return resp
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpha-engine-lib
3
- Version: 0.47.0
4
- Summary: Shared utilities for the Alpha Engine modules: preflight, logging, ArcticDB, dates, decision capture, cost telemetry, Anthropic payload chokepoint, artifact freshness, RAG, agent schemas, SSM secrets, Telegram + SNS alerts, EC2 spot resilience, SSM log-capture, SSM dispatcher, Step-Functions execution-state projection, and S3-conditional-PUT writer locks. Full surface documented in README.
3
+ Version: 0.48.0
4
+ Summary: Shared utilities for the Alpha Engine modules: preflight, logging, ArcticDB, dates, decision capture, cost telemetry, Anthropic payload chokepoint, artifact freshness, RAG, agent schemas, SSM secrets, Telegram + SNS alerts, EC2 spot resilience, SSM log-capture, SSM dispatcher, Step-Functions execution-state projection, S3-conditional-PUT writer locks, and bounded-backoff HTTP retry. Full surface documented in README.
5
5
  Author: Brian McMahon
6
6
  License: Proprietary
7
7
  Requires-Python: >=3.9
@@ -265,6 +265,10 @@ The shared institutional-analytics engine: pure, front-end- and data-source-agno
265
265
  - **`quant.returns`** — `xirr` (money-weighted, Newton + bisection), `time_weighted_return` (GIPS), `cumulative_return`, `annualize` (stdlib).
266
266
  - **`quant.attribution`** — single-period Brinson-Fachler decomposition (`brinson_fachler`) + multi-period Cariño linking (`link_periods`) (stdlib).
267
267
 
268
+ ### `http_retry` — bounded-backoff transient-API retry chokepoint
269
+
270
+ `request_with_retry(url, *, params, session, transient_status, ...)` returns the final `requests.Response` after retrying the transient class — 429 + 5xx responses (honoring `Retry-After`) and `Timeout`/`ConnectionError` network errors — with exponential backoff + full jitter; an exhausted network error raises `HttpRetryError` (api-key-scrubbed), while a persistent transient-status response is returned for the caller to interpret (so a 403, not in the transient set, is handed back for e.g. polygon's `PolygonForbiddenError` conversion). Also exposes the low-level `backoff_delay(attempt, *, base, cap, retry_after)` and `scrub_api_keys(msg)` (masks `api_key=`/`apiKey=` querystring values) for consumers with bespoke loops (the rate-limited `polygon_client` keeps its own loop + 403 + JSON parse and reuses just the delay math + scrubber). Consolidates the four mirrored alpha-engine-data retry sites (FRED fetch, polygon client, preflight reachability, FRED repair) into one policy so they stop drifting (L4499). Stdlib + `requests` only.
271
+
268
272
  ```python
269
273
  from alpha_engine_lib.quant.risk_measures import historical_cvar
270
274
  from alpha_engine_lib.quant.factor_risk import estimate_factor_model, portfolio_risk
@@ -13,6 +13,7 @@ src/alpha_engine_lib/decision_capture.py
13
13
  src/alpha_engine_lib/ec2_spot.py
14
14
  src/alpha_engine_lib/email_sender.py
15
15
  src/alpha_engine_lib/eval_artifacts.py
16
+ src/alpha_engine_lib/http_retry.py
16
17
  src/alpha_engine_lib/locks.py
17
18
  src/alpha_engine_lib/logging.py
18
19
  src/alpha_engine_lib/model_pricing.yaml
@@ -64,6 +65,7 @@ tests/test_decision_capture.py
64
65
  tests/test_ec2_spot.py
65
66
  tests/test_email_sender.py
66
67
  tests/test_eval_artifacts.py
68
+ tests/test_http_retry.py
67
69
  tests/test_locks.py
68
70
  tests/test_logging.py
69
71
  tests/test_pillars.py
@@ -0,0 +1,199 @@
1
+ """Tests for ``alpha_engine_lib.http_retry`` — the consolidated transient
2
+ external-API retry primitive (L4499)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import random
7
+ from unittest.mock import MagicMock, patch
8
+
9
+ import pytest
10
+ import requests
11
+
12
+ from alpha_engine_lib import http_retry
13
+ from alpha_engine_lib.http_retry import (
14
+ HttpRetryError,
15
+ backoff_delay,
16
+ request_with_retry,
17
+ scrub_api_keys,
18
+ )
19
+
20
+
21
+ # ── scrub_api_keys ───────────────────────────────────────────────────────────
22
+
23
+
24
+ def test_scrub_masks_both_styles():
25
+ assert scrub_api_keys("x?api_key=SECRET&y=1") == "x?api_key=***&y=1"
26
+ assert scrub_api_keys("x?apiKey=SECRET&y=1") == "x?apiKey=***&y=1"
27
+
28
+
29
+ def test_scrub_terminates_at_ampersand():
30
+ assert scrub_api_keys("u?apiKey=SECRET&file_type=json") == "u?apiKey=***&file_type=json"
31
+
32
+
33
+ def test_scrub_passthrough_and_idempotent():
34
+ assert scrub_api_keys("no key here") == "no key here"
35
+ once = scrub_api_keys("?api_key=SECRET")
36
+ assert scrub_api_keys(once) == once == "?api_key=***"
37
+
38
+
39
+ def test_scrub_accepts_exception_object():
40
+ exc = requests.HTTPError("500 for url: https://x/?apiKey=LEAKED&a=1")
41
+ out = scrub_api_keys(exc)
42
+ assert "LEAKED" not in out and "apiKey=***" in out
43
+
44
+
45
+ # ── backoff_delay ────────────────────────────────────────────────────────────
46
+
47
+
48
+ def test_backoff_grows_exponentially_and_caps():
49
+ rng = MagicMock()
50
+ rng.uniform.return_value = 0.0 # zero jitter → deterministic
51
+ assert backoff_delay(0, base=1.0, cap=30.0, rng=rng) == 1.0
52
+ assert backoff_delay(1, base=1.0, cap=30.0, rng=rng) == 2.0
53
+ assert backoff_delay(2, base=1.0, cap=30.0, rng=rng) == 4.0
54
+ assert backoff_delay(10, base=1.0, cap=30.0, rng=rng) == 30.0 # capped
55
+
56
+
57
+ def test_backoff_jitter_is_bounded():
58
+ for attempt in range(4):
59
+ d = backoff_delay(attempt, base=1.0, cap=100.0, rng=random.Random(attempt))
60
+ base_term = 1.0 * (2 ** attempt)
61
+ assert base_term <= d <= base_term + 1.0
62
+
63
+
64
+ def test_backoff_honors_numeric_retry_after():
65
+ rng = MagicMock(); rng.uniform.return_value = 0.0
66
+ # Retry-After replaces the exponential term.
67
+ assert backoff_delay(3, base=1.0, cap=100.0, retry_after="12", rng=rng) == 12.0
68
+ assert backoff_delay(3, base=1.0, cap=100.0, retry_after=7.5, rng=rng) == 7.5
69
+
70
+
71
+ def test_backoff_non_numeric_retry_after_falls_back():
72
+ rng = MagicMock(); rng.uniform.return_value = 0.0
73
+ # HTTP-date form → not parseable → exponential term (2**1 = 2).
74
+ assert backoff_delay(1, base=1.0, cap=100.0, retry_after="Wed, 21 Oct 2026 07:28:00 GMT", rng=rng) == 2.0
75
+
76
+
77
+ # ── request_with_retry ───────────────────────────────────────────────────────
78
+
79
+
80
+ def _resp(status: int, headers: "dict | None" = None) -> MagicMock:
81
+ r = MagicMock(spec=requests.Response)
82
+ r.status_code = status
83
+ r.headers = headers or {}
84
+ return r
85
+
86
+
87
+ def _session(*side_effect):
88
+ s = MagicMock()
89
+ s.request.side_effect = list(side_effect)
90
+ return s
91
+
92
+
93
+ _NOSLEEP = lambda *_a, **_k: None # noqa: E731
94
+
95
+
96
+ def test_success_first_try_returns_response():
97
+ s = _session(_resp(200))
98
+ out = request_with_retry("https://x", session=s, sleep=_NOSLEEP, label="x")
99
+ assert out.status_code == 200
100
+ assert s.request.call_count == 1
101
+
102
+
103
+ def test_5xx_retries_then_succeeds():
104
+ s = _session(_resp(500), _resp(200))
105
+ out = request_with_retry("https://x", session=s, max_attempts=3, sleep=_NOSLEEP)
106
+ assert out.status_code == 200
107
+ assert s.request.call_count == 2
108
+
109
+
110
+ def test_5xx_exhausted_returns_last_response():
111
+ # A persistent transient status is RETURNED (caller does raise_for_status).
112
+ s = _session(_resp(503), _resp(503), _resp(503))
113
+ out = request_with_retry("https://x", session=s, max_attempts=3, sleep=_NOSLEEP)
114
+ assert out.status_code == 503
115
+ assert s.request.call_count == 3
116
+
117
+
118
+ def test_429_honors_retry_after_header():
119
+ delays = []
120
+ s = _session(_resp(429, {"Retry-After": "5"}), _resp(200))
121
+ request_with_retry(
122
+ "https://x", session=s, max_attempts=3,
123
+ sleep=lambda d: delays.append(d),
124
+ )
125
+ # The single backoff used Retry-After=5 (+ jitter in [0,1)).
126
+ assert len(delays) == 1 and 5.0 <= delays[0] < 6.0
127
+
128
+
129
+ def test_non_transient_status_returned_immediately():
130
+ # 403 is not in the transient set → returned at once for the caller (e.g.
131
+ # polygon's PolygonForbiddenError conversion), no retry.
132
+ s = _session(_resp(403))
133
+ out = request_with_retry("https://x", session=s, max_attempts=3, sleep=_NOSLEEP)
134
+ assert out.status_code == 403
135
+ assert s.request.call_count == 1
136
+
137
+
138
+ def test_network_error_retries_then_succeeds():
139
+ s = _session(requests.ConnectionError("boom"), _resp(200))
140
+ out = request_with_retry("https://x", session=s, max_attempts=3, sleep=_NOSLEEP)
141
+ assert out.status_code == 200
142
+ assert s.request.call_count == 2
143
+
144
+
145
+ def test_network_error_exhausted_raises_scrubbed():
146
+ s = _session(requests.Timeout("read timed out ?apiKey=LEAKED"),
147
+ requests.Timeout("read timed out ?apiKey=LEAKED"))
148
+ with pytest.raises(HttpRetryError) as ei:
149
+ request_with_retry("https://x", session=s, max_attempts=2, sleep=_NOSLEEP, label="polygon")
150
+ assert "LEAKED" not in str(ei.value)
151
+ assert ei.value.attempts == 2
152
+ assert isinstance(ei.value.last_exc, requests.Timeout)
153
+ assert "polygon" in str(ei.value)
154
+
155
+
156
+ def test_retry_network_false_raises_on_first_network_error():
157
+ s = _session(requests.ConnectionError("boom"))
158
+ with pytest.raises(HttpRetryError):
159
+ request_with_retry("https://x", session=s, retry_network=False, sleep=_NOSLEEP)
160
+ assert s.request.call_count == 1
161
+
162
+
163
+ def test_non_transient_request_exception_raises_immediately():
164
+ s = _session(requests.TooManyRedirects("loop"))
165
+ with pytest.raises(HttpRetryError):
166
+ request_with_retry("https://x", session=s, max_attempts=3, sleep=_NOSLEEP)
167
+ assert s.request.call_count == 1 # no retry on a deterministic error
168
+
169
+
170
+ def test_max_attempts_must_be_positive():
171
+ with pytest.raises(ValueError, match="max_attempts must be >= 1"):
172
+ request_with_retry("https://x", session=_session(_resp(200)), max_attempts=0)
173
+
174
+
175
+ def test_default_session_uses_requests_module():
176
+ # session=None path uses the module-level requests.request.
177
+ with patch.object(http_retry.requests, "request", return_value=_resp(200)) as m:
178
+ out = request_with_retry("https://x", sleep=_NOSLEEP)
179
+ assert out.status_code == 200
180
+ m.assert_called_once()
181
+ # method + url threaded positionally; params/timeout as kwargs.
182
+ args, kwargs = m.call_args
183
+ assert args[0] == "GET" and args[1] == "https://x"
184
+ assert "timeout" in kwargs
185
+
186
+
187
+ def test_custom_transient_status_set():
188
+ # Caller can widen/narrow the retry class; 418 retried here, 500 not.
189
+ s = _session(_resp(418), _resp(200))
190
+ out = request_with_retry(
191
+ "https://x", session=s, transient_status={418}, max_attempts=3, sleep=_NOSLEEP,
192
+ )
193
+ assert out.status_code == 200 and s.request.call_count == 2
194
+
195
+ s2 = _session(_resp(500))
196
+ out2 = request_with_retry(
197
+ "https://x", session=s2, transient_status={418}, max_attempts=3, sleep=_NOSLEEP,
198
+ )
199
+ assert out2.status_code == 500 and s2.request.call_count == 1 # 500 not transient here