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:
+31
-5
@@ -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(
|
||||
|
||||
+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