You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A declarative (manifest) source whose HTTP response connects and then stalls, or whose cursor pagination stops advancing, emits neither RECORD nor STATE for the whole duration of the stall. Nothing in the CDK ever interrupts it. The attempt ends only when the platform source heartbeat fires (heartbeat-max-seconds-between-messages, documented default 3 hours: https://github.com/airbytehq/airbyte/blob/master/docs/platform/understanding-airbyte/heartbeats.md; the Cloud setting observed in the incident below was 5400 s). Because the partition never closes, no state is checkpointed, and every retry repeats the identical walk from the last committed state and stalls at the same point.
Observed in production on source-zendesk-support tickets (Incremental Ticket Export incremental/tickets/cursor.json, CursorPagination on after_url with stop_condition keyed on end_of_stream, RequestPath token option): with num_workers 1, or once sibling streams finish, the source went silent until the heartbeat timeout. Internal reference: airbytehq/oncall#13250.
Root cause
Three independent layers, none of which bounds a hung request or a non-advancing pagination loop.
1. HttpClient sets no request timeout.
The file contains no occurrence of the string timeout at either ref. _send forwards request_kwargs verbatim to requests.Session.send:
requests defaults to timeout=None (https://requests.readthedocs.io/en/latest/user/advanced/#timeouts), so a server that completes the TCP/TLS handshake, sends headers, and then stops sending bytes blocks the worker thread indefinitely. With stream=False the block is inside _send; with stream=True it moves to the decoder iterating the body. In both cases the worker is wedged and the error handler / backoff never runs.
2. ConcurrentSource.timeout_seconds is accepted, documented, stored, and never read.
The CDK therefore has no independent watchdog for a partition whose worker never returns. The main thread waits forever on the queue.
3. SimpleRetriever._read_pages has no guard against a repeated page token.
The loop is while True; its only exits are a falsy response or the paginator returning None:
So an API that keeps returning the same after_url with end_of_stream: false and an empty page makes _read_pages re-request the same URL forever, emitting nothing.
Reproduction
Any manifest-only connector against a local stub server; airbyte-cdk connector test or poetry run source-declarative-manifest read ... with concurrency_level 1.
A. Stalled response (layers 1 and 2):
Run a stub that returns 200 with headers and then never sends a body:
Point a single-stream manifest (HttpRequester, url_base: http://127.0.0.1:8080) at it and run read.
Expected: a timeout error after a bounded interval, routed through the error handler, or the source giving up after timeout_seconds (900 s) as the docstring states.
Observed: no RECORD, no STATE, no log line; the process never terminates. timeout_seconds has no effect at any value.
Expected: the retriever stops with an error once the token repeats.
Observed: identical request re-issued indefinitely; no records, no state, no error.
Impact
Affects every declarative connector: HttpClient and SimpleRetriever are the shared path for all manifest-only and low-code sources.
Failure mode is silent: no output, no error, no diagnostic. Only the platform heartbeat ends the attempt, so each attempt burns the whole heartbeat window (90 minutes at the Cloud setting seen in the incident, 3 hours at the documented default) before failing.
No state is checkpointed because the partition never closes, so retries do not progress and the sync cannot self-heal.
timeout_seconds gives connector authors a false sense of protection: it is accepted and documented but inert.
Suggested fix
Three independent changes, smallest first. (1) alone resolves the observed incident.
Default read timeout in HttpClient._send. If "timeout" is absent from request_kwargs, set a default such as (30, 600) (connect, read) before self._session.send(...) (v7.23.8 L348 / main L414). Expose an override on HttpRequester (a request_timeout field next to stream_response, L70) merged into request_kwargs at L466. A stalled non-streamed response then raises requests.exceptions.ReadTimeout inside _send, which already catches RequestException and hands it to self._error_handler.interpret_response (except at v7.23.8 L349 / main L415, interpret_response at v7.23.8 L352 / main L421); a stalled streamed body raises requests.exceptions.ConnectionError from iter_content (requests wraps urllib3 ReadTimeoutError there), which still un-wedges the worker and fails the partition with a real error. Read timeout is per-byte-gap, not total, so large legitimate downloads are unaffected.
Wire timeout_seconds into _consume_from_queue. Replace queue.get() (L151) with queue.get(timeout=self._timeout_seconds); on queue.Empty, log the partitions still in flight and raise so the attempt fails with a diagnostic instead of waiting for the platform heartbeat. Pass timeout_seconds from ConcurrentDeclarativeSource (L246 / L265) so it is configurable. Keep 900 s as default and ensure the HTTP read timeout from (1) is shorter, so the HTTP layer fires first and this becomes a last-resort watchdog.
Optional: repeated-token guard in _read_pages. After _next_page_token (L424 / L430), if the new token equals the previous last_page_token_value and last_page_size == 0, log an error naming the stream, slice, and token, and break (or raise) instead of re-requesting. The previous token is already available at that point (L421 / L427), so this is a few lines with no signature change. Logging rather than silently returning None from the strategy keeps the failure visible.
The timeout_seconds docstring (L93) already describes the intended behavior ("the source will stop reading and return"); this issue asks for that contract to be implemented.
Symptom
A declarative (manifest) source whose HTTP response connects and then stalls, or whose cursor pagination stops advancing, emits neither RECORD nor STATE for the whole duration of the stall. Nothing in the CDK ever interrupts it. The attempt ends only when the platform source heartbeat fires (
heartbeat-max-seconds-between-messages, documented default 3 hours: https://github.com/airbytehq/airbyte/blob/master/docs/platform/understanding-airbyte/heartbeats.md; the Cloud setting observed in the incident below was 5400 s). Because the partition never closes, no state is checkpointed, and every retry repeats the identical walk from the last committed state and stalls at the same point.Observed in production on source-zendesk-support
tickets(Incremental Ticket Exportincremental/tickets/cursor.json,CursorPaginationonafter_urlwithstop_conditionkeyed onend_of_stream,RequestPathtoken option): withnum_workers1, or once sibling streams finish, the source went silent until the heartbeat timeout. Internal reference: airbytehq/oncall#13250.Root cause
Three independent layers, none of which bounds a hung request or a non-advancing pagination loop.
1.
HttpClientsets no request timeout.The file contains no occurrence of the string
timeoutat either ref._sendforwardsrequest_kwargsverbatim torequests.Session.send:_sendhttps://github.com/airbytehq/airbyte-python-cdk/blob/v7.23.8/airbyte_cdk/sources/streams/http/http_client.py#L325,self._session.send(request, **request_kwargs)https://github.com/airbytehq/airbyte-python-cdk/blob/v7.23.8/airbyte_cdk/sources/streams/http/http_client.py#L348_sendhttps://github.com/airbytehq/airbyte-python-cdk/blob/main/airbyte_cdk/sources/streams/http/http_client.py#L391,self._session.send(...)https://github.com/airbytehq/airbyte-python-cdk/blob/main/airbyte_cdk/sources/streams/http/http_client.py#L414HttpRequester.send_requestonly passes{"stream": self.stream_response}:requestsdefaults totimeout=None(https://requests.readthedocs.io/en/latest/user/advanced/#timeouts), so a server that completes the TCP/TLS handshake, sends headers, and then stops sending bytes blocks the worker thread indefinitely. Withstream=Falsethe block is inside_send; withstream=Trueit moves to the decoder iterating the body. In both cases the worker is wedged and the error handler / backoff never runs.2.
ConcurrentSource.timeout_secondsis accepted, documented, stored, and never read.DEFAULT_TIMEOUT_SECONDS = 900: https://github.com/airbytehq/airbyte-python-cdk/blob/v7.23.8/airbyte_cdk/sources/concurrent_source/concurrent_source.py#L38 / https://github.com/airbytehq/airbyte-python-cdk/blob/main/airbyte_cdk/sources/concurrent_source/concurrent_source.py#L38_timeout_secondsin the repository at either ref is the assignment (git grep _timeout_secondsreturns one line); nothing reads it: https://github.com/airbytehq/airbyte-python-cdk/blob/v7.23.8/airbyte_cdk/sources/concurrent_source/concurrent_source.py#L100 / https://github.com/airbytehq/airbyte-python-cdk/blob/main/airbyte_cdk/sources/concurrent_source/concurrent_source.py#L100_consume_from_queueblocks onqueue.get()with no timeout: https://github.com/airbytehq/airbyte-python-cdk/blob/v7.23.8/airbyte_cdk/sources/concurrent_source/concurrent_source.py#L151 / https://github.com/airbytehq/airbyte-python-cdk/blob/main/airbyte_cdk/sources/concurrent_source/concurrent_source.py#L151ConcurrentDeclarativeSourcedoes not pass it either: https://github.com/airbytehq/airbyte-python-cdk/blob/v7.23.8/airbyte_cdk/sources/declarative/concurrent_declarative_source.py#L246 / https://github.com/airbytehq/airbyte-python-cdk/blob/main/airbyte_cdk/sources/declarative/concurrent_declarative_source.py#L265The CDK therefore has no independent watchdog for a partition whose worker never returns. The main thread waits forever on the queue.
3.
SimpleRetriever._read_pageshas no guard against a repeated page token.The loop is
while True; its only exits are a falsy response or the paginator returningNone:_read_pageshttps://github.com/airbytehq/airbyte-python-cdk/blob/v7.23.8/airbyte_cdk/sources/declarative/retrievers/simple_retriever.py#L343,while Truehttps://github.com/airbytehq/airbyte-python-cdk/blob/v7.23.8/airbyte_cdk/sources/declarative/retrievers/simple_retriever.py#L352,if not response: breakhttps://github.com/airbytehq/airbyte-python-cdk/blob/v7.23.8/airbyte_cdk/sources/declarative/retrievers/simple_retriever.py#L407,if not next_page_token: breakhttps://github.com/airbytehq/airbyte-python-cdk/blob/v7.23.8/airbyte_cdk/sources/declarative/retrievers/simple_retriever.py#L430_read_pageshttps://github.com/airbytehq/airbyte-python-cdk/blob/main/airbyte_cdk/sources/declarative/retrievers/simple_retriever.py#L349,while Truehttps://github.com/airbytehq/airbyte-python-cdk/blob/main/airbyte_cdk/sources/declarative/retrievers/simple_retriever.py#L358,if not response: breakhttps://github.com/airbytehq/airbyte-python-cdk/blob/main/airbyte_cdk/sources/declarative/retrievers/simple_retriever.py#L413,if not next_page_token: breakhttps://github.com/airbytehq/airbyte-python-cdk/blob/main/airbyte_cdk/sources/declarative/retrievers/simple_retriever.py#L436The pagination-reset branch does not help:
PaginationTracker.has_reached_limitis record-count based, so a sequence of empty pages never triggers it (https://github.com/airbytehq/airbyte-python-cdk/blob/v7.23.8/airbyte_cdk/sources/declarative/retrievers/pagination_tracker.py#L44 / https://github.com/airbytehq/airbyte-python-cdk/blob/main/airbyte_cdk/sources/declarative/retrievers/pagination_tracker.py#L44).The previous token is already computed and handed to the paginator (
last_page_token_value: https://github.com/airbytehq/airbyte-python-cdk/blob/v7.23.8/airbyte_cdk/sources/declarative/retrievers/simple_retriever.py#L421 / https://github.com/airbytehq/airbyte-python-cdk/blob/main/airbyte_cdk/sources/declarative/retrievers/simple_retriever.py#L427), butCursorPaginationStrategy.next_page_tokenaccepts it and never compares against it; it only evaluatesstop_conditionand returnscursor_valueas-is:stop_conditioneval: https://github.com/airbytehq/airbyte-python-cdk/blob/v7.23.8/airbyte_cdk/sources/declarative/requesters/paginators/strategies/cursor_pagination_strategy.py#L86 / https://github.com/airbytehq/airbyte-python-cdk/blob/main/airbyte_cdk/sources/declarative/requesters/paginators/strategies/cursor_pagination_strategy.py#L86return token if token else None: https://github.com/airbytehq/airbyte-python-cdk/blob/v7.23.8/airbyte_cdk/sources/declarative/requesters/paginators/strategies/cursor_pagination_strategy.py#L103 / https://github.com/airbytehq/airbyte-python-cdk/blob/main/airbyte_cdk/sources/declarative/requesters/paginators/strategies/cursor_pagination_strategy.py#L103So an API that keeps returning the same
after_urlwithend_of_stream: falseand an empty page makes_read_pagesre-request the same URL forever, emitting nothing.Reproduction
Any manifest-only connector against a local stub server;
airbyte-cdk connector testorpoetry run source-declarative-manifest read ...withconcurrency_level1.A. Stalled response (layers 1 and 2):
200with headers and then never sends a body:HttpRequester,url_base: http://127.0.0.1:8080) at it and runread.timeout_seconds(900 s) as the docstring states.timeout_secondshas no effect at any value.B. Non-advancing cursor (layer 3):
{"tickets": [], "after_url": "http://127.0.0.1:8080/tickets?cursor=x", "end_of_stream": false}.Impact
HttpClientandSimpleRetrieverare the shared path for all manifest-only and low-code sources.timeout_secondsgives connector authors a false sense of protection: it is accepted and documented but inert.Suggested fix
Three independent changes, smallest first. (1) alone resolves the observed incident.
Default read timeout in
HttpClient._send. If"timeout"is absent fromrequest_kwargs, set a default such as(30, 600)(connect, read) beforeself._session.send(...)(v7.23.8 L348 / main L414). Expose an override onHttpRequester(arequest_timeoutfield next tostream_response, L70) merged intorequest_kwargsat L466. A stalled non-streamed response then raisesrequests.exceptions.ReadTimeoutinside_send, which already catchesRequestExceptionand hands it toself._error_handler.interpret_response(exceptat v7.23.8 L349 / main L415,interpret_responseat v7.23.8 L352 / main L421); a stalled streamed body raisesrequests.exceptions.ConnectionErrorfromiter_content(requests wraps urllib3ReadTimeoutErrorthere), which still un-wedges the worker and fails the partition with a real error. Read timeout is per-byte-gap, not total, so large legitimate downloads are unaffected.Wire
timeout_secondsinto_consume_from_queue. Replacequeue.get()(L151) withqueue.get(timeout=self._timeout_seconds); onqueue.Empty, log the partitions still in flight and raise so the attempt fails with a diagnostic instead of waiting for the platform heartbeat. Passtimeout_secondsfromConcurrentDeclarativeSource(L246 / L265) so it is configurable. Keep 900 s as default and ensure the HTTP read timeout from (1) is shorter, so the HTTP layer fires first and this becomes a last-resort watchdog.Optional: repeated-token guard in
_read_pages. After_next_page_token(L424 / L430), if the new token equals the previouslast_page_token_valueandlast_page_size == 0, log an error naming the stream, slice, and token, and break (or raise) instead of re-requesting. The previous token is already available at that point (L421 / L427), so this is a few lines with no signature change. Logging rather than silently returningNonefrom the strategy keeps the failure visible.Precedent
timeout_secondsdocstring (L93) already describes the intended behavior ("the source will stop reading and return"); this issue asks for that contract to be implemented.