Skip to content

fix(okhttp): Don't block on response bodies of unknown length (SSE hangs forever) - #1

Open
carlonzo wants to merge 6 commits into
mainfrom
fix/okhttp-unknown-length-response-body
Open

carlonzo wants to merge 6 commits into
mainfrom
fix/okhttp-unknown-length-response-body

Conversation

@carlonzo

@carlonzo carlonzo commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

The problem

When Sentry captures a response body for Session Replay, it peeks at the body before handing the
response back to your code:

peekBody(SentryReplayOptions.MAX_NETWORK_BODY_SIZE + 1).bytes()

For a normal response this is fine — the body ends, so the peek returns. But OkHttp implements
"peek" as "keep reading until N bytes are buffered, or the stream ends":

// okhttp3.Response
fun peekBody(byteCount: Long): ResponseBody {
  val peeked = body.source().peek()
  peeked.request(byteCount)   // blocking
  ...

A response with no Content-Length — Server-Sent Events, a long-poll, a chunked endpoint that
stays open — never satisfies either condition. The interceptor sits in that read forever.

The consequence is worse than slow events: the application never receives the response at all.
It can't even open a reader, so nothing arrives, ever:

app: call.execute()
  └─ SentryOkHttpInterceptor
       └─ chain.proceed()  ->  200 OK, text/event-stream, connection stays open
       └─ peekBody(256 KB) -> request(262145)
            |- read "data: event-0\n\n"    15 bytes
            |- read "data: event-1\n\n"    15 bytes
            `- ... still needs 256 KB, and no EOF ever comes ...

There are two ways it fails, depending on the server:

stream behaviour what the caller sees
keeps sending (live feed, frequent events) the call hangs forever — no read timeout fires, because the socket is never idle
goes quiet for longer than the read timeout (the usual 15–30s SSE heartbeat vs OkHttp's 10s default) a SocketTimeoutException after 10s, logged as an error, on every connection

It only triggers when Session Replay network details are enabled for the URL
(options.sessionReplay.setNetworkDetailAllowUrls(...)), which is why it is rare and easy to miss.

The solution

You cannot capture a body of unknown length up front, so stop reading it up front.

A body of unknown length is wrapped in a body that copies the bytes as your code consumes them
into a capped buffer, and reports the capture once no more bytes can ever arrive — when the stream
ends, when your code closes the body, or when the capture cap is reached.

The application sees exactly the same bytes, in the same order, at the same speed. The wrapper only
copies what passes through and never reads on its own:

  • events keep flowing, including 15-byte ones, with no added latency
  • measured cost of the capture step: ~0.4 µs per response, versus ~16 µs for the peek — the
    wrapper also allocates no more than having no interceptor at all

Responses with a known Content-Length are untouched: they end on their own, so they keep the
existing up-front capture. That also means unread bodies with a known length — a 500 whose body you
only check the status of — are still captured exactly as before.

Why NetworkRequestData changed

Capturing a streamed body means filling in the response after the breadcrumb carrying it has
already been handed to the scope, so the replay thread can read it while it is being written. The
three response values therefore moved behind a single volatile reference: a late reader sees
either nothing or the complete set, never a mix. Nothing else about that class changed, and the
request side is untouched.

Tests

NetworkBodyCapturingResponseBodyTest covers the wrapper in isolation: nothing is read up front,
bytes are forwarded unchanged, small events arrive one at a time, capture is reported on stream end
and on close (exactly once), an empty body reports an empty capture, the cap is enforced without
truncating what the application reads, read failures propagate, and the delegate body is closed.

SentryOkHttpInterceptorStreamingTest drives a real chunked HTTP origin (MockWebServer cannot
express a body that never ends) through the interceptor:

  • returns the response even though the body never ends — the regression test. Without the fix it
    fails with a TimeoutException; with it, execute() returns immediately.
  • delivers each small event to the application as it arrives — three 15-byte events, each read by
    the application separately, in order
  • captures a streamed body once the stream ends / ...when the application closes it
  • captures a streamed error response body (500)
  • caps the captured body without truncating the body the application reads (200 KB through a
    150 KB cap)
  • handles a zero length body and handles a zero length streamed body
  • does not capture bodies when network body capture is turned off
  • still captures a response with a known length up front and
    still captures an error response with a known length the application never reads — the existing
    behaviour is pinned
  • keeps the connection usable after a streamed response is closed

NetworkRequestDataTest pins the observable contract: empty until set, all three readable once set,
and fillable after the instance was published.

Full :sentry-okhttp:test run: 129 tests, no new failures (the 22 failures present are a
pre-existing JDK 25 / Mockito-inline issue on this machine, identical on unmodified main).
:sentry:spotlessCheck and :sentry-okhttp:detekt pass.

Trade-offs, stated up front

  • A streamed body that your code never reads is no longer captured. OkHttp drains an unread body
    inside the exchange on close(), below the wrapper, so those bytes never pass through it. The
    response itself (status, size, headers) is still recorded. This only affects bodies of unknown
    length; known-length bodies behave as before.
  • The capture is completed on the thread that finishes reading the body, instead of on the thread
    that called execute()/enqueue(). For streaming responses that thread is a background thread by
    definition, and the work is the same parsing that already happened before, only later.
  • The wrapper holds up to MAX_NETWORK_BODY_SIZE per in-flight response until the body is closed.
  • The memory-visibility fix in NetworkRequestData is a JMM correctness change and is not covered by
    a race test: a stress test with four readers and two million writes could not observe the torn
    state on this JVM, so the project has no test that would fail without the volatile.

carlonzo and others added 6 commits October 2, 2026 10:47
Capturing a response body for session replay peeked it up front with
`Response.peekBody(MAX_NETWORK_BODY_SIZE + 1)`. OkHttp implements a peek as
`request(byteCount)`, which keeps reading until that many bytes are buffered or
the stream ends. A response with no Content-Length never satisfies either
condition, so for server-sent events, long-poll and any chunked endpoint that
stays open the interceptor never returned and the caller never received the
response at all.

Bodies with a known length still end on their own, so they keep the existing
up-front capture. Bodies of unknown length are now wrapped in a body that
copies what the application consumes into a capped buffer and reports it once
no more bytes can arrive: when the stream ends, when the application closes
the body, or when the cap is reached.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Split the capture wrapping into its own function to keep a single return, and
suppress TooManyFunctions on the interceptor as done elsewhere in this module.
A response body has a single consumer, and neither Http1ExchangeCodec.cancel()
nor Http2ExchangeCodec.cancel() closes the body, so nothing reaches this class
from another thread. The AtomicBoolean already guarantees the capture is
reported exactly once.
A streamed body is only known once it has been consumed, which can be after the
NetworkRequestData carrying it was handed to the scope, so the replay thread can
read it while it is still being written. Keeping the three response values
behind one volatile reference means a reader sees either nothing or the
complete set.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant