Summary
CompositeErrorHandler.interpret_response swallows REFRESH_TOKEN_THEN_RETRY (and RATE_LIMITED) resolutions matched by an earlier handler in the chain: the loop only short-circuits on SUCCESS, RETRY, IGNORE, and RESET_PAGINATION, so the matched resolution is overwritten by the next handler's result (typically FAIL from the default 401 mapping).
Reproduction
import requests
from unittest.mock import MagicMock
from airbyte_cdk.sources.declarative.requesters.error_handlers.composite_error_handler import CompositeErrorHandler
from airbyte_cdk.sources.declarative.requesters.error_handlers.default_error_handler import DefaultErrorHandler
from airbyte_cdk.sources.declarative.requesters.error_handlers.http_response_filter import HttpResponseFilter
config = {}
refresh_filter = HttpResponseFilter(
action="REFRESH_TOKEN_THEN_RETRY",
predicate="{{ response.get('code') == 124 }}",
config=config,
parameters={},
)
h1 = DefaultErrorHandler(config=config, parameters={}, response_filters=[refresh_filter])
h2 = DefaultErrorHandler(config=config, parameters={})
composite = CompositeErrorHandler(error_handlers=[h1, h2], parameters={})
resp = MagicMock(spec=requests.Response)
resp.status_code = 401
resp.json.return_value = {"code": 124, "message": "Access token is expired."}
resp.headers = {}
resp.ok = False
print(composite.interpret_response(resp).response_action)
# Actual: ResponseAction.FAIL
# Expected: ResponseAction.REFRESH_TOKEN_THEN_RETRY
Verified on CDK 7.23.6 and current main.
Root cause
In airbyte_cdk/sources/declarative/requesters/error_handlers/composite_error_handler.py, interpret_response only returns early for:
if matched_error_resolution.response_action in [
ResponseAction.SUCCESS,
ResponseAction.RETRY,
ResponseAction.IGNORE,
ResponseAction.RESET_PAGINATION,
]:
return matched_error_resolution
A REFRESH_TOKEN_THEN_RETRY match falls through, matched_error_resolution is overwritten by the next handler in the loop, and the last handler's resolution (e.g. FAIL for a 401 via the default mapping) wins.
Impact
Any manifest that declares a REFRESH_TOKEN_THEN_RETRY response filter inside a CompositeErrorHandler (a very common pattern, since composites are the documented way to layer custom filters over the default handler) silently never refreshes/retries — long-running syncs fail with a fatal 401 at the token-expiry boundary. This was observed in production with source-zoom (see airbytehq/airbyte#72784).
Workaround: place the REFRESH_TOKEN_THEN_RETRY filter inside every DefaultErrorHandler in the composite (including the fallback handler), so the last-evaluated handler also returns it.
Suggested fix
Add ResponseAction.REFRESH_TOKEN_THEN_RETRY (and likely ResponseAction.RATE_LIMITED) to the early-return list in CompositeErrorHandler.interpret_response.
Reported on behalf of Ryan Waskewich.
Devin session
Summary
CompositeErrorHandler.interpret_responseswallowsREFRESH_TOKEN_THEN_RETRY(andRATE_LIMITED) resolutions matched by an earlier handler in the chain: the loop only short-circuits onSUCCESS,RETRY,IGNORE, andRESET_PAGINATION, so the matched resolution is overwritten by the next handler's result (typicallyFAILfrom the default 401 mapping).Reproduction
Verified on CDK 7.23.6 and current main.
Root cause
In
airbyte_cdk/sources/declarative/requesters/error_handlers/composite_error_handler.py,interpret_responseonly returns early for:A
REFRESH_TOKEN_THEN_RETRYmatch falls through,matched_error_resolutionis overwritten by the next handler in the loop, and the last handler's resolution (e.g.FAILfor a 401 via the default mapping) wins.Impact
Any manifest that declares a
REFRESH_TOKEN_THEN_RETRYresponse filter inside aCompositeErrorHandler(a very common pattern, since composites are the documented way to layer custom filters over the default handler) silently never refreshes/retries — long-running syncs fail with a fatal 401 at the token-expiry boundary. This was observed in production with source-zoom (see airbytehq/airbyte#72784).Workaround: place the
REFRESH_TOKEN_THEN_RETRYfilter inside everyDefaultErrorHandlerin the composite (including the fallback handler), so the last-evaluated handler also returns it.Suggested fix
Add
ResponseAction.REFRESH_TOKEN_THEN_RETRY(and likelyResponseAction.RATE_LIMITED) to the early-return list inCompositeErrorHandler.interpret_response.Reported on behalf of Ryan Waskewich.
Devin session