Skip to content

feat(taps): Respect rate-limit headers in the default REST backoff#3672

Open
Nazib65 wants to merge 3 commits into
meltano:mainfrom
Nazib65:feat/respect-ratelimit-headers
Open

feat(taps): Respect rate-limit headers in the default REST backoff#3672
Nazib65 wants to merge 3 commits into
meltano:mainfrom
Nazib65:feat/respect-ratelimit-headers

Conversation

@Nazib65

@Nazib65 Nazib65 commented Jun 8, 2026

Copy link
Copy Markdown

What & why

Closes #2012.

The default RESTStream.backoff_wait_generator returned backoff.expo(factor=2) and
never looked at the response, so taps hitting 429 Too Many Requests ignored the
server's own Retry-After / X-RateLimit-Reset headers and waited a guessed
exponential interval instead.

Per the discussion with @ReubenFrankel and @edgarrmondragon, this implements
header-aware backoff within the existing mechanism (the native-retries refactor is
left for later given the open RetriableAPIError questions), respecting rate-limit
headers when present and falling back to exponential backoff when not.

Changes

  • backoff_wait_generator is now a generator using backoff's
    prime-then-.send(exception) protocol. It reads the response defensively
    (getattr(exception, "response", None)) so connection/timeout errors that carry no
    response fall back cleanly instead of raising AttributeError.
  • New overridable get_wait_time_from_response(response) hook reads Retry-After
    (precedence; seconds or HTTP-date) then X-RateLimit-Reset.
  • New _parse_retry_after helper for the two Retry-After value forms.

Decisions worth a look

  • X-RateLimit-Reset is treated as seconds-until-reset (delta) per the IETF
    RateLimit header fields draft referenced in the issue, not a Unix epoch timestamp.
    Happy to switch to epoch (or support both) if you'd prefer — it's a small change.
  • Retry-After takes precedence over X-RateLimit-Reset; 0-second waits are honoured.
  • Fully backwards compatible — taps overriding backoff_wait_generator are unaffected.

Tests

Added to tests/core/rest/test_failure.py:

  • get_wait_time_from_response parsing: Retry-After seconds + HTTP-date, X-RateLimit-Reset,
    precedence, and unparsable/missing → exponential fallback.
  • End-to-end generator drive: header → respected wait; ConnectionError (no response) →
    exponential fallback without crashing; header-less 429 → exponential continues.

Full tests/core/rest/ passes (89); ruff, ruff format, ty, and mypy are clean.

🤖 Generated with Claude Code

Summary by Sourcery

Respect HTTP rate-limit headers when computing REST retry backoff and add tests to verify the new behavior.

New Features:

  • Introduce a header-aware backoff wait generator that prefers server-provided wait times and falls back to exponential backoff.
  • Add an overridable hook to derive retry wait time from HTTP responses, including support for standard Retry-After and X-RateLimit-Reset headers.

Enhancements:

  • Improve robustness of REST backoff handling for errors without HTTP responses by safely falling back to exponential backoff.

Tests:

  • Add unit tests covering wait-time derivation from rate-limit headers, HTTP-date Retry-After parsing, and the interaction between header-based waits and exponential fallback in the backoff generator.

`RESTStream.backoff_wait_generator` previously returned a pure
`backoff.expo` sequence and never inspected the HTTP response, so a
`429` carrying `Retry-After` / `X-RateLimit-Reset` was ignored in favour
of guessed exponential waits.

It is now a generator driven by backoff's `.send(exception)` protocol. On
each retry it reads the response (guarding against errors that carry no
response, e.g. connection resets and timeouts) and defers to a new
overridable `get_wait_time_from_response` hook, falling back to
exponential backoff when no usable header is present.

`get_wait_time_from_response` reads `Retry-After` (seconds or HTTP-date,
taking precedence) then `X-RateLimit-Reset` (seconds until reset, per the
IETF RateLimit header fields draft).

Closes meltano#2012
@Nazib65 Nazib65 requested a review from edgarrmondragon as a code owner June 8, 2026 07:45
@sourcery-ai

sourcery-ai Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Updates RESTStream's default backoff to respect standard rate-limit headers when present while remaining backward-compatible by falling back to exponential backoff, and adds tests covering header parsing and generator behavior.

Sequence diagram for RESTStream backoff_wait_generator using rate-limit headers

sequenceDiagram
    participant Backoff as backoff_decorator
    participant Stream as RESTStream
    participant Expo as backoff_expo
    participant Response as requests_Response

    Backoff->>Stream: backoff_wait_generator()
    activate Stream
    Stream->>Expo: backoff.expo(factor=2)
    Expo-->>Stream: exponential_wait
    Note over Stream: Generator primed

    loop on each retry
        Backoff->>Stream: send(exception)
        activate Stream
        Stream->>Stream: getattr(exception, response, None)
        alt [response is not None]
            Stream->>Stream: get_wait_time_from_response(response)
            alt [Retry-After or X-RateLimit-Reset]
                Stream-->>Backoff: wait_seconds
            else [no usable header]
                Stream->>Expo: send(None)
                Expo-->>Stream: exponential_wait
                Stream-->>Backoff: exponential_wait
            end
        else [response is None]
            Stream->>Expo: send(None)
            Expo-->>Stream: exponential_wait
            Stream-->>Backoff: exponential_wait
        end
        deactivate Stream
    end
Loading

File-Level Changes

Change Details Files
Make RESTStream.backoff_wait_generator header-aware while preserving exponential fallback semantics.
  • Replace simple backoff.expo(factor=2) return with a stateful generator that primes an exponential backoff generator and then loops, receiving the exception via .send().
  • Extract the HTTP response from the exception defensively using getattr(exception, "response", None) to handle errors without responses (e.g., connection errors, timeouts).
  • Compute a wait time from the response via get_wait_time_from_response(response) when available; otherwise, or when it returns None, delegate to the exponential fallback.
  • Yield the computed wait time and continue the loop, accepting the next exception via .send().
singer_sdk/streams/rest.py
Introduce a response-driven wait-time hook and Retry-After parsing helper to interpret standard rate-limit headers.
  • Add RESTStream.get_wait_time_from_response(response) to inspect response.headers for Retry-After and X-RateLimit-Reset, giving precedence to Retry-After and treating X-RateLimit-Reset as seconds until reset.
  • Implement parsing for Retry-After numeric seconds and HTTP-date forms via a new static helper _parse_retry_after, including normalization to UTC and clamping to non-negative values.
  • Return None from get_wait_time_from_response when headers are missing or unparsable to signal fallback to exponential backoff.
  • Document the new method, header precedence, behavior, and versionadded directive in the docstring.
singer_sdk/streams/rest.py
Add tests to validate header parsing and backoff generator behavior under various rate-limiting and error scenarios.
  • Parametrize tests for get_wait_time_from_response to cover Retry-After seconds, X-RateLimit-Reset seconds, header precedence, unparsable values, and missing headers (expecting None).
  • Add a test ensuring Retry-After as an HTTP-date is translated into an approximate seconds-from-now wait, with tolerance for timing drift.
  • Introduce a helper _retriable() that constructs a RetriableAPIError with an optional 429 response and headers for use in tests.
  • Add an end-to-end test for backoff_wait_generator verifying that rate-limit headers are respected, connection errors without responses fall back to exponential backoff, and 429 responses without headers continue the exponential sequence.
tests/core/rest/test_failure.py

Assessment against linked issues

Issue Objective Addressed Explanation
#2012 Update the default RESTStream backoff implementation to inspect HTTP rate-limit headers (including X-RateLimit-Reset) on responses and respect them when determining retry wait times, falling back to exponential backoff when no usable header is present or when there is no response.
#2012 Add or update tests for RESTStream to validate that the new backoff logic respects Retry-After and X-RateLimit-Reset headers, correctly parses their values (including HTTP dates), and falls back to exponential backoff in header-less or unparsable scenarios.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@read-the-docs-community

read-the-docs-community Bot commented Jun 8, 2026

Copy link
Copy Markdown

Documentation build overview

📚 Meltano SDK | 🛠️ Build #33035112 | 📁 Comparing f17ce32 against latest (703866f)

  🔍 Preview build  

3 files changed
± genindex.html
± classes/singer_sdk.GraphQLStream.html
± classes/singer_sdk.RESTStream.html

@codecov

codecov Bot commented Jun 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.16%. Comparing base (bf02d4f) to head (f17ce32).
⚠️ Report is 14 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3672      +/-   ##
==========================================
+ Coverage   94.12%   94.16%   +0.03%     
==========================================
  Files          73       73              
  Lines        6198     6233      +35     
  Branches      762      766       +4     
==========================================
+ Hits         5834     5869      +35     
  Misses        270      270              
  Partials       94       94              
Flag Coverage Δ
core 83.04% <100.00%> (+0.09%) ⬆️
end-to-end 75.70% <24.32%> (-0.32%) ⬇️
optional-components 44.64% <16.21%> (-0.18%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • Given how commonly X-RateLimit-Reset is used as a Unix epoch timestamp in existing APIs, it might be worth making get_wait_time_from_response support both delta and epoch formats (e.g., by treating large values as epoch seconds or by allowing an overridable parsing helper) rather than only the draft’s delta semantics.
  • If you expect some users to override get_wait_time_from_response, consider passing the full exception object (not just the response) into that hook so custom implementations can use error type/context for logging or non-HTTP failures, instead of only having access to response.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Given how commonly `X-RateLimit-Reset` is used as a Unix epoch timestamp in existing APIs, it might be worth making `get_wait_time_from_response` support both delta and epoch formats (e.g., by treating large values as epoch seconds or by allowing an overridable parsing helper) rather than only the draft’s delta semantics.
- If you expect some users to override `get_wait_time_from_response`, consider passing the full exception object (not just the response) into that hook so custom implementations can use error type/context for logging or non-HTTP failures, instead of only having access to `response`.

## Individual Comments

### Comment 1
<location path="tests/core/rest/test_failure.py" line_range="232-239" />
<code_context>
+@pytest.mark.parametrize(
+    "headers,expected",
+    [
+        ({"Retry-After": "30"}, 30.0),
+        ({"X-RateLimit-Reset": "45"}, 45.0),
+        # Retry-After takes precedence over X-RateLimit-Reset.
+        ({"Retry-After": "30", "X-RateLimit-Reset": "999"}, 30.0),
+        # Unparsable / missing headers fall back to exponential backoff (None).
+        ({"Retry-After": "not-a-number"}, None),
+        ({"X-RateLimit-Reset": "not-a-number"}, None),
+        ({}, None),
+    ],
+    ids=[
</code_context>
<issue_to_address>
**suggestion (testing):** Add coverage for zero and negative Retry-After / X-RateLimit-Reset values being clamped to 0.0

Since the code clamps values with `max(0.0, float(...))`, this parametrized test should also cover zero and negative inputs for both headers, e.g.:

- `{"Retry-After": "0"}``0.0`
- `{"Retry-After": "-10"}``0.0`
- `{"X-RateLimit-Reset": "0"}``0.0`
- `{"X-RateLimit-Reset": "-5"}``0.0`

This will verify the clamping behavior and protect against regressions where negative waits might be reintroduced.
</issue_to_address>

### Comment 2
<location path="tests/core/rest/test_failure.py" line_range="284-293" />
<code_context>
+    return RetriableAPIError("rate limited", response)
+
+
+def test_backoff_wait_generator_respects_headers_and_falls_back(
+    basic_rest_stream: RESTStream,
+):
+    """Header waits are respected; other errors fall back to exponential backoff."""
+    gen = basic_rest_stream.backoff_wait_generator()
+    gen.send(None)  # Prime the generator (matches backoff's initialization).
+
+    # 429 with a rate-limit header -> respect the header's wait time.
+    assert gen.send(_retriable({"Retry-After": "30"})) == 30
+
+    # Connection-level error has no response -> exponential fallback, no crash.
+    assert gen.send(requests.exceptions.ConnectionError()) == 2
+
+    # 429 without rate-limit headers -> exponential backoff continues.
+    assert gen.send(_retriable(None)) == 4
</code_context>
<issue_to_address>
**suggestion (testing):** Add a case where backoff_wait_generator sees an unparsable rate-limit header and falls back to exponential

Please also cover the case where rate-limit headers are present but unparsable (e.g. `{"Retry-After": "not-a-number"}`), so that `get_wait_time_from_response` returns `None` and the generator falls back to the exponential sequence. For example:

```python
# 429 with an unparsable rate-limit header -> exponential fallback.
assert gen.send(_retriable({"Retry-After": "not-a-number"})) == 8
```

(or whatever the next value in the sequence is). This will exercise the integration between `get_wait_time_from_response` and the exponential fallback for this edge case.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +232 to +239
({"Retry-After": "30"}, 30.0),
({"X-RateLimit-Reset": "45"}, 45.0),
# Retry-After takes precedence over X-RateLimit-Reset.
({"Retry-After": "30", "X-RateLimit-Reset": "999"}, 30.0),
# Unparsable / missing headers fall back to exponential backoff (None).
({"Retry-After": "not-a-number"}, None),
({"X-RateLimit-Reset": "not-a-number"}, None),
({}, None),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add coverage for zero and negative Retry-After / X-RateLimit-Reset values being clamped to 0.0

Since the code clamps values with max(0.0, float(...)), this parametrized test should also cover zero and negative inputs for both headers, e.g.:

  • {"Retry-After": "0"}0.0
  • {"Retry-After": "-10"}0.0
  • {"X-RateLimit-Reset": "0"}0.0
  • {"X-RateLimit-Reset": "-5"}0.0

This will verify the clamping behavior and protect against regressions where negative waits might be reintroduced.

Comment on lines +284 to +293
def test_backoff_wait_generator_respects_headers_and_falls_back(
basic_rest_stream: RESTStream,
):
"""Header waits are respected; other errors fall back to exponential backoff."""
gen = basic_rest_stream.backoff_wait_generator()
gen.send(None) # Prime the generator (matches backoff's initialization).

# 429 with a rate-limit header -> respect the header's wait time.
assert gen.send(_retriable({"Retry-After": "30"})) == 30

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add a case where backoff_wait_generator sees an unparsable rate-limit header and falls back to exponential

Please also cover the case where rate-limit headers are present but unparsable (e.g. {"Retry-After": "not-a-number"}), so that get_wait_time_from_response returns None and the generator falls back to the exponential sequence. For example:

# 429 with an unparsable rate-limit header -> exponential fallback.
assert gen.send(_retriable({"Retry-After": "not-a-number"})) == 8

(or whatever the next value in the sequence is). This will exercise the integration between get_wait_time_from_response and the exponential fallback for this edge case.

@codspeed-hq

codspeed-hq Bot commented Jun 8, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 8 untouched benchmarks


Comparing Nazib65:feat/respect-ratelimit-headers (2c996ee) with main (703866f)

Open in CodSpeed

Nazib65 added 2 commits June 8, 2026 03:50
Address review suggestions: assert that zero and negative `Retry-After` /
`X-RateLimit-Reset` values are clamped to 0.0, and that
`backoff_wait_generator` falls back to exponential backoff when a response
carries an unparsable rate-limit header.
Exercises the branch in `_parse_retry_after` that treats a timezone-naive
HTTP date as UTC, bringing patch coverage of the rate-limit backoff
changes to 100%.
@Nazib65

Nazib65 commented Jun 8, 2026

Copy link
Copy Markdown
Author

Thanks @sourcery-ai 🙏

Test suggestions — both added: zero/negative clamping in dbd417d8, and the
generator's unparsable-header fallback (plus naive-date coverage) in f17ce328.

Design points — I made deliberate choices here, but they're genuinely open and
I'd defer to maintainer preference:

  1. X-RateLimit-Reset delta vs epoch — I implemented delta-seconds per the IETF
    RateLimit-Headers draft cited in feat: Respect RateLimit headers in default REST backoff implementation #2012. Auto-detecting delta-vs-epoch is ambiguous
    (a large delta is indistinguishable from a near-future epoch, and guessing wrong
    risks very long waits), so I kept it spec-literal and made
    get_wait_time_from_response overridable for epoch-style APIs. Happy to add explicit
    epoch support if preferred.

  2. Passing response vs the full exception — I pass the Response (guaranteed
    non-None; the generator does the no-response guarding). This was partly to avoid the
    footgun behind connection-level crashes where a hook assumed exception.response was
    always populated. Glad to switch to passing the exception and document the
    None-response case if that flexibility is worth more.

@edgarrmondragon @ReubenFrankel — any preference on these two?

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.

feat: Respect RateLimit headers in default REST backoff implementation

1 participant