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:
+83
@@ -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__(
|
||||
|
||||
Reference in New Issue
Block a user