feat: add REDUCE_PAGE_SIZE response action for dynamic page-size reduction - #1149
feat: add REDUCE_PAGE_SIZE response action for dynamic page-size reduction#1149Anatolii Yatsuk (tolik0) wants to merge 3 commits into
Conversation
…ction Adds a declarative response action that lets a stream shrink its page size and re-fetch the same page when an API rejects a request because the page is too large, instead of failing the sync and asking the user to lower a config value and restart. The signal path mirrors RESET_PAGINATION: a response filter resolving to REDUCE_PAGE_SIZE makes HttpClient raise PageSizeReductionRequiredException, which escapes the backoff decorators and is caught by SimpleRetriever._read_pages. The retriever then retries the same page with a smaller size, leaving the next-page token and the stream slice untouched. Reduction behavior is configured with a PageSizeReduction component on SimpleRetriever (reduction_factor, minimum_page_size, max_attempts, reset_policy). State lives in a PageSizeReducer built per _read_pages call, so nothing mutable is stored on the retriever, paginator, or pagination strategy, which are shared across the partitions of a stream. The effective size travels as an optional page_size_override keyword that is only passed when set, so out-of-tree paginators and strategies are unaffected. OffsetIncrement's stop condition also honors the override. It previously compared the returned record count against the configured page size, so a full page at a reduced size would have been treated as the last page and the rest of the partition silently dropped. Unsupported configurations are rejected when the component is built rather than mid-sync: PageIncrement (halving moves record boundaries), paginators other than DefaultPaginator, a missing page_size_option, query-properties chunking, LazySimpleRetriever, and declaring the action without a page_size_reduction block. Reduction always terminates: each step strictly decreases the size, and both the minimum size and max_attempts raise AirbyteTracedException with transient_error. Note: models/declarative_component_schema.py was written by hand in codegen style because Docker was unavailable locally. It still needs `poe assemble` to confirm a zero diff. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
👋 Greetings, Airbyte Team Member!Here are some helpful tips and reminders for your convenience. 💡 Show Tips and TricksTesting This CDK VersionYou can test this version of the CDK using the following: # Run the CLI from this branch:
uvx 'git+https://github.com/airbytehq/airbyte-python-cdk.git@tolik0/cdk/reduce-page-size#egg=airbyte-python-cdk[dev]' --help
# Update a connector to use the CDK from this branch ref:
cd airbyte-integrations/connectors/source-example
poe use-cdk-branch tolik0/cdk/reduce-page-sizePR Slash CommandsAirbyte Maintainers can execute the following slash commands on your PR:
|
| ), | ||
| session=mocked_session, | ||
| ) | ||
| prepared_request = requests.PreparedRequest() |
| from airbyte_cdk.sources.declarative.models.declarative_component_schema import ( | ||
| PageSizeReduction as PageSizeReductionModel, | ||
| ) |
PyTest Results (Full)4 431 tests 4 419 ✅ 9m 49s ⏱️ Results for commit fed78ad. ♻️ This comment has been updated with latest results. |
|
/prerelease
|
The strategy allowlist was written by type, which rejected every CustomPaginationStrategy — including the GraphQL streams that motivated the feature, whose multi-level cursor traversal cannot be expressed by a built-in strategy. Found while adopting the action in source-github. What actually matters is not whether the CDK recognizes the strategy but whether the strategy can be told the reduced page size. A custom strategy is written by the same person enabling the reduction, so it is now accepted when its `next_page_token` takes a `page_size_override` keyword argument (or **kwargs) and rejected with an actionable message when it does not. Checking the signature at config time keeps a strategy that would raise TypeError on the first reduction from being accepted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
/prerelease
|
`PageSizeReducer.reduce` did `current_page_size // reduction_factor` on whatever the pagination strategy returned from `get_page_size`. A built-in strategy always returns an int, but a custom one can return anything, and a string page size — which is what a custom component gets when a manifest field is left uninterpolated — failed mid-sync with `TypeError: unsupported operand type(s) for //: 'str' and 'float'`. Reported as a config error naming the type instead. Found while adopting the action in source-github. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
/prerelease
|
…ifest Completes Step 9: `reviews`, `issue_reactions` and `pull_request_comment_reactions` join the Tier 1 streams in `manifest.yaml`. `GitHubGraphQLStream`, `GitHubGraphQLErrorHandler`, `graphql.py`, `github_schema.py` and the sgqlc dependency are all removed. These three streams walk nested GraphQL connections, and a child connection with more pages cannot be paginated in place: the query has to be re-rooted at that single parent. `reviews` and `issue_reactions` switch between a parent listing and a drill-down; `pull_request_comment_reactions` walks four levels (pullRequests -> reviews -> comments -> reactions) depth-first, so a comment's remaining reactions are drained before the listing advances. Two custom pagination strategies replace the four legacy retrievers' bookkeeping. Both keep all traversal state inside the page token rather than on the component. That is a fix, not a port: one strategy instance is shared by every partition of a stream and the partitions are read concurrently, which is why the legacy `self.reviews_cursors`, `self.issues_cursor` and `self.cursor_storage` were keyed by repository or, in the four-level case, not keyed at all. A self-contained token removes the sharing. Two record extractors handle the fact that records arrive under different paths depending on which root the query used, and carry fields that only exist on the parent node (`reviews.pull_request_url`, `issue_reactions.issue_number`, `pull_request_comment_reactions.comment_id`). One legacy behavior is deliberately dropped: the four-level stream sent `first = min(page_size, total_count)` to avoid paying for pages larger than what remained. `first` has to stay a GraphQL variable for REDUCE_PAGE_SIZE to shrink it, and a variable cannot vary per token, so the connector may over-ask on the last page of a connection. GitHub returns fewer records; the cost is a slightly higher query score. Adopting the action here turned up two CDK defects, both fixed in airbytehq/airbyte-python-cdk#1149 rather than worked around: the strategy allowlist rejected every CustomPaginationStrategy, and a non-integer page size crashed the reducer with a bare TypeError. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ifest Completes Step 9: `reviews`, `issue_reactions` and `pull_request_comment_reactions` join the Tier 1 streams in `manifest.yaml`. `GitHubGraphQLStream`, `GitHubGraphQLErrorHandler`, `graphql.py`, `github_schema.py` and the sgqlc dependency are all removed. These three streams walk nested GraphQL connections, and a child connection with more pages cannot be paginated in place: the query has to be re-rooted at that single parent. `reviews` and `issue_reactions` switch between a parent listing and a drill-down; `pull_request_comment_reactions` walks four levels (pullRequests -> reviews -> comments -> reactions) depth-first, so a comment's remaining reactions are drained before the listing advances. Two custom pagination strategies replace the four legacy retrievers' bookkeeping. Both keep all traversal state inside the page token rather than on the component. That is a fix, not a port: one strategy instance is shared by every partition of a stream and the partitions are read concurrently, which is why the legacy `self.reviews_cursors`, `self.issues_cursor` and `self.cursor_storage` were keyed by repository or, in the four-level case, not keyed at all. A self-contained token removes the sharing. Two record extractors handle the fact that records arrive under different paths depending on which root the query used, and carry fields that only exist on the parent node (`reviews.pull_request_url`, `issue_reactions.issue_number`, `pull_request_comment_reactions.comment_id`). One legacy behavior is deliberately dropped: the four-level stream sent `first = min(page_size, total_count)` to avoid paying for pages larger than what remained. `first` has to stay a GraphQL variable for REDUCE_PAGE_SIZE to shrink it, and a variable cannot vary per token, so the connector may over-ask on the last page of a connection. GitHub returns fewer records; the cost is a slightly higher query score. Adopting the action here turned up two CDK defects, both fixed in airbytehq/airbyte-python-cdk#1149 rather than worked around: the strategy allowlist rejected every CustomPaginationStrategy, and a non-integer page size crashed the reducer with a bare TypeError. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What
Adds a
REDUCE_PAGE_SIZEresponse action to the declarative framework. When a response filter resolves to it, the retriever shrinks the page size and re-fetches the same page — same next-page token, same stream slice — instead of failing the sync.Three commits: the action itself, then two fixes that came out of actually adopting it in a connector (see Adoption below).
Why
Several declarative connectors already expose a page-size config field whose only purpose is letting a user recover from an API that rejects large pages. Recovery today means: sync fails, user reads the error, lowers the setting, restarts the sync.
Four known adopters. All use
CursorPaginationwith a workingpage_size_option, so each adoption is a response-filter action swap plus apage_size_reductionblock:GitHubGraphQLErrorHandlermutatesstream.page_sizeand returnsRETRY, butHttpClientreplays the samePreparedRequest, so the oversizedfirstis already serialized into the body and the retry never benefits. Only a later page picks up the smaller size, and any non-502/504 response resets it. This blocked the manifest-only migration.ListFinancialEvents— fails on 400/InvalidInput("the response exceeds the maximum number of transactions or 10 MB") with an error telling the user to "try reducing it to a smaller value (e.g., 50, 25, 10, or even 1 for very high-volume accounts)". A manual binary search across sync restarts.ticket_comments— retries 504 with exponential backoff at an unchangedper_page, so the retries cannot succeed. Its own spec says the fix is "lower values may help prevent timeouts on large datasets".page_sizeby hand.How
The signal path mirrors
RESET_PAGINATION:HttpResponseFilter action: REDUCE_PAGE_SIZE→HttpClient._handle_error_resolutionraisesPageSizeReductionRequiredException→ the exception is not inTRANSIENT_EXCEPTIONS, so it escapes the backoff decorators untouched →SimpleRetriever._read_pagescatches it, reduces, andcontinues without advancing the token.CompositeErrorHandlershort-circuits on the new action so it is not swallowed when nested.reset_policydefaults toNEVERbecause restoring the configured size after every good page re-triggers the error on each subsequent page and roughly doubles request volume.Thread safety
model_to_component_factorybuilds one retriever per stream and hands that single object toDeclarativePartitionFactory, which reuses it for every partition; partitions are read concurrently by a thread pool. So the reduction state lives in aPageSizeReducerconstructed inside_read_pages, likePaginationTracker— no mutable page-size state on the retriever, paginator, or pagination strategy. The effective size travels as an optionalpage_size_overridekeyword passed only when set, so out-of-treePaginator/PaginationStrategysubclasses that do not accept it keep working. There is a test with two partitions on threads asserting the healthy one keeps its configured size while the failing one runs reduced.Correctness fix in
OffsetIncrementIts stop condition compared the returned record count against the configured page size. Reduced to 50 with a configured 100, a full 50-record page would hit
50 < 100, end pagination, and silently drop the rest of the partition. The stop condition now honors the override; the offset math already advanced by the actuallast_page_size, so that part was fine.Rejected at config time, not mid-sync
PageIncrement(the token is a page number, so halving moves record boundaries and skips records — and its page size is also its stop condition), paginators other thanDefaultPaginator, a missingpage_size_option, query-properties chunking (earlier chunks are already emitted, so a retry would duplicate),LazySimpleRetriever, and declaring the action without apage_size_reductionblock. Each raises with an actionable message naming the stream.Termination
Every reduction strictly decreases the size or raises. Both the
minimum_page_sizefloor andmax_attemptsraiseAirbyteTracedExceptionwithFailureType.transient_error. The attempt counter is never reset, so the worst case ispages + max_attemptsrequests per partition.Adoption, and the two things it caught
airbytehq/airbyte#85822 migrates all six source-github GraphQL streams onto this action. Building it surfaced two defects in the first commit here, both fixed rather than worked around in the connector:
c5fafe32— the strategy allowlist rejected everyCustomPaginationStrategy. The check was by type, which excluded exactly the streams the feature was built for: source-github's nested GraphQL traversal cannot be expressed by a built-in strategy. What actually matters is not whether the CDK recognizes the strategy but whether the strategy can be told the reduced size — and a custom strategy is written by the same person enabling the reduction. A custom strategy is now accepted when itsnext_page_tokentakes apage_size_overridekeyword (or**kwargs), checked by signature at config time so one that would raiseTypeErroron the first reduction is rejected up front with an actionable message.fed78ad4— a non-integer page size crashed the reducer.reduce()didcurrent_page_size // reduction_factoron whateverget_page_sizereturned. Built-in strategies always return an int; a custom one can return anything, and a string page size — which is what a custom component gets when a manifest field is left uninterpolated — failed mid-sync withTypeError: unsupported operand type(s) for //: 'str' and 'float'. Now a config error naming the type.Scope
Page size only. Request-window reduction (airbytehq/airbyte-internal-issues#17173) is a separate axis — it re-slices the datetime range and touches cursor state. This action never changes slice boundaries. The two are intended to compose, and this being the second occupant of the
ResponseAction→ signal-exception →_read_pagesseam should make that one a copy of an established pattern rather than a third mechanism.Relationship to #1056
Supersedes the draft in #1056, which will be closed. Same action name and same exception, different mechanics. #1056 mutated
_page_sizeon the shared strategy instance, so one partition's success wiped another's reduction mid-flight; its reduce loop was unbounded, so a permanently failing endpoint looped forever at page size 1; itsOffsetIncrementchange routed the reduced size throughget_page_size()only, leaving the stop-condition data loss above in place; it letPageIncrementreduce; it did not update theCompositeErrorHandlershort-circuit list; and itsisinstance(self._paginator, DefaultPaginator)guard silently no-oped underPaginatorTestReadDecorator, so Connector Builder test reads skipped reduction entirely.Testing
unit_tests/sources/declarative/retrievers/test_page_size_reducer.py(15 tests) — factor/floor/attempt arithmetic, reset policies, and the non-integer page size guard.test_simple_retriever.py— the retry re-fetches the same page, the retry request actually carries the reduced size (asserted on the outgoing request, not on strategy state), reset-policy behavior, floor andmax_attemptsexhaustion, the action raising when nopage_size_reductionis configured, and the two-thread partition-isolation test.PaginatorTestReadDecorator.test_offset_increment.pypins the stop-condition fix.test_model_to_component_factory.py— 10 tests for the config-time rejections, including a custom strategy that accepts the override and one that does not.test_concurrent_declarative_source.py— manifest-levelHttpMockerread asserting request 1 isfirst=100, the 502 is followed by request 2 to the same cursor withfirst=50, and all records arrive.CI on
c5fafe32: 4430 tests, 0 failures. Locallymypy,ruff checkandruff format --checkare clean, and the full suite is green except two pre-existingtest_file_based_scenarios.pyunstructured failures confirmed identical on a clean baseline.The
Check: destination-motherduckconnector check fails, and is unrelated: its unit tests pass and only the FAST standard tests fail, this change touches declarative source pagination only, and the same check fails on unrelated branches (devin/1787781944-record-expander-truncated-list, 6 consecutive runs).Draft, because
poe assemblecould not run locally (Docker daemon down), soairbyte_cdk/sources/declarative/models/declarative_component_schema.pywas written by hand in codegen style. It loads and behaves correctly, but needspoe assembleto confirm a zero diff — the enum class name generated forreset_policyin particular.Known gap, not addressed here
When reduction bottoms out, the terminal message is "The source is still failing with the smallest page the connector is allowed to request (1 records per page)… Try syncing fewer streams at once, or contact the API provider." It does not name the stream and suggests an unrelated remedy. The connector-specific message it replaces named the stream and pointed at the page-size setting.
PageSizeReductionshould accept a connector-suppliederror_message; the same complaint applies toPaginationTracker's terminal error and was raised in the airbytehq/airbyte-internal-issues#17173 triage. Happy to fold it into this PR if reviewers prefer.🤖 Generated with Claude Code