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