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
This commit is contained in:
root
2026-08-06 23:06:29 -04:00
parent b21c75a1f8
commit edefd90aaa
2 changed files with 114 additions and 5 deletions
+83
View File
@@ -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__(