From edefd90aaa20c1fe4c2548ebf766d1f6f8b3cc06 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 6 Aug 2026 23:06:29 -0400 Subject: [PATCH] oanda: retry once on transient 401/5xx/network faults in candle fetch The practice API rejects ~15-20 requests/day with a spurious 401 "Insufficient authorization" while the same token succeeds on adjacent requests; each one silently cost a full scan cycle on that timeframe. One retry after RETRY_DELAY_S (2 s) absorbs these plus transient 5xx/network faults. Real credential failures fail both attempts and raise as before; other 4xx (bad request, 429) are never retried. Tests: transient-401 recovery, persistent-401 raise, 400 not retried. Claude-Session: https://claude.ai/code/session_01Tujtmmhxj9mxKjPFSoVjmP --- providers/oanda.py | 36 +++++++++++++++++--- test_all.py | 83 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 5 deletions(-) diff --git a/providers/oanda.py b/providers/oanda.py index 2ea5435..5a9626a 100644 --- a/providers/oanda.py +++ b/providers/oanda.py @@ -8,6 +8,7 @@ Oanda's candle JSON, and exposes the real bid/ask spread from MBA candles. """ import logging +import time from datetime import datetime from typing import Dict, Optional @@ -31,6 +32,7 @@ class OandaDataProvider(BaseDataProvider): name = "oanda" DATA_ERROR = OandaDataError + RETRY_DELAY_S = 2.0 OANDA_GRANULARITY = { Timeframe.M1: "M1", @@ -124,11 +126,35 @@ class OandaDataProvider(BaseDataProvider): else: params["count"] = min(count, self.MAX_BARS_PER_REQUEST) - try: - resp = self._session.get(url, params=params, timeout=30) - except requests.RequestException as e: - raise OandaDataError( - f"Oanda candle fetch failed for {instrument}: {e}") from e + # One retry absorbs Oanda's sporadic spurious 401s (~15-20/day on + # the practice API — the same token succeeds on adjacent requests) + # plus transient 5xx/network faults. A real credential problem + # fails both attempts and raises exactly as before. Other 4xx + # (bad request, 429) are never retried. + last_err: Optional[OandaDataError] = None + for attempt in range(2): + if attempt: + time.sleep(self.RETRY_DELAY_S) + try: + resp = self._session.get(url, params=params, timeout=30) + except requests.RequestException as e: + last_err = OandaDataError( + f"Oanda candle fetch failed for {instrument}: {e}") + last_err.__cause__ = e + continue + if resp.status_code == 401 or resp.status_code >= 500: + last_err = OandaDataError( + f"Oanda candle fetch failed for {instrument}: " + f"HTTP {resp.status_code}: {resp.text[:300]}") + if attempt == 0: + logger.warning( + f"Oanda fetch for {instrument} got HTTP " + f"{resp.status_code}; retrying in " + f"{self.RETRY_DELAY_S}s") + continue + break + else: + raise last_err if resp.status_code != 200: raise OandaDataError( diff --git a/test_all.py b/test_all.py index 594118f..67ce91a 100644 --- a/test_all.py +++ b/test_all.py @@ -3453,6 +3453,89 @@ def _(): assert abs(spread - 0.30) < 0.01, f"Expected spread ~0.30, got {spread}" +def _make_oanda_provider(responses): + """Bare OandaDataProvider whose session pops canned responses in order.""" + provider = providers.oanda.OandaDataProvider.__new__( + providers.oanda.OandaDataProvider) + provider.config = PlatformConfig() + provider._base_url = "https://test" + provider._cache = {} + provider._cache_timestamps = {} + provider._cache_outputsize = {} + provider._cache_lock = threading.Lock() + provider._inflight = {} + provider._spread_cache = {} + queue = list(responses) + provider._session = type("S", (), { + "get": lambda self, *a, **k: queue.pop(0), + "headers": {}, + })() + return provider + + +def _oanda_resp(status_code, candles=None, text=""): + return type("MockResp", (), { + "status_code": status_code, + "text": text, + "json": lambda self: {"candles": candles or [], "instrument": "XAU_USD"}, + })() + + +_OANDA_OK_CANDLE = { + "complete": True, + "time": "2026-07-20T10:00:00.000000000Z", + "mid": {"o": "2400.00", "h": "2405.00", "l": "2398.00", "c": "2403.00"}, + "bid": {"o": "2399.85", "h": "2404.85", "l": "2397.85", "c": "2402.85"}, + "ask": {"o": "2400.15", "h": "2405.15", "l": "2398.15", "c": "2403.15"}, + "volume": 1234, +} + + +@test("providers.oanda: transient 401 is retried once and recovers") +def _(): + provider = _make_oanda_provider([ + _oanda_resp(401, text='{"errorMessage":"Insufficient authorization"}'), + _oanda_resp(200, candles=[_OANDA_OK_CANDLE]), + ]) + orig_sleep = providers.oanda.time.sleep + slept = [] + providers.oanda.time.sleep = lambda s: slept.append(s) + try: + df = provider._fetch_oanda_candles("XAU/USD", Timeframe.H1, 300) + finally: + providers.oanda.time.sleep = orig_sleep + assert len(df) == 1, f"Expected recovered fetch with 1 bar, got {len(df)}" + assert slept == [providers.oanda.OandaDataProvider.RETRY_DELAY_S], \ + f"Expected one retry sleep, got {slept}" + + +@test("providers.oanda: persistent 401 raises after retry; 4xx never retried") +def _(): + orig_sleep = providers.oanda.time.sleep + providers.oanda.time.sleep = lambda s: None + try: + provider = _make_oanda_provider([ + _oanda_resp(401, text='{"errorMessage":"Insufficient authorization"}'), + _oanda_resp(401, text='{"errorMessage":"Insufficient authorization"}'), + ]) + try: + provider._fetch_oanda_candles("XAU/USD", Timeframe.H1, 300) + assert False, "Persistent 401 should raise OandaDataError" + except providers.oanda.OandaDataError as e: + assert "401" in str(e) + + # Non-auth 4xx must fail immediately — only one response queued, + # so a retry attempt would IndexError on the empty queue. + provider = _make_oanda_provider([_oanda_resp(400, text="bad request")]) + try: + provider._fetch_oanda_candles("XAU/USD", Timeframe.H1, 300) + assert False, "HTTP 400 should raise OandaDataError" + except providers.oanda.OandaDataError as e: + assert "400" in str(e) + finally: + providers.oanda.time.sleep = orig_sleep + + @test("providers.oanda: instrument name mapping") def _(): provider = providers.oanda.OandaDataProvider.__new__(