Repository navigation
feat(low-code): opt-in TCP keepalive and request timeouts on HttpRequester - #1168
Conversation
…timeouts retryable Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
👋 Greetings, Airbyte Team Member!Here are some helpful tips and reminders for your convenience. 💡 Show Tips and TricksTesting This CDK VersionYou can test this version of the CDK using the following: # Run the CLI from this branch:
uvx 'git+https://github.com/airbytehq/airbyte-python-cdk.git@devin/1789662799-http-client-timeouts#egg=airbyte-python-cdk[dev]' --help
# Update a connector to use the CDK from this branch ref:
cd airbyte-integrations/connectors/source-example
poe use-cdk-branch devin/1789662799-http-client-timeoutsPR Slash CommandsAirbyte Maintainers can execute the following slash commands on your PR:
|
HttpClient and make timeouts retryableHttpClient
There was a problem hiding this comment.
🟡 Changes recommended
Body-read and streamed-response timeouts are not consistently normalized or retried.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds configurable HTTP connect/read timeouts and maps timeout failures to transient retries.
Changes:
- Adds default, environment, client, and request-level timeout configuration.
- Adds timeout error mappings and retry behavior.
- Adds unit and socket-based timeout tests.
File summaries
| File | Summary |
|---|---|
unit_tests/sources/streams/http/test_http_client.py |
Tests timeout defaults, overrides, and retry scenarios. |
unit_tests/sources/streams/http/error_handlers/test_http_status_error_handler.py |
Tests timeout error resolution. |
unit_tests/sources/declarative/requesters/error_handlers/test_default_error_handler.py |
Tests declarative timeout handling. |
airbyte_cdk/sources/streams/http/http_client.py |
Applies configured request timeouts. |
airbyte_cdk/sources/streams/http/error_handlers/default_error_mapping.py |
Maps timeout exceptions to transient retries. |
Review details
Suppressed comments (1)
airbyte_cdk/sources/streams/http/error_handlers/default_error_mapping.py:41
- These exact-class mappings do not cover a read timeout that occurs after response headers. With
stream=False,requestseagerly readsresponse.contentinsideSession.sendand converts urllib3'sReadTimeoutErrorduring that read intorequests.exceptions.ConnectionError; the handler then misses this key and returns the genericsystem_errorresolution. A server that sends headers and then stalls therefore does not get the promisedtransient_errorclassification. The timeout cause needs to be normalized/inspected before exact-class lookup, with a body-stall regression test.
ReadTimeout: ErrorResolution(
response_action=ResponseAction.RETRY,
failure_type=FailureType.transient_error,
error_message="The request timed out while waiting for the server to respond. Exception: requests.exceptions.ReadTimeout",
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
PyTest Results (Full)5 066 tests 5 054 ✅ 12m 1s ⏱️ Results for commit a8e017c. ♻️ This comment has been updated with latest results. |
Daryna Ishchenko (darynaishchenko)
left a comment
There was a problem hiding this comment.
The change and the defaults look right to me. The fleet already uses the same 300 s read timeout independently (source-iterable (60, 300), source-marketo (30, 300)), and both halves of the change are load-bearing: dropping the mapping entries fails 5 tests.
🟠 medium: the token endpoint can still hang forever (not in the diff, so it's here instead of inline)
The PR bounds HttpClient._send(), but these call sites bypass it and still pass no timeout:
abstract_oauth.py:411: OAuth token refresh, the first request of every sync for every OAuth connectordeclarative/auth/token.py:163/:250:SessionTokenAuthenticatorlogin and validationunstructured_parser.py:340: API-based file parsing
A token endpoint that accepts the connection and never answers parks the thread exactly as before, so the TL;DR ("one server that stopped answering could park a connector thread forever") still holds for auth. I'd fix this in this PR, because it's one argument per site:
timeout=default_request_timeout(),The OAuth refresh already retries on ConnectionError / ConnectTimeout / ReadTimeout (abstract_oauth.py:379-386). That retry can't fire today because a silent endpoint never raises; with a timeout it plugs straight in. The session-token calls have no retry, so there it turns a hang into a hard error, which is still strictly better.
…ser requests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
Done in 64c851b: |
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
… call Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
Both points are in as of The four The parser call is where a plain default would have been a regression, so it is now connect-bounded and read-unbounded: Kept the three explicit mapping entries, since you're right that the lookup matches the exact class. One nuance worth recording: before this PR timeouts weren't falling through to the Your On the red checks, neither comes from this diff. |
Nothing sets AIRBYTE_HTTP_CONNECT_TIMEOUT_SECONDS / AIRBYTE_HTTP_READ_TIMEOUT_SECONDS in Cloud and users cannot, so they were not a usable override. Removing them also removes an uncaught ValueError on an empty or non-numeric value. Overrides remain per request (request_kwargs['timeout']) and per client (HttpClient(request_timeout=...)). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolves the test_requests_native_auth.py conflict with #1188: both sides appended tests to the same class, so all are kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Daryna Ishchenko (darynaishchenko)
left a comment
There was a problem hiding this comment.
Thanks for this — the fix is right in principle (an unbounded hang is worse than any timeout) and it is well tested. What I want to discuss before merging is the approach: one fixed value for every connector, with no way to change it from a manifest.
The read timeout fires when the server sends nothing for 300 s. For most APIs that is fine, but synchronous export/report endpoints that compute server-side before the first byte can legitimately stay silent longer. Python connectors can override the value (request_kwargs() or HttpClient(request_timeout=...)). Manifest-only and Builder connectors — the larger population, and the one that picks up new SDM versions automatically — cannot: there is no schema field, HttpRequester builds HttpClient without request_timeout and passes only {"stream": ...}, and the env-var override was removed in 20ec792. For such a connector a request that used to succeed now times out, retries, and fails as transient_error, with no fix short of a custom component or an SDM pin.
The PR already lists the manifest-level field as a follow-up. I would like it in this PR, or merged before the release that contains this one, so the default ships together with the way to change it:
declarative_component_schema.yaml: an optional timeout onHttpRequester(either a singlerequest_timeoutaccepting(connect, read)or separateconnect_timeout/read_timeout), withtitle/description/examples; regenerate the models with/poe build.model_to_component_factory.py: pass it throughHttpRequesterintoHttpClient(request_timeout=...).- A test showing a manifest that sets it reaches
session.sendwith that value, and one showing a manifest without it gets the default.
On the value itself: 300 s is a reasonable first default (iterable, marketo and google-ads chose the same independently), but we have no fleet data on how many endpoints stay silent longer. A /prerelease plus a pinned run on a connector with long synchronous endpoints would be worth doing before release.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
HttpClient
Daryna Ishchenko (darynaishchenko)
left a comment
There was a problem hiding this comment.
Approving. The opt-in redesign resolves my earlier request-changes point: there is no fleet-wide default any more, and a connector that sets none of the three fields behaves exactly as before (tests pin the adapter type, pool sizes and the missing timeout kwarg).
What's done well
- Keepalive fixes the NAT-drop hang without risking slow-but-alive endpoints. The
proxy_manager_foroverride closes a real requests gap. - The tests are hermetic and fail fast. I mutated 7 production lines locally (injection removed, caller-timeout precedence flipped,
http://mount dropped, proxy override removed, factory/requester wiring dropped), and each one broke a test within seconds. - The hand-edited generated model matches what datamodel-codegen 0.26.3 emits (
PositiveFloat≡Field(gt=0)).
Rollout
- No connector in the
Test Connectorsmatrix opts in, so the source-stripe follow-up is the first real exercise of this path. A/prereleaseplus a pinned stripe run before any wider adoption would close that gap. - Note for the stripe PR:
read_timeout_in_seconds: 300would fail the same 21-minute-silence case that keepalive alone survives in your lab table.
Three optional 🟡 nits inline; none blocks the merge.
3ffbc02 to
fdd9766
Compare
… use the retry window Address review nits on #1168: - Log a warning (once per process, via the cached options) when the probe socket can't be created or SO_KEEPALIVE is rejected, instead of silently falling back to plain sockets. - Note in the read_timeout_in_seconds description that each timed-out attempt counts against the error handler's retry window (600 s for manifest connectors), so a value near 300 allows only a few retries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Daryna Ishchenko (@darynaishchenko) Thanks for the review. I added the retry-window note and a one-time warning on keepalive fallback in a8e017c, and I'll do config interpolation for the timeouts as a non-breaking follow-up. |
Summary
Nothing changes for connectors that don't set these fields. A default
HttpClient/HttpRequesterstill mounts main's plainHTTPAdapteronhttps://only, sends notimeoutkwarg, and opens sockets withoutSO_KEEPALIVE. Regression tests pin all three.This PR adds three optional
HttpRequesterfields, all off by default:Data path:
model_to_component_factory→HttpRequester→HttpClient(request_timeout=(connect, read) | None, use_tcp_keepalive=bool).use_tcp_keepalive=TruemountsTcpKeepaliveHTTPAdapter(new,streams/http/tcp_keepalive.py) on bothhttps://andhttp://, for cached and uncached sessions. It sets the socket options on the pool manager and onproxy_manager_for, because requests doesn't forward pool kwargs to proxy managers.TCP_KEEPIDLEon Linux/Windows,TCP_KEEPALIVEon macOS). Unsupported ones are dropped, never raised. The result is cached as an immutable tuple.session=objects are never modified.request_timeoutis added torequest_kwargsonly when set, and never overrides a caller-suppliedtimeout.Why opt-in. The previous version of this PR shipped fleet-wide default timeouts. Review pointed out that this breaks manifest-only connectors with slow synchronous endpoints, and that they had no way to override it. This version adds the knobs only. A fleet-wide default can be decided separately later.
Evidence (local network lab: client → NAT router with a 1200 s conntrack idle timeout → server).
main: hangs past 30 min with no exception. With keepalive: succeeds.GCP Cloud NAT's default TCP established idle timeout is 1200 s, and it applies to connections idle while an endpoint takes long to respond (Cloud NAT docs). AWS NAT Gateway drops idle connections after 350 s and recommends keepalive below that (AWS docs).
What it does not cover:
read_timeout_in_seconds; keepalive won't fail it.connect_timeout_in_seconds.stream=True) is being decoded raises outsideHttpClient._send's retry loop.requestscalls in OAuth token refresh and the declarative session-token authenticator don't go throughHttpClient, so they get neither knob.No error-mapping change. Timeouts keep today's classification: they retry via the unexpected-exception fallback (
RETRY+system_error).Generated model.
poe build(dagger) ran. Its output also rewrites ~420 unrelated lines ofdeclarative_component_schema.py(conint(...)/confloat(...)annotations, class reordering) and then failsmypy airbyte_cdkwith 10valid-typeerrors. That happens on plainmainwith no schema change too, so it's pre-existing generator drift. The three fields were therefore added by hand to the committed file in its existingField(..., gt=0)style, with the same content the generator emits.Dependency.
urllib3is now declared directly (urllib3 = "*", no new constraint; it already came in throughrequests), becausetcp_keepalive.pyimports it and deptry flags transitive imports (DEP003).Follow-ups (not in this PR):
transient_error.First consumer: source-stripe will opt in (
connect_timeout_in_seconds: 30,read_timeout_in_seconds: 300,use_tcp_keepalive: true) in a separate airbyte PR after release.Suggested reviewers: Daryna Ishchenko (@darynaishchenko), Maxime Carbonneau-Leclerc (@maxi297) (not assigned).
Link to Devin session: https://app.devin.ai/sessions/56ec2115e89a41e99baa6ae024af93b2
Open in Devin Desktop: https://app.devin.ai/desktop/session/56ec2115e89a41e99baa6ae024af93b2?variant=devin
Requested by: ZaneHyattAB