Skip to content

fix(notion): make the query page bound configurable and honour Retry-After - #6006

Open
uschtwill wants to merge 1 commit into
gastownhall:mainfrom
uschtwill:fix/notion-incremental-query
Open

fix(notion): make the query page bound configurable and honour Retry-After#6006
uschtwill wants to merge 1 commit into
gastownhall:mainfrom
uschtwill:fix/notion-incremental-query

Conversation

@uschtwill

Copy link
Copy Markdown
Contributor

Refs #6004.

What this does

QueryDataSource paginates up to maxQueryPages (50) × maxPageSize (100). Past 5000 rows it returns an error rather than partial results, so sync stops working in both directions — and because every CLI narrowing flag (--issues, --state, --dry-run, bd notion push/pull) is applied after that call returns, none of them is a workaround.

Two changes, and the second is what makes the first safe to use:

  1. Client.MaxQueryPages / WithMaxQueryPages — lets a caller raise the bound for a large data source. Unset behaviour is unchanged. The error at the bound now names the ceiling in rows as well as pages, because rows is the number a caller can actually compare against their own data source.

  2. Retry-After-honouring retries in doRequest — 429 and 529 always; other 5xx only for GET/DELETE, since replaying a POST that may already have been applied server-side is how duplicates get made. Exponential fallback clamped at 30s.

(2) is not optional garnish. Notion enforces roughly 3 requests/second per connection, and the client currently has no retry, no backoff and no 429 handling at all — every non-2xx becomes a hard error. Raising the page bound without backoff would trade a clear, reproducible failure for an intermittent one.

The request body is now marshalled once and the reader rebuilt per attempt; a retry cannot reuse a drained body.

What this deliberately does not do

It does not fix the underlying design, where one unfiltered whole-database fetch feeds both the pull and the push's create-vs-update index, so cost scales with total database size on every sync forever.

I had intended to send last_edited_time on_or_after Since into the query — FetchOptions.Since already exists, is populated from <prefix>.last_sync, and is currently applied client-side in matchesFetchSince after everything has been downloaded. On reading it properly that is not a safe drop-in: FetchIssues also relies on seeing the full remote set for shouldBackfillNotionIssue, which discovers pages that exist remotely but are missing locally. Filtering the fetch would silently stop that discovery. It needs a deliberate decision about backfill semantics, which felt like the maintainers' call rather than something to smuggle into this PR. I have written that up in #6004.

Testing

New tests cover: the configured bound, the unchanged default, Retry-After being obeyed exactly, GET retrying on 5xx, and POST not retrying.

Verified the tests actually bite rather than merely pass:

  • forcing maxRequestAttempts to 1 fails exactly the two retry tests, and nothing else;
  • ignoring the configured bound fails exactly the bound test.

make build and make fmt-check pass. internal/notion and internal/tracker pass with -tags gms_pure_go. golangci-lint was not run locally — it isn't installed on this machine, so CI will be the first gate for it.

Context

Our tracker crossed 5000 rows overnight and sync died in both directions for ~14 hours before anyone noticed, because a failing sync looks exactly like a quiet one. Operator-filed issues kept landing in Notion and never reached bd. Recovery meant trashing 1005 old rows by hand through the Notion API.

…After

QueryDataSource paginates up to maxQueryPages (50) x maxPageSize (100), so a
data source larger than 5000 rows cannot be synced at all: the call returns an
error rather than partial results, and because every CLI narrowing flag is
applied after it returns, no flag works around it. Sync dies in both directions
at once. Refs gastownhall#6004.

Two changes, and the second is what makes the first safe:

- Client.MaxQueryPages / WithMaxQueryPages lets a caller raise the bound for a
  large data source. Default behaviour is unchanged when unset. The error at the
  bound now names the ceiling in rows as well as pages, since rows is the number
  a caller can compare against their own data source.

- doRequest now retries 429 and 529 honouring Retry-After, with exponential
  fallback clamped at 30s. Notion enforces ~3 requests/second per connection, so
  raising the page bound without this would trade a clear failure for an
  intermittent one. Other 5xx retry only for GET/DELETE — replaying a POST that
  may already have been applied server-side is how duplicates get made.

The request body is now marshalled once and the reader rebuilt per attempt; a
retry cannot reuse a drained body.

Tests cover the configured bound, the unchanged default, Retry-After being
obeyed exactly, GET retrying on 5xx, and POST not retrying. Verified they bite:
forcing maxRequestAttempts to 1 fails exactly the two retry tests, and ignoring
the configured bound fails exactly the bound test.

make build and make fmt-check pass; internal/notion and internal/tracker pass
with -tags gms_pure_go. golangci-lint was not run locally (not installed on this
machine); CI will cover it.
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