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:
Viktor Barzin 2026-04-17 19:45:23 +00:00
parent 7d2c1199a9
commit 1eb3f78ea5
4 changed files with 268 additions and 29 deletions

View file

@ -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]