feat(java): per-operation timeouts and bounded response body reads

Draft
AS
2h ago
+5356-717

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-timeout on an operation is now honoured (feature operationTimeout).
  • 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 named timeout, 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 default HttpClient; the resolved timeout is set on each HttpRequest, which bounds the time to headers. A timeout surfaces as UncheckedIOException wrapping HttpTimeoutException.
  • OkHttp client: keeps its default connect timeout; the resolved timeout is applied to the Call with nanosecond precision, so it bounds the whole exchange alongside the client's existing 60 s read timeout. A timeout surfaces as an InterruptedIOException in the cause chain.
  • The Builder.timeout javadoc describes the arm the SDK was generated for.

Response body limits

  • SDK.builder().maxResponseBodyBytes(long) (and HTTPClient.setMaxResponseBodyBytes) caps how many body bytes are read per response. The builder stores the cap on SDKConfiguration and the SDK constructor applies it to the HTTP client, so it survives any builder ordering relative to .client(...) and also applies when an SDK is constructed from an SDKConfiguration directly. 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 a Blob. Requests whose Accept header 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's Content-Type, so a server cannot switch the cap off.
  • Past the cap, reading a streamed body (bytes()) throws ResponseTooLargeException (an IOException carrying the cap); a deserialized sync call fails with UncheckedIOException whose cause is ResponseTooLargeException; an async call completes exceptionally with ResponseTooLargeException as the cause.
  • Default is unbounded, so existing SDKs behave as before until a cap is configured.

Debug logging

  • logResponse only 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 maxResponseBodyBytes is unbounded. A conservative default would change behaviour for large non-streaming downloads.
  • JDK HttpRequest.timeout only 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.yaml adds cancelledRequestWithOperationTimeout: the api-test-service delay endpoint with x-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/100 operation 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 with UncheckedIOException → ResponseTooLargeException; event streams are not capped; non-positive caps are rejected. UtilsAdditionalTest pins BoundedInputStream at, past and across skip of the cap and its unbounded short-circuit; ReactiveUtilsAdditionalTest pins limitBytes pass-through, failure with upstream cancellation, and the unbounded short-circuit.
  • Secondary (OkHttp) TimeoutAdditionalTest and ResponseBodiesAdditionalTest: per-call, SDK default and operation extension timeouts surface InterruptedIOException, a per-call and an SDK default timeout each beat the extension, and oversized bodies fail with ResponseTooLargeException.
  • 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-javav2 and go generate ./... pass.