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
RequestOptions.timeout is a single per-call timeout knob (sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/RequestOptions.kt:51, constructor block lines 50-54). The two reference transports give that same knob two different guarantees:
OkHttp maps it to OkHttp's per-call timeout, which bounds the whole exchange — connect, write, read, and handshake — including reads of the response body. See applyCallTimeout at sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/OkHttpTransport.kt:274-287 (call.timeout().timeout(...) at line 285) and the execute(request, options) KDoc at lines 94-96 ("connect + write + read + handshake").
JDK java.net.http maps it to HttpRequest.Builder.timeout, which JDK 11+ enforces only from connect through response-headers receipt. Reads of the returned body stream are not bounded by it. See RequestAdapter step 4 at sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/RequestAdapter.kt:32-34 and its application at line 88 (responseTimeout?.let { builder.timeout(it) }).
The consequence: a server that sends response headers and then stalls mid-body hangs forever on the JDK transport, but is bounded (and surfaces as a retryable timeout) on OkHttp. Same knob, divergent guarantee.
The stall lands where retries cannot rescue it. client.send(..., BodyHandlers.ofInputStream()) (JdkHttpTransport.kt:130; async sendAsync(...) at line 207) returns as soon as headers arrive, and the body InputStream is read lazily. ResponseAdapter wraps that stream unbounded via Io.provider.source(body) (ResponseAdapter.kt:79, the live stream obtained at line 65), and the SDK Response is handed back up the pipeline as a successful exchange. The hang then occurs later, whenever something reads the body — serde deserialization, body logging, or application code calling body().string() — with no deadline governing that read. Because the transport call already "succeeded," the retry step never observes it.
The scope divergence is already acknowledged in the RequestOptions.timeout KDoc (RequestOptions.kt:36-45: "reads of the returned body stream are not bounded by it, so a stalled body can still hang"). This issue is to close that hole, not merely to document it.
Impact
This is a defect in a core platform primitive, so it is platform-wide. RequestOptions.timeout is the one timeout contract every consumer sees: generated service clients, downstream SDKs, and application code built on top of this toolkit all set it expecting a bounded call. On the JDK transport that expectation is silently violated for the body-read phase, and no consumer can work around it from above:
A generated client cannot reach past RequestOptions to arm a JDK-level read/idle deadline. java.net.http has no such knob, which is exactly why the SDK carries responseTimeout itself (JdkHttpTransport.kt:53-54, 242, 400-408).
Wrapping the response body defensively in consumer code is not possible in general, because the body is frequently consumed inside the SDK pipeline (serde, logging) before it is ever handed to the caller.
The failure mode is a permanent hang of the calling thread (or a virtual-thread carrier), not a slow call, and it behaves differently depending on which transport module happens to be on the classpath. A consumer who validated their timeout behavior against OkHttp gets a different, unbounded behavior after swapping to the JDK transport.
Where
sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/RequestOptions.kt:51 — public val timeout: Duration?, the single per-call knob (constructor block lines 50-54). Scope-divergence note in the @property timeout KDoc at lines 36-45.
sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/OkHttpTransport.kt:274-287 — applyCallTimeout; call.timeout().timeout(maxOf(1L, it.toMillis()), TimeUnit.MILLISECONDS) at line 285 bounds the whole exchange including body reads. execute(request, options) KDoc at lines 94-96.
sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/RequestAdapter.kt:32-34,88 — the per-request timeout is mapped to HttpRequest.Builder.timeout, documented as "JDK 11+ enforces the timeout from connect through response-headers receipt."
sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransport.kt:130 (sync client.send(..., BodyHandlers.ofInputStream())), :207 (async sendAsync), :242 (effectiveTimeout) — the timeout resolution and dispatch path; the body handler returns before the body is read.
sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/ResponseAdapter.kt:65,79 — the live body InputStream is obtained (line 65) and wrapped unbounded via Io.provider.source(body) (line 79); nothing here or downstream applies a read deadline.
Expected vs. actual
Expected. A caller sets RequestOptions.timeout (or the transport's responseTimeout) and gets a bounded call on either reference transport. When a server sends headers and then stalls mid-body, the read fails within the budget and surfaces as a retryable timeout, matching OkHttp and the transport conformance contract's promise to classify timeouts into the SDK's canonical exception contract (see §17 intro and TRANSPORT-4).
Actual. On the JDK transport the budget covers only up to response-headers receipt. A mid-body stall blocks the reading thread indefinitely with no timeout, on both the sync (JdkHttpTransport.kt:130) and async (:207) paths. OkHttp, given the identical RequestOptions, cancels the call via its watchdog and surfaces a retryable NetworkException.
Proposed fix
Have the JDK transport bound response-body reads so the per-call timeout gives the same end-to-end guarantee as OkHttp. Since java.net.http exposes no native read/idle knob, this must be enforced SDK-side inside the transport module, for example:
wrap the body InputStream returned by BodyHandlers.ofInputStream() with a deadline/idle guard before it is handed to Io.provider.source(...) in ResponseAdapter (ResponseAdapter.kt:65,79), or
arm a scheduled abort tied to the resolved effectiveTimeout (JdkHttpTransport.kt:242) that cancels/closes the exchange if the body has not been fully read within the budget.
Constraints on the fix:
Keep it in the transport module, not sdk-core. The I/O streaming contracts must not impose their own read/write deadline; deadline enforcement belongs to the transport that owns the socket (IO-40, §5.6). The guard wraps the JDK body at the transport layer and must not be pushed into the core Source/Sink seam. sdk-transport-jdkhttp targets JDK 11 and already uses java.util.concurrent, so a scheduled-abort mechanism needs no third-party runtime dependency and does not touch core's near-zero-deps posture.
Classify the fired deadline correctly. A body-read timeout must surface as the SDK's canonical retryable transport failure (NetworkException) and must not set the caller's interrupt flag, even though the JDK represents socket read timeouts with SocketTimeoutException (a subtype of InterruptedIOException). The timeout branch must be checked before the interrupt branch, exactly as the existing dispatch path already does (JdkHttpTransport.kt:140-149). See TRANSPORT-4 / XCUT-2.
Do not add a phase-taxonomy struct to RequestOptions. The single per-call knob is the deliberate HTTP-34 design (operational overrides modeled as one per-call timeout, one max-retries, and tags — not a connect/read/write breakdown). Per-phase knobs already live on the transport builders (JdkHttpTransport.Builder.connectTimeout / responseTimeout; RequestAdapter.kt:88). This issue is only about making the existing single knob cover the body-read phase on the JDK transport; it does not change the RequestOptions shape.
Acceptance criteria
Against a server that sends response headers and then stalls before or during the body, a JDK-transport call carrying a per-call RequestOptions.timeout (or a configured responseTimeout) fails within roughly the budget instead of blocking indefinitely, on both the sync execute and async executeAsync paths.
The fired body-read deadline surfaces as the canonical retryable NetworkException (reports itself retryable) and leaves the caller's interrupt flag clear; the timeout subtype is checked before the interrupt branch (TRANSPORT-4 / XCUT-2).
A positive sub-resolution budget still yields a finite deadline rather than an unbounded read, matching OkHttp's existing clamp behavior (TRANSPORT-6).
A normal, non-stalling body read completes unaffected, and closing the SDK Response still cascades to close the JDK InputStream and release the connection back to the pool.
No new third-party runtime dependency is added to any module; the guard lives entirely in sdk-transport-jdkhttp, and the core Source/Sink seam is unchanged (IO-40).
RequestOptions keeps its shape (single timeout + maxRetries + tags); no per-phase timeout struct is introduced (HTTP-34). apiCheck stays green, or any intentional transport-module API change is captured via apiDump.
References
HTTP-34 (§4.5) — RequestOptions models per-call operational overrides as a single per-call timeout + max-retries + tags; this is why the fix must not introduce a per-phase timeout taxonomy into the core model.
HTTP-35 (§4.5) — the options builder's positive-timeout validation (context for the single-knob contract).
TRANSPORT-5 (§17.2) — a per-call timeout override must apply to that single call. The JDK transport honors this only for the up-to-headers phase; the body-read phase escapes it.
TRANSPORT-4 (§17.2) / XCUT-2 — a read/response timeout must be classified as a retryable transport failure and must not set the cancellation flag, with the timeout subtype checked before the interrupt branch. The SDK-side body-read deadline must land here.
TRANSPORT-6 (§17.2) — any SDK-side body-read deadline must itself be a finite, non-truncated bound (parity with the existing sub-resolution clamp on OkHttp).
IO-40 (§5.6) — the I/O streaming contracts must not impose their own read/write deadline; deadline enforcement belongs to the transport. This places the fix in the transport module, not the core I/O seam.
§17 (transport conformance contract intro) — a conforming transport classifies cancellation, timeout, and no-response failures into the SDK's canonical exception contract; the body-read hole leaves one timeout class unclassified on the JDK transport.
Summary
RequestOptions.timeoutis a single per-call timeout knob (sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/RequestOptions.kt:51, constructor block lines 50-54). The two reference transports give that same knob two different guarantees:applyCallTimeoutatsdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/OkHttpTransport.kt:274-287(call.timeout().timeout(...)at line 285) and theexecute(request, options)KDoc at lines 94-96 ("connect + write + read + handshake").java.net.httpmaps it toHttpRequest.Builder.timeout, which JDK 11+ enforces only from connect through response-headers receipt. Reads of the returned body stream are not bounded by it. SeeRequestAdapterstep 4 atsdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/RequestAdapter.kt:32-34and its application at line 88 (responseTimeout?.let { builder.timeout(it) }).The consequence: a server that sends response headers and then stalls mid-body hangs forever on the JDK transport, but is bounded (and surfaces as a retryable timeout) on OkHttp. Same knob, divergent guarantee.
The stall lands where retries cannot rescue it.
client.send(..., BodyHandlers.ofInputStream())(JdkHttpTransport.kt:130; asyncsendAsync(...)at line 207) returns as soon as headers arrive, and the bodyInputStreamis read lazily.ResponseAdapterwraps that stream unbounded viaIo.provider.source(body)(ResponseAdapter.kt:79, the live stream obtained at line 65), and the SDKResponseis handed back up the pipeline as a successful exchange. The hang then occurs later, whenever something reads the body — serde deserialization, body logging, or application code callingbody().string()— with no deadline governing that read. Because the transport call already "succeeded," the retry step never observes it.The scope divergence is already acknowledged in the
RequestOptions.timeoutKDoc (RequestOptions.kt:36-45: "reads of the returned body stream are not bounded by it, so a stalled body can still hang"). This issue is to close that hole, not merely to document it.Impact
This is a defect in a core platform primitive, so it is platform-wide.
RequestOptions.timeoutis the one timeout contract every consumer sees: generated service clients, downstream SDKs, and application code built on top of this toolkit all set it expecting a bounded call. On the JDK transport that expectation is silently violated for the body-read phase, and no consumer can work around it from above:RequestOptionsto arm a JDK-level read/idle deadline.java.net.httphas no such knob, which is exactly why the SDK carriesresponseTimeoutitself (JdkHttpTransport.kt:53-54,242,400-408).Where
sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/RequestOptions.kt:51—public val timeout: Duration?, the single per-call knob (constructor block lines 50-54). Scope-divergence note in the@property timeoutKDoc at lines 36-45.sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/OkHttpTransport.kt:274-287—applyCallTimeout;call.timeout().timeout(maxOf(1L, it.toMillis()), TimeUnit.MILLISECONDS)at line 285 bounds the whole exchange including body reads.execute(request, options)KDoc at lines 94-96.sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/RequestAdapter.kt:32-34,88— the per-request timeout is mapped toHttpRequest.Builder.timeout, documented as "JDK 11+ enforces the timeout from connect through response-headers receipt."sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransport.kt:130(syncclient.send(..., BodyHandlers.ofInputStream())),:207(asyncsendAsync),:242(effectiveTimeout) — the timeout resolution and dispatch path; the body handler returns before the body is read.sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/ResponseAdapter.kt:65,79— the live bodyInputStreamis obtained (line 65) and wrapped unbounded viaIo.provider.source(body)(line 79); nothing here or downstream applies a read deadline.Expected vs. actual
Expected. A caller sets
RequestOptions.timeout(or the transport'sresponseTimeout) and gets a bounded call on either reference transport. When a server sends headers and then stalls mid-body, the read fails within the budget and surfaces as a retryable timeout, matching OkHttp and the transport conformance contract's promise to classify timeouts into the SDK's canonical exception contract (see §17 intro and TRANSPORT-4).Actual. On the JDK transport the budget covers only up to response-headers receipt. A mid-body stall blocks the reading thread indefinitely with no timeout, on both the sync (
JdkHttpTransport.kt:130) and async (:207) paths. OkHttp, given the identicalRequestOptions, cancels the call via its watchdog and surfaces a retryableNetworkException.Proposed fix
Have the JDK transport bound response-body reads so the per-call timeout gives the same end-to-end guarantee as OkHttp. Since
java.net.httpexposes no native read/idle knob, this must be enforced SDK-side inside the transport module, for example:InputStreamreturned byBodyHandlers.ofInputStream()with a deadline/idle guard before it is handed toIo.provider.source(...)inResponseAdapter(ResponseAdapter.kt:65,79), oreffectiveTimeout(JdkHttpTransport.kt:242) that cancels/closes the exchange if the body has not been fully read within the budget.Constraints on the fix:
sdk-core. The I/O streaming contracts must not impose their own read/write deadline; deadline enforcement belongs to the transport that owns the socket (IO-40, §5.6). The guard wraps the JDK body at the transport layer and must not be pushed into the coreSource/Sinkseam.sdk-transport-jdkhttptargets JDK 11 and already usesjava.util.concurrent, so a scheduled-abort mechanism needs no third-party runtime dependency and does not touch core's near-zero-deps posture.NetworkException) and must not set the caller's interrupt flag, even though the JDK represents socket read timeouts withSocketTimeoutException(a subtype ofInterruptedIOException). The timeout branch must be checked before the interrupt branch, exactly as the existing dispatch path already does (JdkHttpTransport.kt:140-149). See TRANSPORT-4 / XCUT-2.RequestOptions. The single per-call knob is the deliberate HTTP-34 design (operational overrides modeled as one per-call timeout, one max-retries, and tags — not a connect/read/write breakdown). Per-phase knobs already live on the transport builders (JdkHttpTransport.Builder.connectTimeout/responseTimeout;RequestAdapter.kt:88). This issue is only about making the existing single knob cover the body-read phase on the JDK transport; it does not change theRequestOptionsshape.Acceptance criteria
RequestOptions.timeout(or a configuredresponseTimeout) fails within roughly the budget instead of blocking indefinitely, on both the syncexecuteand asyncexecuteAsyncpaths.NetworkException(reports itself retryable) and leaves the caller's interrupt flag clear; the timeout subtype is checked before the interrupt branch (TRANSPORT-4 / XCUT-2).Responsestill cascades to close the JDKInputStreamand release the connection back to the pool.sdk-transport-jdkhttp, and the coreSource/Sinkseam is unchanged (IO-40).RequestOptionskeeps its shape (singletimeout+maxRetries+tags); no per-phase timeout struct is introduced (HTTP-34).apiCheckstays green, or any intentional transport-module API change is captured viaapiDump.References
RequestOptionsmodels per-call operational overrides as a single per-call timeout + max-retries + tags; this is why the fix must not introduce a per-phase timeout taxonomy into the core model.Timeoutvalue type — connect/read/write/request). That is a broader model addition; this issue is scoped narrowly to closing the unbounded-body-read hole on the JDK transport under the existing single per-call knob, and does not depend on Add a per-phase Timeout value type (connect/read/write/request) #41 landing.