fix(call-rate): honor RateLimit-Remaining when a reset header is present - #1133
devin-ai-integration[bot] wants to merge 5 commits into
Conversation
Co-Authored-By: bot_apk <apk@cognition.ai>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
👋 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@devin/1787707125-movingwindow-honor-ratelimit-headers#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 devin/1787707125-movingwindow-honor-ratelimit-headersPR Slash CommandsAirbyte Maintainers can execute the following slash commands on your PR:
|
There was a problem hiding this comment.
Pull request overview
This PR fixes MovingWindowCallRatePolicy.update() so it properly synchronizes the local moving-window bucket with API-provided rate-limit feedback (notably when both “remaining” and “reset” headers are present), and adds unit tests covering the corrected behavior and HttpAPIBudget.update_from_response() integration.
Changes:
- Update moving-window rate-limit state from
available_callsregardless ofcall_reset_ts, instead of silently ignoring the “both headers present” case. - Add
_calls_left()helper to compute remaining allowance across multiple configured rates (most constraining rate wins). - Add new unit tests covering update semantics (both headers present, no-op cases, most-constraining rate behavior, and the zero-available-calls bucket fill case) plus an end-to-end
HttpAPIBudgetheader-driven update test.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
airbyte_cdk/sources/streams/call_rate.py |
Fixes moving-window update logic and introduces _calls_left() to reconcile bucket state with API-reported remaining calls. |
unit_tests/sources/streams/test_call_rate.py |
Adds unit tests validating the corrected moving-window update behavior and HttpAPIBudget.update_from_response() integration. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
PyTest Results (Full)4 402 tests 4 390 ✅ 10m 57s ⏱️ Results for commit 0a6c8e6. ♻️ This comment has been updated with latest results. |
This comment was marked as outdated.
This comment was marked as outdated.
Co-Authored-By: bot_apk <apk@cognition.ai>
This comment was marked as resolved.
This comment was marked as resolved.
Co-Authored-By: bot_apk <apk@cognition.ai>
|
/prerelease
|
|
Prerelease from
If you're pointing source-klaviyo at that image, what this build changes is only pacing, so the signal is in timing rather than in records: expect fewer https://app.devin.ai/sessions/878b6be04bb648618e2fe0b6834a9151 |
|
/prerelease
|
Co-Authored-By: bot_apk <apk@cognition.ai>
Summary
MovingWindowCallRatePolicy.update()silently dropped the rate-limit state reported by the API whenever the response carried both a remaining-calls header and a reset header — the exact shape Klaviyo (and many other APIs) return on every non-429 response. The both-present branch was a commented-outTODO, so every connector declaring anapi_budgetwith moving-window policies was purely feed-forward: it throttled to manifest constants and only learned about upstream state from a429after the fact.The single-header path was also effectively inert.
items_to_addwas assigned a comparison, not a count:so
available_calls == 0— which is what a429produces, see below — added a single dummy call instead of filling the bucket.After this change,
update()reacts toavailable_callsregardless ofcall_reset_ts, and fills the bucket until what it still allows equals what the API reports:_calls_left()is the new helper: for each configured rate it counts the items inside that rate's own interval (viabinary_search, the same primitiveInMemoryBucket.putuses) and returns the most constraining remainder. That matters for the burst+steady rate pairs connectors actually declare — comparing againstrates[0].limitalone (the old code) would let a tighter long-window rate go unenforced, andbucket.count()counts the whole max-interval window rather than the rate's own.The
429path, and why the fill is boundedHttpAPIBudget.get_calls_left_from_response()returns0for any status instatus_codes_for_ratelimit_hit(default[429]) even when the response carries no remaining header at all. So the fill path is reached by every connector with a moving-window policy, not only those whose API publishesRateLimit-*. Filling to zero then makes the nextacquire_callsleep for the failing rate's entire interval, with nothing bounding it — 900 s for a single100 per PT15Mpolicy such assource-harvest's/reports/, which would breach the ≤600 s ceiling the linked issue requires (sources heartbeat at 5400 s) and would stack on top of theRetry-Afterbackoff that connector already performs._calls_left()therefore ignores any rate whose window exceedsMAX_HEADER_DRIVEN_WAIT— a hardcodedtimedelta(minutes=10)on the policy class — and returnsNonewhen that leaves nothing eligible, in which caseupdate()does not touch the bucket. A policy whose only window is longer than the bound keeps behaving exactly as it does today rather than parking a worker for the whole window. The constructor signature is unchanged, so no call site or manifest is affected.Deliberately not changed:
call_reset_tsstays unused, and is documented as such. A moving window has no reset point, so the window length remains the configured one and the only actionable signal is the number of calls left. This also sidesteps the fact thatHttpAPIBudget.get_reset_ts_from_response()parses the reset header as an absolute epoch timestamp while several APIs (Klaviyo included) document it as seconds remaining — a real latent bug forFixedWindowCallRatePolicyusers, but out of scope here and best fixed with an explicit semantics option.api_budget(e.g.source-klaviyo) pick this up once the CDK version ships insource-declarative-manifest.Known limitation: the update applies to the most constraining rate
With several rates configured,
min(calls_left)is normally the burst rate, while aRateLimit-Remainingheader normally describes the coarsest window the API publishes. For everysource-klaviyopolicy (burst/second paired with steady/minute) that means the update only starts to bite in roughly the last 5-10 % of the steady window; over the earlier part of the minute the connector paces as it does today.Applying the header to the coarsest rate instead is not a one-line change:
InMemoryBucket.put()validates the weight against every rate and inserts nothing if any one fails, so filling a 150/min window on a policy that also declares 10/s is rejected outright. Doing it correctly requires dummy items carrying spread-out past timestamps merged intobucket.items, plus an explicit header→rate mapping to say which window the header describes. Both are left as follow-ups; this PR's scope is making the feedback arrive at all and making the429path safe.Declarative-First Evaluation
The originating issue is on
source-klaviyo, a manifest-only connector, so a custom Python component was evaluated and rejected.source-klaviyoalready declares the right thing in its manifest — anHTTPAPIBudgetwith 11 per-endpointMovingWindowCallRatePolicyentries andstatus_codes_for_ratelimit_hit: [429], using the defaultratelimit-remaining/ratelimit-resetheader names, which match Klaviyo's headers (lookup is case-insensitive). That YAML is instantiated into the classes inairbyte_cdk/sources/streams/call_rate.pybyModelToComponentFactory.create_moving_window_call_rate_policy, so the connector already executes the code changed here. None of the declarative building blocks (RecordFilter,AddFields/RemoveFields,DatetimeBasedCursor,DefaultPaginator,SubstreamPartitionRouter, requester error handlers, transformations,$refoverrides) can affect inter-request pacing — that is entirely theapi_budgetpolicy's job. The gap was therefore not in the manifest or in any connector-side component, but in the shared CDK policy the manifest already points at, so the fix belongs here. Net result: no connector custom component, no manifest change, and no new declarative surface —declarative_component_schema.yamland its generated model are untouched.Behavior compatibility
429responses from APIs that send neither header:available_calls is None→ early return, unchanged.429responses:available_callsis0, so the bucket is filled for every moving-window policy whose window is within the bound. Worst-case induced wait is that policy's own window, bounded byMAX_HEADER_DRIVEN_WAIT(10 min). Policies whose only window exceeds the bound are untouched.0rather than overflowing every rate's headroom and silently inserting nothing.Not a breaking change under the connector breaking-change checklist: no schema, spec, state, or emitted-data change — only request pacing. No connector version bump here either; this is CDK-only, and
source-klaviyopicks it up when itssource-declarative-manifestbase image (pinned at7.24.0) is bumped. It is, however, a fleet-wide pacing change: 36 connectors inairbytehq/airbytedeclare aMovingWindowCallRatePolicy, and all of them reach the fill path via429.Reproduction
No live Klaviyo account was available (no private key), so this was not reproduced against the real API — it is verified statically and by unit tests. The gap is directly visible in the pre-change source:
update()guarded onavailable_calls is not None and call_reset_ts is None, whileHttpAPIBudget.update_from_response()passes both values whenever both headers are present, so Klaviyo's responses took the ignored path every time.test_update_available_calls_with_reset_tsreproduces that at the policy level (all 10 calls go through before the change), andTestHttpAPIBudget::test_update_from_responsereproduces it end-to-end through a response object carrying Klaviyo-shaped headers.Test Coverage
unit_tests/sources/streams/test_call_rate.py:test_update_available_calls_with_reset_ts— the both-headers-present combination now throttles.test_update_only_lowers_allowance—available_calls=50against a limit of 10 is a no-op.test_update_is_noop_without_available_calls— headerless non-429responses unaffected.test_update_respects_the_most_constraining_rate—10/s+5/min, so the long window binds from empty; asserts the failing rate islimit=5/1.0mand the wait is ~60 s. Both mutants of_calls_left(the oldrates[0].limit - count()quantity, andcalls_left[0]in place ofmin) fail this test.test_update_available_calls_zero_fills_bucket— covers theweight=Truebug.test_update_ignores_rates_over_header_wait_cap— a100 per PT15Mpolicy is left untouched byavailable_calls=0.test_update_caps_to_eligible_rate— mixed10/min+100/15min; throttles on the eligible rate with a wait inside the bound.test_update_clamps_negative_available_calls— a negative header does not fail open.TestHttpAPIBudget::test_update_from_response— end-to-end withRateLimit-Remaining/-Reset/-Limit.TestHttpAPIBudget::test_update_from_429_response— the header-less429path that every fleet connector reaches; asserts the wait stays inside the bound.TestHttpAPIBudget::test_update_from_429_response_ignores_over_cap_policy— a429against an over-bound policy causes no stall.pytest unit_tests/sources/streams/test_call_rate.py→ 49 passed. Fulltest_model_to_component_factory.py→ 157 passed. Declarative schema/model consistency tests pass.ruff check/ruff formatclean;mypy --config-file mypy.ini airbyte_cdkclean.Follow-ups
max_header_driven_waitonMovingWindowCallRatePolicy) is in0a6c8e66's parent if it's wanted later.HttpAPIBudget.get_reset_ts_from_response()parsing the reset header as an epoch timestamp when several APIs document it as seconds remaining, which affectsFixedWindowCallRatePolicy.Resolves https://github.com/airbytehq/airbyte-internal-issues/issues/17029:
Link to Devin session: https://app.devin.ai/sessions/878b6be04bb648618e2fe0b6834a9151
Open in Devin Desktop: https://app.devin.ai/desktop/session/878b6be04bb648618e2fe0b6834a9151?variant=devin