Skip to content

fix(okhttp): Don't block on response bodies by reading the body upfront - #6231

Open
markushi wants to merge 24 commits into
mainfrom
fix/okhttp-unknown-length-response-body
Open

markushi wants to merge 24 commits into
mainfrom
fix/okhttp-unknown-length-response-body

Conversation

@markushi

@markushi markushi commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

📜 Description

SentryOkHttpInterceptor captured the response body with peekBody(MAX_NETWORK_BODY_SIZE + 1). A peek
is request(n), which waits for n bytes or for the end of the stream — a body with no
Content-Length delivers neither, so the interceptor never returned and the application never
received the response.

Every response body now goes through the new CapturedResponseBody, which copies the bytes the
application reads into a capped buffer. Nothing is read for the capture itself. The status code and
the headers are recorded before anything is consumed, so a stream that stays open still produces a
usable breadcrumb.

💡 Motivation and Context

With Session Replay network details enabled for a URL, every Server-Sent Events, long-poll or open
chunked endpoint hung forever or failed with a SocketTimeoutException. Gzipped responses were
affected too: OkHttp strips Content-Length when it decompresses, so an application interceptor sees
-1.

Continues carlonzo#1 by @carlonzo

Fixes #6138

💚 How did you test it?

Added unit tests, including a regression test that hangs without the fix.

📝 Checklist

  • I added GH Issue ID & Linear ID
  • I added tests to verify the changes.
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled.
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • Review from the native team if needed.
  • No breaking change or entry added to the changelog.
  • No breaking change for hybrid SDKs or communicated to hybrid SDKs.
  • Public API changes reviewed by another Mobile SDK team member or implemented according to the develop docs spec.

🔮 Next steps

carlonzo and others added 14 commits October 6, 2026 13:32
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.
@sentry

sentry Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

📲 Install Builds

Android

🔗 App Name App ID Version Configuration
SDK Size io.sentry.tests.size 8.61.1 (1) release

⚙️ sentry-android Build Distribution Settings

@sentry-junior

sentry-junior Bot commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

🐑 PR Shepherd — diagnosed head 8193c3632971d76bb5df2451f7ee6a14500803cb, new head 406bd4ea15c74d887030aa6c87cb2d31a14932dc

Check Class Diagnosis
Run Tests for API Level 35 (UI Tests Critical) code All 3 Maestro flows (app start, corruptEnvelope, crash) failed because expected UI elements never showed up. There were some emulator/adb warnings, but every flow failing suggests the app didn't reach its main screen. main is green for this workflow, so a human should check.
Ui tests (Integration Tests) code Sauce Labs Android 15 suite failed with an Espresso IdlingResourceTimeoutException while waiting for mock relay requests. main is green. This PR changes OkHttp response-body handling, so I can't rule out a link to the diff.

Actions

  • Merge conflict: only CHANGELOG.md conflicted with main. I merged main in and kept both entries (this PR's fix stays under ## Unreleased): 406bd4e. Pull before you keep working.
  • No retries: the failures above look code-related, not infra flakes. The merge commit starts fresh CI on the new head, and I'll report again if the UI checks still fail there.

…shepherd]

# Conflicts:
#	CHANGELOG.md

Co-Authored-By: Markus Hintersteiner <markus.hintersteiner@sentry.io>
@sentry-junior

sentry-junior Bot commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

PR shepherd (skill)

  • Head: d4909968 (merge of main, [pr-shepherd]). Mergeable: yes, no conflicting files.
  • Build / Build Job ubuntu-latest: flaky. :sentry-android-navigation3:testReleaseUnitTest failed in SentryNavEffectTest because Robolectric couldn't download its android-all-instrumented SDK artifact. That's a network/registry fetch problem, in a module this PR doesn't touch. The same workflow is green on main (eb32cb55).
  • Action: I re-ran the job once (run 39731911070, attempt 2). If it fails again on this SHA, it needs a human.

@markushi
markushi marked this pull request as ready for review October 22, 2026 07:43
@markushi markushi changed the title fix(okhttp): Don't block on response bodies with no known length fix(okhttp): Don't block on response bodies by reading the body upfront Oct 22, 2026
@markushi markushi added the sanity-check PR needs a lightweight review for obvious issues label Oct 22, 2026

@0xadam-brown 0xadam-brown left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a bunch for this 💯 ! A few comments from my end below.

Also, out of scope for now, but it looks like there might be a request-side version of this bug, in that the request body is still read into memory before the request is sent (link). Presumably that means streaming, duplex, or large uploads to an allowlisted URL can hang / lose data / trigger an OOM.

Let me know if you want me to open an issue 👍

* @param delegate the body to capture from.
* @param maxBytes the maximum number of bytes to retain; capture stops once it is reached.
* @param onCaptured invoked at most once, synchronously, on the thread that finishes the body — so
* it must not block. A body that is abandoned without being read to the end or closed never

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: Double-checking that the onCaptured KDoc is correct? I see, eg, that we invoke the callback from CapturedResponseBody.read() if we hit maxBytes or encounter an IOException.

override fun source(): BufferedSource = capturingSource

override fun close() {
try {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we can replace try + finally with delegate.use if you like

super.read(sink, byteCount)
} catch (e: IOException) {
// What arrived is the evidence for this very failure, and a caller handling it is not
// obliged to close the body.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Fwiw, I can't quite figure out what the LLM was trying to say here.

// the application may not have taken everything in the sink yet, hence the offset
val toTake = minOf(maxBytes - captured.size, sink.size - sinkBefore)
if (toTake > 0L) {
sink.copyTo(captured, sinkBefore, toTake)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ I think we need to use the Buffer.copyTo() method that takes an OutputStream rather than the one that takes a Buffer.

Looks like Buffer.copyTo(Buffer, ...) doesn't immediately copy bytes, but instead (eventually) calls Segment.sharedCopy() (link). That means:

  1. each captured section will keep a reference to the sink segment's entire 8 KB byte array (link), and
  2. the segments won't be returned to the segment pool (link)

My LLM tells me that for SSE, long-poll, or chunked streams with small events, each small event will be forced to retain a full 8 KB array. With 10-byte heartbeats, that makes for ~120 MB of memory leaked per stream.

The Buffer.copyTo() method that takes an OutputStream doesn't share the segment, and so we avoid leaking it (link).

return newBuilder()
.body(
CapturedResponseBody(responseBody, captureCap) { capturedBytes ->
// This runs on the thread consuming the body, inside its read or close, so a failure in

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

h: Thoughts about calling recordResponse() from our executor service?

I'm worried about delaying delivery of SSE events when the onCaptured callback here is invoked from CapturedResponseBody.read(), and causing app frames to stutter when called from .close() (as apps often cancel a stream or close a response from onDestroy()).

ExceptionUtils.rethrowIfFatal(e)
logger.log(
io.sentry.SentryLevel.ERROR,
"Failed to capture the http response body for Network Details: ${e.message}",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: We could also log e to pick up the stack traces.

}

@Test
fun `a failing capture callback does not reach the application`() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: Maybe: closes the delegate even when the capture callback throws? (Looks like the current name is the opposite of what it should be, given that we assert wrapper.close() is called)


public static <T> @NotNull ReplayNetworkRequestOrResponse createResponse(
@NotNull final T httpObject,
@Nullable final Integer statusCode,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth calling the binary incompatible changes out in the CHANGELOG since they show up in sentry rather than sentry-okhttp??

That said, happy to defer to you, esp if we always assume breaking changes to ApiStatus.Internal types are allowed.

Comment thread CHANGELOG.md

### Fixes

- Fix `SentryOkHttpInterceptor` hanging forever on responses whose body has no known length, such as Server-Sent Events or a gzipped response ([#6231](https://github.com/getsentry/sentry-java/pull/6231))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: Might want to add a "Session Replay:" prefix to this so we don't suggest this was happening to everyone (could also add a qualification that it only impacted folks who had network details turned on if you think that'd be helpful).

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 4dbcbfa. Configure here.

// the application may not have taken everything in the sink yet, hence the offset
val toTake = minOf(maxBytes - captured.size, sink.size - sinkBefore)
if (toTake > 0L) {
sink.copyTo(captured, sinkBefore, toTake)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shared segments inflate capture memory

High Severity

Buffer.copyTo(Buffer) shares each Okio segment instead of copying bytes, so every small SSE/chunked read retains an entire 8 KB array. A stream that arrives as many small events can pin tens of megabytes until the cap is hit, and reportCaptured never clears captured, so that memory stays for the life of the body.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4dbcbfa. Configure here.

io.sentry.SentryLevel.ERROR,
"Failed to capture the http response body for Network Details: ${e.message}",
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New broad Throwable catch added

Low Severity

This new catch (Throwable) wraps SDK code (recordResponse and NetworkBodyParser.fromBytes), not a user or third-party callback. The project rule allows that pattern only at those entry points, even when rethrowIfFatal is used.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bugbot

Reviewed by Cursor Bugbot for commit 4dbcbfa. Configure here.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

sanity-check PR needs a lightweight review for obvious issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Gate OkHttp body capture on active Session Replay

3 participants