fix(data): chunk poll_once's catch-up request under Coinbase's candle cap - #269
Merged
Conversation
… 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
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>
This was referenced Aug 16, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
poll_oncerequested its entire catch-up range in a singleget_candlescall:Coinbase rejects any range over ~350 candles:
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_HOURsat 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_oncenow pages[fetch_start, latest_closed]forward in windows of at mostMAX_CANDLES_PER_REQUESTcandles, importing the existing constant fromkeel.data.historyrather than declaring a second one — a duplicated venue limit is the same bug class being fixed here.The new
_poll_catch_uphelper mirrorshistory._fill_forward's windowing idiom so the two read the same way, with two deliberate differences:(MAX - 1) * gran_sec, so the inclusive[start, end]range is at mostMAXcandles (_fill_forward's+ MAX * stepis one over, harmless under the ~350 cap but not a property worth copying);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_candlesis deliberately unchanged — it correctly passesstart/endthrough, 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), thelast_ts >= latest_closedno-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 <= 300andassert 1 > 1— not on any import or fixture error: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_REQUESTcandles (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
Baseline reconciliation, measured directly rather than assumed: the FULL suite on
mainat29d9b20is 2727 passed, 1 skipped. This branch adds 5 tests (4 chunking + 1 pinning thatan empty window must not stop catch-up), giving 2732.
🤖 Generated with Claude Code