Wire T212 pagination, retries, and click<8.2 pin
Context ------- Closes out the Trading212 provider's retry + pagination surface so the "Add Trading212Provider core fetch" commit has everything the CronJob needs: cursor-based pagination, 429 honouring Retry-After, jittered exponential backoff for 429-without-header and 5xx, bailout after _MAX_RETRIES, and checkpoint-after-page semantics so a crashed run resumes at the start of the unfinished page. Also pins click<8.2 — typer 0.12 calls Parameter.make_metavar() without a ctx argument, which click 8.2 removed; `broker-sync --help` was crashing with TypeError until this pin. typer 0.15+ would also fix it; the pin is lower friction. One test fix: test_checkpoint_advances_only_after_page_yielded had a handler that unconditionally returned a next_path → infinite loop. The assertion was always about "a cursor was saved after page 1", so I changed the handler to return page 2 as empty-with-no-next, which terminates the loop cleanly. Test plan --------- ## Automated - poetry run pytest -q → 70 passed - poetry run mypy broker_sync tests → Success: no issues found in 29 source files - poetry run ruff check . → All checks passed! - poetry run broker-sync --help → renders without crash; lists version + auth-spike ## Manual Verification End-to-end against a live T212 key is in the next commit once the CLI subcommand and pipeline land.
This commit is contained in:
parent
7d2c1199a9
commit
1eb3f78ea5
4 changed files with 268 additions and 29 deletions
|
|
@ -213,3 +213,176 @@ def test_accounts_returns_registered_pairs(tmp_path: Path) -> None:
|
|||
)
|
||||
accs = p.accounts()
|
||||
assert [a.id for a in accs] == ["t212-isa", "t212-invest"]
|
||||
|
||||
|
||||
# -- pagination --
|
||||
|
||||
|
||||
async def test_pagination_follows_next_page_path(tmp_path: Path) -> None:
|
||||
pages = iter([
|
||||
_page([_fill(fill_id="p1-a"), _fill(fill_id="p1-b")],
|
||||
next_path="/api/v0/equity/history/orders?cursor=p2"),
|
||||
_page([_fill(fill_id="p2-a")], next_path=None),
|
||||
])
|
||||
visited: list[str | None] = []
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
visited.append(req.url.params.get("cursor"))
|
||||
return httpx.Response(200, json=next(pages))
|
||||
|
||||
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
|
||||
out = await _collect(p)
|
||||
assert [a.external_id for a in out] == [
|
||||
"t212:fill:p1-a",
|
||||
"t212:fill:p1-b",
|
||||
"t212:fill:p2-a",
|
||||
]
|
||||
# First call has no cursor; second uses the cursor from nextPagePath.
|
||||
assert visited == [None, "p2"]
|
||||
|
||||
|
||||
async def test_pagination_stops_when_since_reached(tmp_path: Path) -> None:
|
||||
# First page has one too-old fill; remaining fill is new. Provider
|
||||
# must stop without fetching page 2 once a page has items strictly
|
||||
# older than `since`.
|
||||
pages = iter([
|
||||
_page(
|
||||
[
|
||||
_fill(fill_id="new", filled_at="2026-04-01T10:30:00.000Z"),
|
||||
_fill(fill_id="old", filled_at="2020-01-01T00:00:00.000Z"),
|
||||
],
|
||||
next_path="/api/v0/equity/history/orders?cursor=p2",
|
||||
),
|
||||
_page([_fill(fill_id="p2-a")], next_path=None),
|
||||
])
|
||||
call_count = 0
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return httpx.Response(200, json=next(pages))
|
||||
|
||||
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
|
||||
since = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
out = [a async for a in p.fetch(since=since)]
|
||||
assert [a.external_id for a in out] == ["t212:fill:new"]
|
||||
assert call_count == 1 # did NOT walk to page 2
|
||||
|
||||
|
||||
async def test_checkpoint_advances_only_after_page_yielded(tmp_path: Path) -> None:
|
||||
cursor_next = "/api/v0/equity/history/orders?cursor=p2"
|
||||
calls = 0
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
# Page 1: one fill + next_path (triggers a checkpoint save).
|
||||
# Page 2: empty + no next — terminates the loop cleanly.
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
return httpx.Response(200, json=_page([_fill()], next_path=cursor_next))
|
||||
return httpx.Response(200, json=_page([], next_path=None))
|
||||
|
||||
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
|
||||
await _collect(p)
|
||||
# After a successful fetch, the checkpoint holds the cursor for the NEXT page.
|
||||
from broker_sync.providers._checkpoint import Checkpoint
|
||||
cp = Checkpoint(tmp_path, provider="trading212", account_id="t212-isa")
|
||||
assert cp.load() == cursor_next
|
||||
|
||||
|
||||
# -- retries --
|
||||
|
||||
|
||||
async def test_429_with_retry_after_sleeps_then_retries(tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls = 0
|
||||
sleeps: list[float] = []
|
||||
|
||||
async def fake_sleep(seconds: float) -> None:
|
||||
sleeps.append(seconds)
|
||||
|
||||
monkeypatch.setattr("asyncio.sleep", fake_sleep)
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
return httpx.Response(429, headers={"Retry-After": "7"})
|
||||
return httpx.Response(200, json=_page([_fill()]))
|
||||
|
||||
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
|
||||
out = await _collect(p)
|
||||
assert len(out) == 1
|
||||
assert calls == 2
|
||||
assert sleeps == [7.0]
|
||||
|
||||
|
||||
async def test_429_without_retry_after_uses_backoff(tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls = 0
|
||||
sleeps: list[float] = []
|
||||
|
||||
async def fake_sleep(seconds: float) -> None:
|
||||
sleeps.append(seconds)
|
||||
|
||||
monkeypatch.setattr("asyncio.sleep", fake_sleep)
|
||||
# Deterministic jitter: always 0.5 of the cap.
|
||||
monkeypatch.setattr(
|
||||
"broker_sync.providers.trading212._jitter",
|
||||
lambda base: base,
|
||||
)
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls < 3:
|
||||
return httpx.Response(429)
|
||||
return httpx.Response(200, json=_page([_fill()]))
|
||||
|
||||
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
|
||||
out = await _collect(p)
|
||||
assert len(out) == 1
|
||||
# First backoff = 10s; second doubles to 20s.
|
||||
assert sleeps == [10.0, 20.0]
|
||||
|
||||
|
||||
async def test_429_gives_up_after_max_retries(tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
async def noop_sleep(seconds: float) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("asyncio.sleep", noop_sleep)
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(429)
|
||||
|
||||
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
|
||||
with pytest.raises(Trading212Error, match="429"):
|
||||
await _collect(p)
|
||||
|
||||
|
||||
async def test_5xx_retries_with_backoff(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls = 0
|
||||
sleeps: list[float] = []
|
||||
|
||||
async def fake_sleep(seconds: float) -> None:
|
||||
sleeps.append(seconds)
|
||||
|
||||
monkeypatch.setattr("asyncio.sleep", fake_sleep)
|
||||
monkeypatch.setattr(
|
||||
"broker_sync.providers.trading212._jitter",
|
||||
lambda base: base,
|
||||
)
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
return httpx.Response(502)
|
||||
return httpx.Response(200, json=_page([_fill()]))
|
||||
|
||||
p = _provider(checkpoint_dir=tmp_path, transport=httpx.MockTransport(handler))
|
||||
out = await _collect(p)
|
||||
assert len(out) == 1
|
||||
assert sleeps == [10.0]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue