This is an automated email from the ASF dual-hosted git repository.
hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new 46caa7c82 fix(connectors): honor Retry-After on every retried status
(#4192)
46caa7c82 is described below
commit 46caa7c82bd3a329bc319b135dd50779d38fa1c5
Author: Ryan Huang <[email protected]>
AuthorDate: Sun Oct 11 00:15:18 2026 +0800
fix(connectors): honor Retry-After on every retried status (#4192)
---
.claude/skills/connector-sdk/SKILL.md | 2 +-
core/connectors/sdk/README.md | 2 +
core/connectors/sdk/src/retry.rs | 195 +++++++++++++++++-----
core/connectors/sinks/influxdb_sink/README.md | 5 +-
core/connectors/sinks/quickwit_sink/README.md | 4 +-
core/connectors/sources/influxdb_source/README.md | 4 +-
6 files changed, 163 insertions(+), 49 deletions(-)
diff --git a/.claude/skills/connector-sdk/SKILL.md
b/.claude/skills/connector-sdk/SKILL.md
index 8ad3a2791..440e9a1ec 100644
--- a/.claude/skills/connector-sdk/SKILL.md
+++ b/.claude/skills/connector-sdk/SKILL.md
@@ -154,7 +154,7 @@ Plugin authors call this on every consumed message. The
implementation in `lib.r
- `CircuitBreaker`: threshold + cooldown, `try_lock()` on the success path to
avoid hot-path contention.
- `retry_async(policy, context, should_retry, op)`: the retry loop for
anything failing as `Err`. Owns attempt counting, backoff and the per-retry
log; returns `RetryFailure { error, attempts, exhausted }` so callers write
their own terminal log.
- `retry_backoff(base, retry, max)`: backoff only, for a loop that computes
its own delay. `retry` is 1-based. `exponential_backoff` is the 0-based
primitive underneath and applies no jitter, so call it directly only when the
delay must be exact (`source.rs::nack_retry_delay`, whose tests assert exact
values).
-- `HttpRetryMiddleware`: integrates with `reqwest-middleware`. Retries 429 +
5xx + network errors. Honors `Retry-After`.
+- `HttpRetryMiddleware`: integrates with `reqwest-middleware`. Retries 429 +
5xx + network errors. Honors `Retry-After` on 429 and 5xx as a floor over the
computed backoff, capped at a one-hour `MAX_RETRY_AFTER`, not `max_delay`.
- `max_retries` = **total attempts** including the first try, not extra
retries. Document if you change this convention. `meilisearch_sink` is the
standing exception: its `max_retries` / `max_open_retries` count retries
*after* the first, as its README states.
- New helpers must take `Duration` (not `u64 millis`) on the public API.
Internal computation uses `humantime` parsing of `String`.
diff --git a/core/connectors/sdk/README.md b/core/connectors/sdk/README.md
index f058291d4..f046397dc 100644
--- a/core/connectors/sdk/README.md
+++ b/core/connectors/sdk/README.md
@@ -54,6 +54,8 @@ value.static = "hello"
`retry_async` runs an operation that fails with `Err` and retries it while
`should_retry` accepts the error. It owns attempt counting, backoff and the
per-retry log, and returns `RetryFailure { error, attempts, exhausted }` so the
caller logs the terminal failure. `retry_backoff` computes a single delay for a
loop that cannot use `retry_async`, such as `HttpRetryMiddleware`, which
retries on an `Ok` response rather than an `Err`. Its `retry` argument is
1-based.
+`HttpRetryMiddleware` reads a server's `Retry-After` header on every status it
retries, which is 429 and any 5xx. The header can lengthen the computed backoff
but never shorten it, so a zero or a short value keeps the backoff. `max_delay`
bounds only the computed backoff. The header has its own one-hour ceiling, and
a value past it is drawn from the jitter window just below it. Only the
integer-seconds form is read, so an HTTP-date or unparsable value falls back to
the computed backoff.
+
Two symbols were removed and one field changed meaning, each in a way that
breaks out-of-tree plugins, so those plugins must be rebuilt against the
current source:
| Removed | Replacement |
diff --git a/core/connectors/sdk/src/retry.rs b/core/connectors/sdk/src/retry.rs
index 362eebbcd..7ff788613 100644
--- a/core/connectors/sdk/src/retry.rs
+++ b/core/connectors/sdk/src/retry.rs
@@ -37,6 +37,7 @@ use anyhow::anyhow;
use http::Extensions;
use humantime::Duration as HumanDuration;
use rand::RngExt as _;
+use reqwest::header::{HeaderMap, RETRY_AFTER};
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware, Middleware,
Next};
use std::fmt;
use std::future::Future;
@@ -168,8 +169,8 @@ pub fn exponential_backoff(base: Duration, attempt: u32,
max_delay: Duration) ->
}
/// Parse a `Retry-After` header value (integer seconds).
-/// Returns `None` for HTTP-date values — callers should fall back to their
-/// own backoff strategy.
+/// Returns `None` for any value that is not integer seconds, including the
+/// HTTP-date form — callers should fall back to their own backoff strategy.
pub fn parse_retry_after(value: &str) -> Option<Duration> {
if let Ok(secs) = value.trim().parse::<u64>() {
return Some(Duration::from_secs(secs));
@@ -352,11 +353,43 @@ pub fn is_transient_status(status: reqwest::StatusCode)
-> bool {
status == reqwest::StatusCode::TOO_MANY_REQUESTS ||
status.is_server_error()
}
+/// Ceiling on a server-supplied `Retry-After`, separate from and much larger
+/// than `max_delay`, which bounds only the backoff we compute ourselves. It
+/// guards against an absurd value rather than tuning a real rate-limit window.
+const MAX_RETRY_AFTER: Duration = Duration::from_secs(60 * 60);
+
+/// Read the delay a response's `Retry-After` header asks for, before any cap.
+///
+/// Returns `None` when the header is absent, unreadable, or in the HTTP-date
+/// form that [`parse_retry_after`] does not accept.
+fn requested_retry_after(headers: &HeaderMap) -> Option<Duration> {
+ headers
+ .get(RETRY_AFTER)
+ .and_then(|value| value.to_str().ok())
+ .and_then(parse_retry_after)
+}
+
+/// The wait before a retry whose response carried `Retry-After`.
+///
+/// The header can only lengthen the computed `backoff`, never shorten it, so a
+/// `Retry-After: 0` or a short window keeps the backoff the operator
configured.
+/// A value past `ceiling` is drawn from the jitter window below it instead of
+/// landing on it, so instances throttled together do not retry in lockstep.
+fn retry_after_delay(requested: Duration, backoff: Duration, ceiling:
Duration) -> Duration {
+ let honored = if requested > ceiling {
+ retry_backoff(ceiling, 1, ceiling)
+ } else {
+ requested
+ };
+ honored.max(backoff)
+}
+
/// Per-request retry middleware for HTTP connectors.
///
/// Wraps a `reqwest-middleware` stack and retries transient failures
/// (HTTP 429, 5xx, network errors) with exponential back-off and ±20 % jitter.
-/// A `Retry-After` response header on a 429 overrides the calculated delay.
+/// A `Retry-After` response header on a retried status can lengthen the
+/// calculated delay, up to a one-hour ceiling of its own.
///
/// The `log_prefix` parameter identifies the connector in log messages
/// (e.g. `"InfluxDB"`, `"Elasticsearch"`), allowing this middleware to be
@@ -420,29 +453,30 @@ impl Middleware for HttpRetryMiddleware {
return Ok(response);
}
- // Parse Retry-After on 429 before falling back to our own
- // calculated backoff.
- let retry_after = if status ==
reqwest::StatusCode::TOO_MANY_REQUESTS {
- response
- .headers()
- .get("Retry-After")
- .and_then(|value| value.to_str().ok())
- .and_then(parse_retry_after)
- } else {
- None
- };
-
attempts += 1;
if is_transient_status(status) && attempts <
self.policy.max_attempts {
+ let backoff = self.policy.backoff(attempts);
+ let requested =
requested_retry_after(response.headers());
+ let delay = requested.map_or(backoff, |requested| {
+ retry_after_delay(requested, backoff,
MAX_RETRY_AFTER)
+ });
// Consume the error body for logging, then retry.
let body_text =
response.text().await.unwrap_or_default();
- let delay = retry_after.unwrap_or_else(||
self.policy.backoff(attempts));
- warn!(
- "{} transient error {status} \
- (attempt {attempts}/{}): {body_text}. \
- Retrying in {delay:?}...",
- self.log_prefix, self.policy.max_attempts
- );
+ match requested.filter(|requested| *requested >
MAX_RETRY_AFTER) {
+ Some(requested) => warn!(
+ "{} transient error {status} \
+ (attempt {attempts}/{}): {body_text}. \
+ Retry-After asked for {requested:?}, past the
\
+ {MAX_RETRY_AFTER:?} ceiling. Retrying in
{delay:?}...",
+ self.log_prefix, self.policy.max_attempts
+ ),
+ None => warn!(
+ "{} transient error {status} \
+ (attempt {attempts}/{}): {body_text}. \
+ Retrying in {delay:?}...",
+ self.log_prefix, self.policy.max_attempts
+ ),
+ }
tokio::time::sleep(delay).await;
current_req = match next_req {
Some(r) => r,
@@ -816,34 +850,109 @@ mod tests {
);
}
- #[tokio::test]
- async fn
given_a_retry_after_should_take_precedence_over_the_computed_backoff() {
- let server = mock_then_ok(429, &[("Retry-After", "1")]).await;
- // Backoff bounded far below the header, so honoring it is visible.
- let client = retry_client(3, Duration::from_millis(10),
Duration::from_millis(50));
-
- let started = Instant::now();
- let response = client.get(server.uri()).send().await.unwrap();
- let elapsed = started.elapsed();
+ fn retry_after_header(value: &str) -> HeaderMap {
+ let mut headers = HeaderMap::new();
+ headers.insert(RETRY_AFTER, value.parse().unwrap());
+ headers
+ }
- assert_eq!(response.status(), 200);
+ #[test]
+ fn given_a_retry_after_should_read_the_full_requested_delay() {
+ // The middleware applies the cap, and logs the full value when it
does.
assert_eq!(
- server.received_requests().await.unwrap().len(),
- 2,
- "expected exactly one retry"
+ requested_retry_after(&retry_after_header("86400")),
+ Some(Duration::from_secs(86400))
);
+ }
+
+ #[test]
+ fn
given_an_unusable_retry_after_should_fall_back_to_the_computed_backoff() {
+ for (header, why) in [
+ (
+ "Wed, 21 Oct 2026 07:28:00 GMT",
+ "the HTTP-date form is not parsed yet",
+ ),
+ (
+ "not-a-number",
+ "an unparsable header must not stop the retry",
+ ),
+ ] {
+ assert_eq!(
+ requested_retry_after(&retry_after_header(header)),
+ None,
+ "{why}"
+ );
+ }
+ }
+
+ #[test]
+ fn
given_a_retry_after_past_the_ceiling_should_draw_from_the_jitter_window_below_it()
{
+ let ceiling = Duration::from_secs(10);
+ let delays: Vec<Duration> = (0..100)
+ .map(|_| {
+ retry_after_delay(
+ Duration::from_secs(86400),
+ Duration::from_millis(10),
+ ceiling,
+ )
+ })
+ .collect();
+
+ for delay in &delays {
+ assert!(
+ *delay >= ceiling.mul_f64(JITTER_LOW) && *delay <= ceiling,
+ "a day-long header must land in the jitter window under
{ceiling:?}, got {delay:?}"
+ );
+ }
assert!(
- elapsed >= Duration::from_millis(900),
- "used the computed backoff instead of Retry-After: waited
{elapsed:?}"
- );
- // The header asks for 1s. Anything far past it means the middleware
- // added its own backoff on top instead of honoring the header.
- assert!(
- elapsed < Duration::from_millis(1500),
- "waited {elapsed:?}, past the 1s the header asked for"
+ delays.iter().any(|delay| *delay < ceiling),
+ "every draw landed on {ceiling:?}, so throttled instances would
retry in lockstep"
);
}
+ #[test]
+ fn given_a_retry_after_shorter_than_the_backoff_should_keep_the_backoff() {
+ let backoff = Duration::from_secs(5);
+
+ for requested in [Duration::ZERO, Duration::from_secs(1)] {
+ assert_eq!(
+ retry_after_delay(requested, backoff, MAX_RETRY_AFTER),
+ backoff,
+ "a {requested:?} header must not shorten the computed backoff"
+ );
+ }
+ }
+
+ #[tokio::test]
+ async fn
given_a_retry_after_past_max_delay_should_be_honored_on_every_retried_status() {
+ // RFC 9110 allows Retry-After on any 5xx, and InfluxDB OSS documents
it
+ // on 503. `max_delay` bounds only the computed backoff, so the header
+ // must outrank it.
+ for status in [429, 503] {
+ let server = mock_then_ok(status, &[("Retry-After", "1")]).await;
+ let client = retry_client(3, Duration::from_millis(10),
Duration::from_millis(100));
+
+ let started = Instant::now();
+ let response = client.get(server.uri()).send().await.unwrap();
+ let elapsed = started.elapsed();
+
+ assert_eq!(response.status(), 200);
+ assert_eq!(
+ server.received_requests().await.unwrap().len(),
+ 2,
+ "expected exactly one retry on {status}"
+ );
+ assert!(
+ elapsed >= Duration::from_millis(900),
+ "ignored Retry-After on {status} and used the computed
backoff: waited {elapsed:?}"
+ );
+ assert!(
+ elapsed < Duration::from_millis(1500),
+ "waited {elapsed:?} on {status}, past the 1s the header asked
for"
+ );
+ }
+ }
+
#[test]
fn given_a_retry_number_should_back_off_from_the_base_delay() {
let policy = policy(8);
diff --git a/core/connectors/sinks/influxdb_sink/README.md
b/core/connectors/sinks/influxdb_sink/README.md
index 1ab1b5b91..361e61da1 100644
--- a/core/connectors/sinks/influxdb_sink/README.md
+++ b/core/connectors/sinks/influxdb_sink/README.md
@@ -156,8 +156,9 @@ The sink uses a layered design:
A serialization error rejects its whole chunk. Later chunks are still
attempted after failures,
and the first error is returned after the loop. `max_retries = 3` allows two
retries; `0` and
`1` both allow one attempt. Backoff starts at `retry_delay`, doubles with ±20%
jitter and is
-capped by `retry_max_delay`. Integer-seconds `Retry-After` on a 429 overrides
that cap;
-HTTP-date values are ignored. Startup uses its own attempt budget/cap and
retries any failed
+capped by `retry_max_delay`. Integer-seconds `Retry-After` on a 429 or any 5xx
can
+lengthen the calculated delay past `retry_max_delay`, up to one hour, but
never shortens
+it. HTTP-date and unparsable values fall back to the calculated delay. Startup
uses its own attempt budget/cap and retries any failed
health check. Invalid duration strings warn and fall back to `1s`.
The runtime records a plugin error and continues polling with consumer
auto-commit. Failed
diff --git a/core/connectors/sinks/quickwit_sink/README.md
b/core/connectors/sinks/quickwit_sink/README.md
index b64575721..a5158d58f 100644
--- a/core/connectors/sinks/quickwit_sink/README.md
+++ b/core/connectors/sinks/quickwit_sink/README.md
@@ -11,7 +11,7 @@ The Quickwit connector sends data to the Quickwit API using
HTTP. It checks read
| `verbose_logging` | `false` | Log received and ingested message counts at
`info` instead of `debug`. |
| `max_retries` | `3` | Total HTTP attempts including the first; `0` and `1`
both allow one attempt. |
| `retry_delay` | `"1s"` | Base exponential delay for HTTP retries and
readiness probes. |
-| `retry_max_delay` | `"5s"` | Cap for calculated HTTP retry delays; a valid
`Retry-After` on HTTP 429 overrides it. |
+| `retry_max_delay` | `"5s"` | Cap for calculated HTTP retry delays. A valid
`Retry-After` on HTTP 429 or any 5xx can exceed it, up to one hour. |
| `max_open_retries` | `10` | One initial probe per readiness check, with up
to `max_open_retries - 1` retries shared between them; `0` and `1` disable
retries. |
| `open_retry_max_delay` | `"30s"` | Maximum delay between readiness probes. |
| `timeout` | `"30s"` | Timeout per HTTP attempt; retries and waits can extend
the complete operation. |
@@ -103,7 +103,7 @@ The examples use `mode: dynamic` to retain wrapper fields.
With `mode: strict`,
## Delivery semantics
-Transient HTTP failures, including 429, can retry a request that Quickwit
already accepted. Quickwit ingest has no deduplication key, so these retries
can produce duplicate documents. Set `max_retries = 1` to disable the sink's
HTTP retry loop. Calculated delays use exponential backoff with jitter; a valid
`Retry-After` on HTTP 429 replaces the calculated delay.
+Transient HTTP failures, including 429, can retry a request that Quickwit
already accepted. Quickwit ingest has no deduplication key, so these retries
can produce duplicate documents. Set `max_retries = 1` to disable the sink's
HTTP retry loop. Calculated delays use exponential backoff with jitter. A valid
`Retry-After` on HTTP 429 or any 5xx can lengthen the calculated delay past
`retry_max_delay`, up to one hour.
Service readiness retries any failed health probe. After verifying or creating
the index, the sink probes its ingest endpoint with an empty body before
accepting messages.
This is intentional: legacy index metadata can exist before the ingest queue
is ready, while the read-only `/tail` endpoint checks only legacy ingestion.
diff --git a/core/connectors/sources/influxdb_source/README.md
b/core/connectors/sources/influxdb_source/README.md
index 337f61e84..2de54a27b 100644
--- a/core/connectors/sources/influxdb_source/README.md
+++ b/core/connectors/sources/influxdb_source/README.md
@@ -112,7 +112,9 @@ circuit_breaker_threshold = 5 # consecutive failures
before circuit trips
circuit_breaker_cool_down = "30s" # cooldown before queries resume
```
-Attempt counts are clamped to at least one. Query retries use exponential
backoff with jitter; integer-seconds `Retry-After` on HTTP 429 can override the
delay cap. Other HTTP errors and malformed successful response bodies fail the
poll. Startup retries failed authenticated `GET /health` requests. Invalid
duration strings warn and fall back to `1s`, while zero durations are accepted.
`batch_size = 0` is clamped to `1`. Without `initial_offset`, the cursor starts
at `1970-01-01T00:00:00Z`.
+Attempt counts are clamped to at least one. Query retries use exponential
backoff with jitter. Integer-seconds `Retry-After` on HTTP 429 or any 5xx can
lengthen the calculated delay past `retry_max_delay`, up to one hour.
+
+HTTP 429 and 5xx fail the poll once the last attempt fails. Other HTTP errors
and malformed successful response bodies fail it right away. Startup retries
failed authenticated `GET /health` requests. Invalid duration strings warn and
fall back to `1s`, while zero durations are accepted. `batch_size = 0` is
clamped to `1`. Without `initial_offset`, the cursor starts at
`1970-01-01T00:00:00Z`.
## Query Template Placeholders