feat(java): per-operation timeouts and bounded response body reads
Draft
AS
2h ago+5356+5356+0123456789012345678901234567890123456789-717-717-012345678901234567890123456789
Summary
Hardens generated Java SDKs against slow, endless, or oversized responses (CWE-770 resource exhaustion). Reported through a customer security review; the findings were confirmed against the current templates.
Timeouts
x-speakeasy-timeouton an operation is now honoured (featureoperationTimeout).SDK.builder().timeout(Duration)sets a default for every call.- Every request builder gets
.timeout(Duration)for a per-call override, unless the operation already has a parameter namedtimeout, in which case no option is generated so the builder never has two setters (the SDK default still applies before the operation extension there). Zero and negative durations are rejected. Resolution order: per-call, then SDK default, then operation extension. Under retries the timeout applies to each attempt. The resolution is rendered by one template helper shared by the sync and async operation classes. - JDK client:
connectTimeout(30s)on the defaultHttpClient; the resolved timeout is set on eachHttpRequest, which bounds the time to headers. A timeout surfaces asUncheckedIOExceptionwrappingHttpTimeoutException. - OkHttp client: keeps its default connect timeout; the resolved timeout is applied to the
Callwith nanosecond precision, so it bounds the whole exchange alongside the client's existing 60 s read timeout. A timeout surfaces as anInterruptedIOExceptionin the cause chain. - The
Builder.timeoutjavadoc describes the arm the SDK was generated for.
Response body limits
SDK.builder().maxResponseBodyBytes(long)(andHTTPClient.setMaxResponseBodyBytes) caps how many body bytes are read per response. The builder stores the cap onSDKConfigurationand theSDKconstructor applies it to the HTTP client, so it survives any builder ordering relative to.client(...)and also applies when an SDK is constructed from anSDKConfigurationdirectly. Non-positive caps are rejected.- Both arms wrap the sync body in
BoundedInputStream, which caps each delegated read and skip to the remaining budget; the JDK async path limits the body publisher before it becomes aBlob. Requests whoseAcceptheader names an event stream or JSON Lines media type are read incrementally and are not capped on either arm; the exemption is decided from the request, not from the server'sContent-Type, so a server cannot switch the cap off. - Past the cap, reading a streamed body (
bytes()) throwsResponseTooLargeException(anIOExceptioncarrying the cap); a deserialized sync call fails withUncheckedIOExceptionwhose cause isResponseTooLargeException; an async call completes exceptionally withResponseTooLargeExceptionas the cause. - Default is unbounded, so existing SDKs behave as before until a cap is configured.
Debug logging
logResponseonly buffers a body it will actually log (JSON or plain text). Binary and other content types are passed through untouched.
Default behaviour change
- The default JDK client now has a 30 s connect timeout; previously connects were unbounded.
Public surface change
Per-call Options are now accepted by every operation, not only those with retries. Existing direct-call overloads ((params) and (params, serverURL)) are preserved and a (params, serverURL, Options) overload is added. Operation classes keep their previous Sync/Async constructors next to the new ones that take Options, so code constructing them directly still compiles.
Open decisions for review
- Default
maxResponseBodyBytesis unbounded. A conservative default would change behaviour for large non-streaming downloads. - JDK
HttpRequest.timeoutonly bounds time-to-headers, not body streaming. The body cap is the complementary protection; a read-idle timeout on the JDK arm is not included. - No README section yet for timeouts (no registered section id exists for other targets either).
Testing
tests/specs/fragments/uber/operation-timeout.yamladdscancelledRequestWithOperationTimeout: the api-test-service delay endpoint withx-speakeasy-timeout: 1000, so the extension is exercised at runtime by every target that supports it.- Primary (JDK)
TimeoutAdditionalTest: per-call override respected (sync and async) and allows completion; SDK default respected and overridden by a per-call timeout; operation extension respected and overridden by both a per-call and an SDK default timeout. Recorded IDs:timeout-ms-override-is-respected,timeout-ms-override-allows-completion,timeout-sdk-default-is-respected,timeout-operation-extension-is-respected. - Primary
ResponseBodiesAdditionalTest(response-body-limit): the existing/bytes/100operation with a 50 byte cap fails on read, sync and async, with the cap reported; a 200 byte cap with debug logging on reads all 100 bytes; an 8 byte cap on a JSON response fails deserialization withUncheckedIOException→ResponseTooLargeException; event streams are not capped; non-positive caps are rejected.UtilsAdditionalTestpinsBoundedInputStreamat, past and acrossskipof the cap and its unbounded short-circuit;ReactiveUtilsAdditionalTestpinslimitBytespass-through, failure with upstream cancellation, and the unbounded short-circuit. - Secondary (OkHttp)
TimeoutAdditionalTestandResponseBodiesAdditionalTest: per-call, SDK default and operation extension timeouts surfaceInterruptedIOException, a per-call and an SDK default timeout each beat the extension, and oversized bodies fail withResponseTooLargeException. - Primary and secondary suites pass locally apart from the known go-httpbin shape mismatches. Review SDK regenerated (the lock now records
operationTimeout).make lint,make check-template-javav2andgo generate ./...pass.