Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -2134,6 +2134,7 @@ definitions:
- "$ref": "#/definitions/ConstantBackoffStrategy"
- "$ref": "#/definitions/ExponentialBackoffStrategy"
- "$ref": "#/definitions/WaitTimeFromHeader"
- "$ref": "#/definitions/WaitTimeFromResponse"
- "$ref": "#/definitions/WaitUntilTimeFromHeader"
- "$ref": "#/definitions/CustomBackoffStrategy"
max_retries:
Expand Down Expand Up @@ -5117,6 +5118,42 @@ definitions:
$parameters:
type: object
additionalProperties: true
WaitTimeFromResponse:
title: Wait Time Extracted From Response
description: Compute the wait time, in relative seconds, from the JSON response body or the response headers. A non-JSON body is exposed as an empty `response`. If the expression evaluates to an empty, zero, negative or non-numeric result, or cannot be evaluated on the response, the next backoff strategy is used.
type: object
required:
- type
- wait_time_in_seconds
properties:
type:
type: string
enum: [WaitTimeFromResponse]
wait_time_in_seconds:
title: Wait Time in Seconds
description: The expression defining how long to wait before retrying, in seconds relative to now (convert an absolute timestamp with `timestamp(...) - now_utc().timestamp()`). An empty, zero, negative or non-numeric result, or an expression that cannot be evaluated on the response, defers to the next backoff strategy. Nothing bounds the wait unless `max_waiting_time_in_seconds` is set, so setting it is strongly recommended.
type: string
interpolation_context:
- config
- response
- headers
examples:
- "{{ response.retry_after_seconds }}"
- "{{ max(1, timestamp(response.error.message | regex_search('Retry after (\\S+Z)')) - now_utc().timestamp()) if (response is mapping and response.get('error') is mapping and ((response.error.message or '') | string | regex_search('Retry after (\\S+Z)'))) else '' }}"
max_waiting_time_in_seconds:
title: Max Waiting Time in Seconds
description: 'Stop the stream instead of waiting, when the computed wait is greater than or equal to this value. Can be a hardcoded number, or a string interpolated from the connector config so that the bound can be changed without a connector release. A value of 0 means never wait. Not evaluated when a rate-limited retry can rotate to another credential with quota; any other retryable error still consults this strategy.'
anyOf:
- type: number
- type: string
interpolation_context:
- config
examples:
- 3600
- "{{ config['max_waiting_time'] * 60 }}"
$parameters:
type: object
additionalProperties: true
WaitUntilTimeFromHeader:
title: Wait Until Time Defined In Response Header
description: Extract time at which we can retry the request from response header and wait for the difference between now and that time.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1455,6 +1455,26 @@ class WaitTimeFromHeader(BaseModel):
parameters: Optional[Dict[str, Any]] = Field(None, alias="$parameters")


class WaitTimeFromResponse(BaseModel):
type: Literal["WaitTimeFromResponse"]
wait_time_in_seconds: str = Field(
...,
description="The expression defining how long to wait before retrying, in seconds relative to now (convert an absolute timestamp with `timestamp(...) - now_utc().timestamp()`). An empty, zero, negative or non-numeric result, or an expression that cannot be evaluated on the response, defers to the next backoff strategy. Nothing bounds the wait unless `max_waiting_time_in_seconds` is set, so setting it is strongly recommended.",
examples=[
"{{ response.retry_after_seconds }}",
"{{ max(1, timestamp(response.error.message | regex_search('Retry after (\\S+Z)')) - now_utc().timestamp()) if (response is mapping and response.get('error') is mapping and ((response.error.message or '') | string | regex_search('Retry after (\\S+Z)'))) else '' }}",
],
title="Wait Time in Seconds",
)
max_waiting_time_in_seconds: Optional[Union[float, str]] = Field(
None,
description="Stop the stream instead of waiting, when the computed wait is greater than or equal to this value. Can be a hardcoded number, or a string interpolated from the connector config so that the bound can be changed without a connector release. A value of 0 means never wait. Not evaluated when a rate-limited retry can rotate to another credential with quota; any other retryable error still consults this strategy.",
examples=[3600, "{{ config['max_waiting_time'] * 60 }}"],
title="Max Waiting Time in Seconds",
)
parameters: Optional[Dict[str, Any]] = Field(None, alias="$parameters")


class WaitUntilTimeFromHeader(BaseModel):
type: Literal["WaitUntilTimeFromHeader"]
header: str = Field(
Expand Down Expand Up @@ -2296,6 +2316,7 @@ class DefaultErrorHandler(BaseModel):
ConstantBackoffStrategy,
ExponentialBackoffStrategy,
WaitTimeFromHeader,
WaitTimeFromResponse,
WaitUntilTimeFromHeader,
CustomBackoffStrategy,
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,9 @@
from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
WaitTimeFromHeader as WaitTimeFromHeaderModel,
)
from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
WaitTimeFromResponse as WaitTimeFromResponseModel,
)
from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
WaitUntilTimeFromHeader as WaitUntilTimeFromHeaderModel,
)
Expand Down Expand Up @@ -538,6 +541,7 @@
ConstantBackoffStrategy,
ExponentialBackoffStrategy,
WaitTimeFromHeaderBackoffStrategy,
WaitTimeFromResponseBackoffStrategy,
WaitUntilTimeFromHeaderBackoffStrategy,
)
from airbyte_cdk.sources.declarative.requesters.http_job_repository import AsyncHttpJobRepository
Expand Down Expand Up @@ -885,6 +889,7 @@ def _init_mappings(self) -> None:
SubstreamPartitionRouterModel: self.create_substream_partition_router,
ValidateAdheresToSchemaModel: self.create_validate_adheres_to_schema,
WaitTimeFromHeaderModel: self.create_wait_time_from_header,
WaitTimeFromResponseModel: self.create_wait_time_from_response,
WaitUntilTimeFromHeaderModel: self.create_wait_until_time_from_header,
AsyncRetrieverModel: self.create_async_retriever,
HttpComponentsResolverModel: self.create_http_components_resolver,
Expand Down Expand Up @@ -5188,6 +5193,17 @@ def create_wait_time_from_header(
max_waiting_time_in_seconds=model.max_waiting_time_in_seconds,
)

@staticmethod
def create_wait_time_from_response(
model: WaitTimeFromResponseModel, config: Config, **kwargs: Any
) -> WaitTimeFromResponseBackoffStrategy:
return WaitTimeFromResponseBackoffStrategy(
wait_time_in_seconds=model.wait_time_in_seconds,
parameters=model.parameters or {},
config=config,
max_waiting_time_in_seconds=model.max_waiting_time_in_seconds,
)

@staticmethod
def create_wait_until_time_from_header(
model: WaitUntilTimeFromHeaderModel, config: Config, **kwargs: Any
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
from airbyte_cdk.sources.declarative.requesters.error_handlers.backoff_strategies.wait_time_from_header_backoff_strategy import (
WaitTimeFromHeaderBackoffStrategy,
)
from airbyte_cdk.sources.declarative.requesters.error_handlers.backoff_strategies.wait_time_from_response_backoff_strategy import (
WaitTimeFromResponseBackoffStrategy,
)
from airbyte_cdk.sources.declarative.requesters.error_handlers.backoff_strategies.wait_until_time_from_header_backoff_strategy import (
WaitUntilTimeFromHeaderBackoffStrategy,
)
Expand All @@ -19,5 +22,6 @@
"ConstantBackoffStrategy",
"ExponentialBackoffStrategy",
"WaitTimeFromHeaderBackoffStrategy",
"WaitTimeFromResponseBackoffStrategy",
"WaitUntilTimeFromHeaderBackoffStrategy",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
#
# Copyright (c) 2026 Airbyte, Inc., all rights reserved.
#

import logging
import math
from dataclasses import InitVar, dataclass
from typing import Any, Mapping, Optional, Union

import requests
from jinja2.exceptions import UndefinedError

from airbyte_cdk.models import FailureType
from airbyte_cdk.sources.declarative.interpolation.interpolated_string import InterpolatedString
from airbyte_cdk.sources.declarative.interpolation.jinja import JinjaInterpolation
from airbyte_cdk.sources.declarative.requesters.error_handlers.backoff_strategies.max_waiting_time_helper import (
evaluate_max_waiting_time,
interpolated_max_waiting_time,
)
from airbyte_cdk.sources.streams.http.error_handlers import BackoffStrategy
from airbyte_cdk.sources.types import Config
from airbyte_cdk.utils import AirbyteTracedException

LOGGER = logging.getLogger("airbyte")

_INTERPOLATION_CONTEXT = frozenset({"config", "response", "headers", "parameters"})


@dataclass
class WaitTimeFromResponseBackoffStrategy(BackoffStrategy):
"""
Extract wait time, in relative seconds, from the JSON response body or the response headers.

Attributes:
wait_time_in_seconds: Expression defining how long to wait before retrying. An empty,
zero, negative, non-finite or non-numeric result, or an expression that cannot be
evaluated on the response body, defers to the next backoff strategy.
max_waiting_time_in_seconds: Optional inclusive cap on the computed wait.
"""

wait_time_in_seconds: Union[InterpolatedString, str]
parameters: InitVar[Mapping[str, Any]]
config: Config
max_waiting_time_in_seconds: Optional[Union[float, InterpolatedString, str]] = None

def __post_init__(self, parameters: Mapping[str, Any]) -> None:
# Evaluated with `JinjaInterpolation` directly rather than `InterpolatedString`: the latter
# caches a template as plain text when its first evaluation returns the template unchanged,
# which is what `JinjaInterpolation` does when rendering raises `TypeError`. One response
# body of an unexpected shape would then turn the strategy off for the rest of the sync.
self._wait_time_template = (
self.wait_time_in_seconds.string
if isinstance(self.wait_time_in_seconds, InterpolatedString)
else self.wait_time_in_seconds
)
self._interpolation = JinjaInterpolation()
self._parameters = parameters
undeclared = (
JinjaInterpolation._find_undeclared_variables(self._wait_time_template)
- _INTERPOLATION_CONTEXT
)
if undeclared:
# A typo such as `resposne` would otherwise fail on every response and leave the
# strategy silently inert; it is a manifest bug, so it fails at startup.
raise AirbyteTracedException(
internal_message=(
f"WaitTimeFromResponse `wait_time_in_seconds` {self._wait_time_template!r} "
f"uses undeclared variables {sorted(undeclared)}; only "
f"{sorted(_INTERPOLATION_CONTEXT)} are available."
),
message=(
"The connector's rate limit wait time expression is invalid. This is a "
"problem with the connector rather than with your configuration."
),
failure_type=FailureType.system_error,
)
self._max_waiting_time_in_seconds = interpolated_max_waiting_time(
self.max_waiting_time_in_seconds, parameters
)
evaluate_max_waiting_time(self._max_waiting_time_in_seconds, self.config)

def backoff_time(
self,
response_or_exception: Optional[Union[requests.Response, requests.RequestException]],
attempt_count: int,
) -> Optional[float]:
response: Any = {}
headers: Mapping[str, Any] = {}
if isinstance(response_or_exception, requests.Response):
try:
response = response_or_exception.json()
except (ValueError, requests.RequestException, RecursionError):
# Unreadable body (not JSON, too deeply nested, or a broken stream): interpolate
# with an empty response so an expression reading only `headers` still works.
pass
headers = response_or_exception.headers

try:
evaluated_wait_time = self._interpolation.eval(
self._wait_time_template,
self.config,
# The template as its own default re-raises an `UndefinedError` from the first
# render instead of returning None, so it is reported below.
self._wait_time_template,
parameters=self._parameters,
response=response,
headers=headers,
)
if evaluated_wait_time == self._wait_time_template:
# `JinjaInterpolation._eval` returns the raw template when rendering raises
# `TypeError`, e.g. arithmetic on a string from the body.
raise TypeError("the expression could not be rendered")
except (UndefinedError, TypeError, ValueError, ArithmeticError) as error:
# The strategy runs on every retryable response, not only the one the expression was
# written for: a body of another shape (a list, or a string where an object was
# expected) must defer to the next strategy, not fail the stream. The body is not
# logged; the error type is enough to point at the expression.
LOGGER.warning(
"WaitTimeFromResponse: `wait_time_in_seconds` could not be evaluated on this "
f"response ({type(error).__name__}); deferring to the next backoff strategy."
)
return None
if evaluated_wait_time is None or (
isinstance(evaluated_wait_time, str) and not evaluated_wait_time.strip()
):
return None

try:
wait_time = float(evaluated_wait_time)
except (TypeError, ValueError, ArithmeticError):
# Mirrors the header strategies, which return None for a value they cannot read, so
# the next backoff strategy decides. The value is not logged: it comes from the
# response body and may hold anything.
LOGGER.warning(
"WaitTimeFromResponse: `wait_time_in_seconds` resolved to a non-numeric "
f"{type(evaluated_wait_time).__name__}; deferring to the next backoff strategy."
)
return None
if not math.isfinite(wait_time) or wait_time < 0:
# A retry time that has already passed gives a negative wait, which is not a wait to
# take: the next strategy decides, as `WaitUntilTimeFromHeader` does for a past
# timestamp. Returned as-is, a negative value would reach `time.sleep` and fail the
# stream. A zero wait is returned as 0, but `DefaultErrorHandler` treats it as no
# answer and moves on to the next strategy, as it does for `WaitTimeFromHeader`.
return None
max_waiting_time = evaluate_max_waiting_time(self._max_waiting_time_in_seconds, self.config)
if max_waiting_time is not None and wait_time and wait_time >= max_waiting_time:
raise AirbyteTracedException(
internal_message=(
f"Rate limit wait time {wait_time}s is greater than or equal to the "
f"maximum of {max_waiting_time}s this stream is allowed to wait. "
f"Stopping the stream..."
),
message="The rate limit wait time is longer than the connector is allowed to wait.",
failure_type=FailureType.transient_error,
)

return wait_time

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.

P2: nothing bounds the wait when no max_waiting_time_in_seconds is set

Any finite positive value reaches time.sleep as it is. max_time does not shorten a sleep that has already started, because the sleep runs in the on_backoff handler (rate_limiting.py:110-119). Response bodies often carry absolute epoch or millisecond timestamps rather than relative seconds, so a one-character manifest mistake goes a long way:

  • {{ response.reset }} with {"reset": 1760500000} sleeps for about 55 years, until the heartbeat kills the job.
  • A value of about 9.2e9 or more makes time.sleep raise OverflowError: timestamp out of range for platform time_t, an untraced failure.

WaitTimeFromHeader has the same pattern, but headers rarely carry epochs. At minimum, please say in the field description that the result must be relative seconds and that a cap is strongly recommended. A hard ceiling here (return None with a warning above, say, a day) would be safer still.

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.

Addressed the minimum in 19c24ff. The wait_time_in_seconds description now says the result must be in seconds relative to now, shows how to convert an absolute timestamp (timestamp(...) - now_utc().timestamp()), and says that nothing bounds the wait unless max_waiting_time_in_seconds is set, so setting it is strongly recommended. The generated model has the same text.

I did not add a hard ceiling. It would change behaviour, and WaitTimeFromHeader has none either. Leaving this thread open in case you think the CDK should enforce one here.

Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@
ConstantBackoffStrategy,
ExponentialBackoffStrategy,
WaitTimeFromHeaderBackoffStrategy,
WaitTimeFromResponseBackoffStrategy,
WaitUntilTimeFromHeaderBackoffStrategy,
)
from airbyte_cdk.sources.declarative.requesters.http_job_repository import AsyncHttpJobRepository
Expand Down Expand Up @@ -2786,6 +2787,44 @@ def test_create_requester_with_backoff_jitter(
assert backoff_strategy.jitter_range_in_seconds == expected_jitter_range


def test_create_requester_with_wait_time_from_response_strategy():
content = """
requester:
type: HttpRequester
path: "/v3/marketing/lists"
url_base: "https://api.sendgrid.com"
error_handler:
backoff_strategies:
- type: WaitTimeFromResponse
wait_time_in_seconds: "{{ response[parameters.wait_field] }}"
max_waiting_time_in_seconds: 30
$parameters:
wait_field: retry_after_seconds
"""
parsed_manifest = YamlDeclarativeSource._parse(content)
resolved_manifest = resolver.preprocess_manifest(parsed_manifest)
requester_manifest = transformer.propagate_types_and_parameters(
"", resolved_manifest["requester"], {}
)

requester = factory.create_component(
model_type=HttpRequesterModel,
component_definition=requester_manifest,
config=input_config,
name="name",
decoder=None,
)

assert isinstance(requester.error_handler, DefaultErrorHandler)
backoff_strategy = requester.error_handler.backoff_strategies[0]
assert isinstance(backoff_strategy, WaitTimeFromResponseBackoffStrategy)
assert backoff_strategy.max_waiting_time_in_seconds == 30
Comment thread
darynaishchenko marked this conversation as resolved.
rate_limited = requests.Response()
rate_limited.status_code = 429
rate_limited._content = b'{"retry_after_seconds": 12}'
assert backoff_strategy.backoff_time(rate_limited, 1) == 12


@pytest.mark.parametrize(
"backoff_strategy_model, backoff_strategy_arguments",
[
Expand Down
Loading
Loading