Skip to content

fix(data): chunk poll_once's catch-up request under Coinbase's candle cap - #269

Merged
eaitbrahim merged 2 commits into
mainfrom
fix/poll-once-chunking
Aug 15, 2026
Merged

fix(data): chunk poll_once's catch-up request under Coinbase's candle cap#269
eaitbrahim merged 2 commits into
mainfrom
fix/poll-once-chunking

Conversation

@eaitbrahim

@eaitbrahim eaitbrahim commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

The bug

poll_once requested its entire catch-up range in a single get_candles call:

fetch_start = last_ts + gran_sec if last_ts is not None else latest_closed
fetched = client.get_candles(product_id, granularity, fetch_start, latest_closed)

Coinbase rejects any range over ~350 candles:

400 INVALID_ARGUMENT ... "number of candles requested should be less than 350"

Why it can't recover on its own

The request size is a function of how stale the product is, and staleness only grows while the poll is failing. Every subsequent poll therefore asks for a strictly larger range and fails the same way — there is no path back to a legal request without intervention.

The live instance: ZEC-USD at ONE_HOUR sat 552 hours stale, i.e. a ~552-candle request, and had been failing hourly for two days, widening by ~24 candles a day. The other 18 products were current, so nothing else masked or explained it.

Note the empty-repo case was never affected: with last_ts is None, fetch_start = latest_closed, so exactly one candle is requested. Wedging requires cached-but-stale candles.

The fix

poll_once now pages [fetch_start, latest_closed] forward in windows of at most MAX_CANDLES_PER_REQUEST candles, importing the existing constant from keel.data.history rather than declaring a second one — a duplicated venue limit is the same bug class being fixed here.

The new _poll_catch_up helper mirrors history._fill_forward's windowing idiom so the two read the same way, with two deliberate differences:

  • windows are sized (MAX - 1) * gran_sec, so the inclusive [start, end] range is at most MAX candles (_fill_forward's + MAX * step is one over, harmless under the ~350 cap but not a property worth copying);
  • an empty window does not break the loop, since a mid-history hole must not block catch-up of newer candles.

Each window is upserted as it arrives, so a failure partway through leaves the earlier windows persisted and the next poll resumes further along instead of restarting.

cb_client.get_candles is deliberately unchanged — it correctly passes start/end through, and batching belongs in the caller.

Preserved exactly: the small-range path (still a single request), the empty-repo path (still one candle at latest_closed), the last_ts >= latest_closed no-op, and the return value's meaning.

Tests (written first, confirmed failing for the right reason)

Against the old code the three new chunking tests fail on the oversized single call — assert 552 <= 300 and assert 1 > 1 — not on any import or fixture error:

FAILED test_poll_once_chunks_a_gap_larger_than_the_coinbase_cap
FAILED test_poll_once_never_requests_more_than_the_candle_cap
FAILED test_poll_once_chunk_windows_are_contiguous_and_non_overlapping
3 failed, 12 passed

They reproduce the real 552-hour ZEC-USD gap and assert that catch-up issues multiple calls and persists the complete contiguous series, that no request exceeds MAX_CANDLES_PER_REQUEST candles (asserted on the start/end args the fake client actually received), and that the windows tile the range exactly — no duplicate and no gap at a chunk boundary. A fourth test guards that a small gap still takes exactly one request.

Gates

uv run ruff check keel tests packages   All checks passed!
uv run mypy                             Success: no issues found in 224 source files
uv run pytest -q                        2732 passed, 1 skipped

Baseline reconciliation, measured directly rather than assumed: the FULL suite on main at
29d9b20 is 2727 passed, 1 skipped. This branch adds 5 tests (4 chunking + 1 pinning that
an empty window must not stop catch-up), giving 2732.

An earlier version of this section cited "2716", which was a scoped baseline measured with
--ignore=tests/data/test_market_feed.py, not the full-suite figure. It reconciled correctly
(2716 + 15 in that file = 2731) but read as if it were the whole suite, and two later readers
tripped on it. The full-suite number is 2727.

🤖 Generated with Claude Code

eaitbrahim and others added 2 commits August 15, 2026 15:37
… cap

`poll_once` asked for its entire catch-up range in ONE `get_candles` call:
`[last_ts + gran, latest_closed]`. Coinbase rejects any range over ~350
candles with `400 INVALID_ARGUMENT ... "number of candles requested should
be less than 350"`, so the moment a product falls that far behind, the poll
fails.

The failure is self-wedging, which is what makes it worth fixing rather than
waiting out. The request size is a function of how stale the product is, and
staleness only ever grows while the poll is failing, so every subsequent poll
asks for a strictly larger range and fails the same way. There is no path back
to a legal request. In production ZEC-USD at ONE_HOUR sat 552 hours stale --
a ~552-candle request -- and had failed hourly for two days, widening by ~24
candles a day, while the other 18 products stayed current.

`poll_once` now pages `[fetch_start, latest_closed]` forward in windows of at
most `MAX_CANDLES_PER_REQUEST` candles, importing the existing constant from
`keel.data.history` rather than declaring a second one -- a duplicated venue
limit is the same bug class. The new `_poll_catch_up` helper mirrors
`history._fill_forward`'s windowing idiom so the two read the same way, with
two deliberate differences: windows are sized `(MAX - 1) * gran` so the
*inclusive* range is at most MAX candles, and an empty window does not stop
the loop, since a mid-history hole must not block catch-up of newer candles.
Each window is upserted as it arrives, so a failure partway through leaves the
earlier windows persisted and the next poll resumes further along.

`cb_client.get_candles` is unchanged -- it correctly passes start/end through,
and batching belongs in the caller. Behaviour is unchanged for the small-range
case (still a single request) and the empty-repo case (still exactly one
candle at `latest_closed`).

Tests first: three new tests reproduce the 552-hour ZEC-USD gap and fail
against the old single-request code (`assert 552 <= 300`). They assert the
catch-up issues multiple calls, that no request exceeds
MAX_CANDLES_PER_REQUEST candles, and that the windows tile the range exactly
-- no duplicate and no gap at a chunk boundary. A fourth guards that a small
gap still takes exactly one request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_poll_catch_up deliberately keeps paging when a window returns zero
candles, so catch-up can cross a mid-history hole and still reach fresh
data beyond it. No existing test exercised an empty window -- the
_stale_series fixture is contiguous everywhere -- so `if not fetched:
break` (a plausible "cleanup" toward history._fill_backward's symmetric
behavior) would pass the whole suite while silently reintroducing the
wedge. Add a test with a fake client that returns [] for the first
catch-up window and real candles only for a later one, asserting
poll_once still reaches and persists the later candles.

Also drop STALE_EXPECTED_TS, a dead fixture constant in the same file
that nothing referenced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eaitbrahim added a commit that referenced this pull request Aug 15, 2026
… cap

Coinbase rejects any candle request spanning more than ~350 candles with
`400 INVALID_ARGUMENT ... "number of candles requested should be less than
350"`. #269 fixed this in `market_feed.poll_once`; review found two more
sites with the same defect. Both are fixed here so every candle-request
windowing site in the codebase agrees.

**`repair.py` could never heal a hole larger than the cap.** `gaps.detect`
puts no cap on `n_missing`, and `repair_series` asked for the whole hole in
one call. Any gap of roughly 349+ candles therefore 400s -- and because the
per-window `except` swallows the exception into `result.errors` and moves
on, it failed *silently and forever*: every scheduled repair retried the
same oversized request. The defect is real but currently LATENT, and that is worth
stating precisely rather than overselling it: measured across all 24 cached
series in the deployment, the largest interior gap is 15 bars (ICP-USD,
ONE_HOUR) against a ~349 ceiling, so nothing in the cache triggers it today.
An earlier draft of this message claimed it left ZEC-USD an unrepairable
553-candle hole; that was wrong. ZEC's 553 candles were STALENESS -- a
missing tail, which `poll_once`/`ensure_history` catch up forward -- not an
interior gap, so `repair` was never asked to heal it. The fix closes a trap
before it bites: an interior gap over ~349 is entirely reachable via a
multi-day venue outage, a delist-and-relist, or a product re-added after a
long absence, and the failure mode if it happened would be silent and
permanent.

The widened window is now paged in `MAX_CANDLES_PER_REQUEST`-sized chunks
(imported from `keel.data.history` -- a duplicated venue limit would be the
same bug class), each upserted as it arrives so a mid-window failure leaves
the earlier chunks persisted and the next run resumes further along.

Semantics preserved exactly, because they are load-bearing:

- the ±step widening applies to the OUTER edges only (venues disagree about
  endpoint inclusivity); internal chunk boundaries stay contiguous and are
  not themselves widened;
- a window is eligible to be recorded *absent at source* only if EVERY
  chunk completed -- the difference between "the venue lacks this data" and
  "we failed to ask properly", and the module docstring is explicit that
  the absence record is an assertion about an observation;
- the per-window `except ... continue` stays, so one bad window cannot
  abort the pass;
- `sleep_fn` paces every chunk request, matching `history._fill_forward`.

**`history.py`'s off-by-one was latent but would activate if the constant
were raised.** An inclusive range `[a, a + N*step]` holds N+1 candles, so
`_fill_forward` and `_fill_backward` requested 301, not 300 -- verified:
a 1000-candle gap produced window sizes [301, 301, 301, 97]. Harmless at
300, but `history.py` invites raising the constant toward 350, at which
point it would silently request 351 and reproduce the incident in the
backfill path. Both sites now use `(MAX_CANDLES_PER_REQUEST - 1) * step`,
matching what #269 did in `market_feed.py`, with a comment on the
arithmetic so the -1 never reads as an accident.

Tests written first and confirmed failing for the right reason: 9 of the 10
new tests fail against the unfixed code on candle-count, call-count and
absence-record assertions (the 10th is a regression guard that a small hole
still takes exactly one request, which must pass before and after). The
repair tests reproduce the real 553-candle ZEC hole against a fake venue
that enforces the true ~349 ceiling, and assert on the actual start/end
args it received. The history tests assert exact window sizes and express
the invariant in terms of the constant, so raising it toward 350 stays safe.

Gates: ruff clean, mypy clean (224 source files), pytest 2737 passed /
1 skipped (2727 + 1 skipped at 29d9b20, plus the 10 added here).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eaitbrahim added a commit that referenced this pull request Aug 15, 2026
… cap

Coinbase rejects any candle request spanning more than ~350 candles with
`400 INVALID_ARGUMENT ... "number of candles requested should be less than
350"`. #269 fixed this in `market_feed.poll_once`; review found two more
sites with the same defect. Both are fixed here so every candle-request
windowing site in the codebase agrees. Both are LATENT -- neither is firing
today -- and both are the kind that fail quietly once they do.

**`repair.py` cannot heal an interior hole larger than the cap.**
`gaps.detect` puts no cap on `n_missing`, and `repair_series` asked for the
whole hole in one call, so a gap of roughly 349+ candles 400s. Because the
per-window `except` swallows the exception into `result.errors` and moves
on, the failure would be *silent and permanent*: every scheduled repair
retries the same oversized request, and the only symptom is a series that
quietly never repairs.

No cached series triggers this today. Measured across the deployment (24
products, ONE_HOUR and ONE_DAY), the largest interior gap is 15 bars
(ICP-USD, ONE_HOUR) against a ~349 limit, and no series has an interior gap
at or over the cap. So this is closing a latent trap, not healing an
existing hole. It is still worth doing: an interior gap that large is
entirely reachable -- a multi-day venue outage, a delisting and relisting,
or a product added back after a long absence -- and the silent-forever
failure mode is exactly the kind that is not noticed until much later.

(For the avoidance of doubt: the 553-candle ZEC-USD figure from the #269
incident was end-of-series STALENESS, which `poll_once`/`ensure_history`
catch up forward. It was never an interior gap, so `repair` was never asked
to heal it. 553 survives here only as the oversized-gap size in the tests.)

The widened window is now paged in `MAX_CANDLES_PER_REQUEST`-sized chunks
(imported from `keel.data.history` -- a duplicated venue limit would be the
same bug class), each upserted as it arrives so a mid-window failure leaves
the earlier chunks persisted and the next run resumes further along.

Semantics preserved exactly, because they are load-bearing:

- the ±step widening applies to the OUTER edges only (venues disagree about
  endpoint inclusivity); internal chunk boundaries stay contiguous and are
  not themselves widened;
- a window is eligible to be recorded *absent at source* only if EVERY
  chunk completed -- the difference between "the venue lacks this data" and
  "we failed to ask properly", and the module docstring is explicit that
  the absence record is an assertion about an observation;
- the per-window `except ... continue` stays, so one bad window cannot
  abort the pass;
- `sleep_fn` paces every chunk request, matching `history._fill_forward`.

**`history.py`'s off-by-one would activate if the constant were raised.**
An inclusive range `[a, a + N*step]` holds N+1 candles, so `_fill_forward`
and `_fill_backward` requested 301, not 300 -- verified: a 1000-candle gap
produced window sizes [301, 301, 301, 97]. Harmless at 300, but
`history.py` invites raising the constant toward 350, at which point it
would silently request 351 and reproduce the incident in the backfill path.
Both sites now use `(MAX_CANDLES_PER_REQUEST - 1) * step`, matching what
#269 did in `market_feed.py`, with a comment on the arithmetic so the -1
never reads as an accident.

Tests written first and confirmed failing for the right reason: 9 of the 10
new tests fail against the unfixed code on candle-count, call-count and
absence-record assertions (the 10th is a regression guard that a small hole
still takes exactly one request, which must pass before and after). The
repair tests drive an oversized gap against a fake venue that enforces the
true ~349 ceiling, and assert on the actual start/end args it received. The
history tests assert exact window sizes and express the invariant in terms
of the constant, so raising it toward 350 stays safe.

Gates: ruff clean, mypy clean (224 source files), pytest 2737 passed /
1 skipped (2727 + 1 skipped at 29d9b20, plus the 10 added here).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eaitbrahim
eaitbrahim merged commit abf1e64 into main Aug 15, 2026
1 check failed
@eaitbrahim
eaitbrahim deleted the fix/poll-once-chunking branch August 15, 2026 20:10
eaitbrahim added a commit that referenced this pull request Aug 15, 2026
… cap (#271)

Coinbase rejects any candle request spanning more than ~350 candles with
`400 INVALID_ARGUMENT ... "number of candles requested should be less than
350"`. #269 fixed this in `market_feed.poll_once`; review found two more
sites with the same defect. Both are fixed here so every candle-request
windowing site in the codebase agrees. Both are LATENT -- neither is firing
today -- and both are the kind that fail quietly once they do.

**`repair.py` cannot heal an interior hole larger than the cap.**
`gaps.detect` puts no cap on `n_missing`, and `repair_series` asked for the
whole hole in one call, so a gap of roughly 349+ candles 400s. Because the
per-window `except` swallows the exception into `result.errors` and moves
on, the failure would be *silent and permanent*: every scheduled repair
retries the same oversized request, and the only symptom is a series that
quietly never repairs.

No cached series triggers this today. Measured across the deployment (24
products, ONE_HOUR and ONE_DAY), the largest interior gap is 15 bars
(ICP-USD, ONE_HOUR) against a ~349 limit, and no series has an interior gap
at or over the cap. So this is closing a latent trap, not healing an
existing hole. It is still worth doing: an interior gap that large is
entirely reachable -- a multi-day venue outage, a delisting and relisting,
or a product added back after a long absence -- and the silent-forever
failure mode is exactly the kind that is not noticed until much later.

(For the avoidance of doubt: the 553-candle ZEC-USD figure from the #269
incident was end-of-series STALENESS, which `poll_once`/`ensure_history`
catch up forward. It was never an interior gap, so `repair` was never asked
to heal it. 553 survives here only as the oversized-gap size in the tests.)

The widened window is now paged in `MAX_CANDLES_PER_REQUEST`-sized chunks
(imported from `keel.data.history` -- a duplicated venue limit would be the
same bug class), each upserted as it arrives so a mid-window failure leaves
the earlier chunks persisted and the next run resumes further along.

Semantics preserved exactly, because they are load-bearing:

- the ±step widening applies to the OUTER edges only (venues disagree about
  endpoint inclusivity); internal chunk boundaries stay contiguous and are
  not themselves widened;
- a window is eligible to be recorded *absent at source* only if EVERY
  chunk completed -- the difference between "the venue lacks this data" and
  "we failed to ask properly", and the module docstring is explicit that
  the absence record is an assertion about an observation;
- the per-window `except ... continue` stays, so one bad window cannot
  abort the pass;
- `sleep_fn` paces every chunk request, matching `history._fill_forward`.

**`history.py`'s off-by-one would activate if the constant were raised.**
An inclusive range `[a, a + N*step]` holds N+1 candles, so `_fill_forward`
and `_fill_backward` requested 301, not 300 -- verified: a 1000-candle gap
produced window sizes [301, 301, 301, 97]. Harmless at 300, but
`history.py` invites raising the constant toward 350, at which point it
would silently request 351 and reproduce the incident in the backfill path.
Both sites now use `(MAX_CANDLES_PER_REQUEST - 1) * step`, matching what
#269 did in `market_feed.py`, with a comment on the arithmetic so the -1
never reads as an accident.

Tests written first and confirmed failing for the right reason: 9 of the 10
new tests fail against the unfixed code on candle-count, call-count and
absence-record assertions (the 10th is a regression guard that a small hole
still takes exactly one request, which must pass before and after). The
repair tests drive an oversized gap against a fake venue that enforces the
true ~349 ceiling, and assert on the actual start/end args it received. The
history tests assert exact window sizes and express the invariant in terms
of the constant, so raising it toward 350 stays safe.

Gates: ruff clean, mypy clean (224 source files), pytest 2737 passed /
1 skipped (2727 + 1 skipped at 29d9b20, plus the 10 added here).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
eaitbrahim added a commit that referenced this pull request Aug 16, 2026
…indow site (#295)

#269 chunked `market_feed.poll_once`; #271 swept for the same defect and fixed
`repair.py` and `history.py`. `market_feed.backfill` was not reached by either,
and has the identical shape: `_missing_ranges` groups absent timestamps into
CONTIGUOUS ranges and each range was requested in a single call, so a hole
wider than the venue's ~350-candle cap 400s exactly as the poll path did. On an
empty repo the entire history window is one such range.

LATENT, and stated plainly rather than dressed up: `backfill` has no production
caller today -- `keel fetch` goes through `history.ensure_history` -- so nothing
is failing on this right now. It is worth closing anyway on #271's own stated
grounds, that every candle-request windowing site in the codebase should agree.
A public data-layer entry point that 400s the moment it is called from anywhere
is a trap left armed for whoever calls it next.

The windowing arithmetic is now in one place, `_request_windows`, used by both
`backfill` and `_poll_catch_up`. That was the actual reason this site was easy
to miss: the `(MAX_CANDLES_PER_REQUEST - 1)` expression existed only inside
`_poll_catch_up`, so `backfill` had nothing to be inconsistent WITH. #271 had
to correct exactly that off-by-one after finding it duplicated in `history.py`
-- the same bug class the shared `MAX_CANDLES_PER_REQUEST` import exists to
prevent -- so it now has one definition here.

`_poll_catch_up` keeps its own filter and `seen` dedup and is otherwise
unchanged; folding it onto the helper also removed a `window_start = window_end
+ gran_sec` line left stranded when its `while` became a `for`.

Tests written first and confirmed failing for the right reason: both cap tests
failed on a single 575-candle request against the unfixed code. The third is a
regression guard that a window within the cap still costs exactly one request,
which passes before and after.

Gates: ruff clean, mypy clean (77 source files), pytest 2762 -> 2765 passed /
1 skipped.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant