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.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tujtmmhxj9mxKjPFSoVjmP
This commit is contained in:
root
2026-08-06 23:06:29 -04:00
parent b3022ef5c5
commit a42ef16113
2 changed files with 114 additions and 5 deletions
+28 -2
View File
@@ -8,6 +8,7 @@ Oanda's candle JSON, and exposes the real bid/ask spread from MBA candles.
""" """
import logging import logging
import time
from datetime import datetime from datetime import datetime
from typing import Dict, Optional from typing import Dict, Optional
@@ -31,6 +32,7 @@ class OandaDataProvider(BaseDataProvider):
name = "oanda" name = "oanda"
DATA_ERROR = OandaDataError DATA_ERROR = OandaDataError
RETRY_DELAY_S = 2.0
OANDA_GRANULARITY = { OANDA_GRANULARITY = {
Timeframe.M1: "M1", Timeframe.M1: "M1",
@@ -124,11 +126,35 @@ class OandaDataProvider(BaseDataProvider):
else: else:
params["count"] = min(count, self.MAX_BARS_PER_REQUEST) params["count"] = min(count, self.MAX_BARS_PER_REQUEST)
# 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: try:
resp = self._session.get(url, params=params, timeout=30) resp = self._session.get(url, params=params, timeout=30)
except requests.RequestException as e: except requests.RequestException as e:
raise OandaDataError( last_err = OandaDataError(
f"Oanda candle fetch failed for {instrument}: {e}") from e 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: if resp.status_code != 200:
raise OandaDataError( raise OandaDataError(
+83
View File
@@ -3453,6 +3453,89 @@ def _():
assert abs(spread - 0.30) < 0.01, f"Expected spread ~0.30, got {spread}" 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") @test("providers.oanda: instrument name mapping")
def _(): def _():
provider = providers.oanda.OandaDataProvider.__new__( provider = providers.oanda.OandaDataProvider.__new__(