Repository navigation
sentry-apache-http-client-5: transport sends a truncated gzip body, Relay rejects every envelope with HTTP 400 #6000
Description
Integration
sentry-apache-http-client-5
Java Version
17
Other Error Monitoring Solution
No
Other Error Monitoring Solution Name
No response
Version
8.21.1 (also present on main, and in every release since the module was added in #1114)
Steps to Reproduce
ApacheHttpClientTransport.send reads the gzip buffer inside the try-with-resources that owns the GZIPOutputStream, i.e. before close():
try (final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
final GZIPOutputStream gzip = new GZIPOutputStream(outputStream)) {
options.getSerializer().serialize(envelopeWithClientReport, gzip);
final SimpleHttpRequest request = SimpleHttpRequests.post(requestDetails.getUrl().toString());
request.setBody(
outputStream.toByteArray(), ContentType.create("application/x-sentry-envelope")); // <-- before close()
request.setHeader("Content-Encoding", "gzip");Deflater buffers its compressed output and only emits the final deflate block plus the 8-byte gzip trailer (CRC32 + ISIZE) on close()/finish(). JsonSerializer.serialize deliberately does not close the stream it is handed ("we do not want to close these as we would also close the stream that was passed in"), and flush() on a GZIPOutputStream constructed without syncFlush does not flush the deflater either. So at the point toByteArray() is called, the buffer holds only the 10-byte gzip header.
To reproduce end-to-end: configure the SDK with options.setTransportFactory(new ApacheHttpClientTransportFactory()) and capture anything. Every envelope is rejected.
The truncation itself reproduces with no Sentry code at all, mirroring what the serializer does to the stream:
byte[] payload = ("{\"event_id\":\"9ec79c33ec9942ab8353589fcb2e04dc\"}\n"
+ "{\"type\":\"event\",\"length\":42}\n" + "x".repeat(400)).getBytes("UTF-8");
byte[] body;
try (ByteArrayOutputStream out = new ByteArrayOutputStream();
GZIPOutputStream gzip = new GZIPOutputStream(out)) {
BufferedOutputStream bos = new BufferedOutputStream(gzip); // as JsonSerializer does
bos.write(payload);
bos.flush();
body = out.toByteArray(); // read before close(), as the transport does
}
System.out.println(payload.length + " bytes in -> " + body.length + " bytes out");
new GZIPInputStream(new ByteArrayInputStream(body)).readAllBytes(); // throws469 bytes in -> 10 bytes out
Exception in thread "main" java.io.EOFException: Unexpected end of ZLIB input stream
Moving toByteArray() after the try-with-resources block (or calling gzip.finish() before reading) produces a complete, decodable stream.
Expected Result
The POST body is a complete gzip stream containing the serialized envelope, and Sentry accepts it — the same body the default HttpConnection-based transport produces. HttpConnection gets this right incidentally, because it gzips straight into the connection's output stream and so never reads a buffer early.
Actual Result
The body is always the bare 10-byte gzip header: no payload, no trailer, regardless of envelope size. Sentry/Relay rejects 100% of envelopes:
HTTP 400 Failed to buffer the request body: unexpected end of file
Verified against a Relay instance: the same envelope sent uncompressed, or gzipped with the ordering fixed, returns HTTP 200; gzipped the way this transport does it returns the 400 above.
Two things make this hard to notice, which is presumably why the module has shipped this way since #1114:
- The failure is silent. The transport reports it via
options.getLogger(), andDiagnosticLogger.isEnabledreturns false unlessoptions.isDebug()is set, so by default every event is dropped with no output at all. We lost five days of production error reporting before noticing, and only found it by reading Relay's own request logs. Content-Encoding: gzipis set unconditionally, so there is no non-gzip path that would have worked.
Possibly relevant to #4742 ("Verify that the Apache Transport works ... Make a decision on how to move forward with it") — this is the answer to the first bullet: as shipped, it cannot deliver an envelope. Happy to send a PR with the reorder plus a test asserting the request body inflates back to the serialized envelope, if useful.
Metadata
Metadata
Assignees
Labels
Projects
- StatusShow more project fieldsWaiting for: Product Owner
Activity
linear-code commentedon Aug 25, 2026
JAVA-707
alacoste commentedon Aug 26, 2026
A second, unrelated defect in the same class, spotted while working around the one above — happy to fold it into the same PR.
ApacheHttpClientTransport.close(boolean)awaits a shutdown it never initiates:AbstractHttpAsyncClientBase.awaitShutdownwaits for the reactor to reachSHUT_DOWN, but nothing has asked it to shut down, so the status staysACTIVEand the call simply blocks until the timeout expires.close(CloseMode.GRACEFUL)in thefinallythen performs the actual shutdown. Net effect: a flat extra second on every non-restarting close, and the intended "give in-flight uploads a moment, then close" never happens.Timed against a live reactor with one request in flight (httpclient5 5.5.1):
Adding
httpclient.initiateShutdown()before the await fixes it. Skipping the await outright whenisRestartingis also a bit clearer than passingTimeValue.ofSeconds(0), which today happens to be indistinguishable from the non-restarting path anyway (both just block until they give up).Low severity on its own, since
AsyncHttpTransport-style draining is done byflush()beforeclose()inSentry.close()— it costs shutdown latency rather than events.alacoste commentedon Aug 26, 2026
Correcting my previous comment: I understated the shutdown issue. It is not just the ~1s wasted on the no-op
awaitShutdown— theclose(CloseMode.GRACEFUL)in thefinallyadds up to a further 5 seconds.MultiCoreIOReactor(httpcore5 5.3.6):So
close(GRACEFUL)already does the initiate-then-await that the transport is trying to do by hand, on its own hardcoded 5s timeout that isn't reachable through theCloseableHttpAsyncClientAPI. The transport's ownawaitShutdown(TimeValue.ofSeconds(isRestarting ? 0 : 1))is therefore both a no-op (nothing has initiated shutdown yet) and redundant with what follows.Measured with one 4s upload in flight (httpclient5 5.5.1):
Two consequences for
ApacheHttpClientTransport.close(boolean)as written:isRestartinghas no effect. Its whole purpose per theITransportjavadoc is to "avoid locking the main thread by dropping the current connection", butTimeValue.ofSeconds(0)only shortens a wait that was doing nothing anyway, and theclose(GRACEFUL)afterwards blocks identically either way.initiateShutdown(), thenawaitShutdownonly when not restarting, thenclose(CloseMode.IMMEDIATE)gives ~1s / ~0s respectively and makes the flag meaningful. Happy to include this in the same PR as the gzip fix.alacoste commentedon Aug 26, 2026
Third one in the same method, for completeness:
ApacheHttpClientTransport.close(boolean)never closes itsRateLimiter.RateLimiter implements Closeable, owns a lazily createdTimerand aCopyOnWriteArrayList<IRateLimitObserver>, and itsclose()cancels the timer and clears the observers.AsyncHttpTransport.close(boolean)callsrateLimiter.close()as its first statement:ApacheHttpClientTransport.close(boolean)has no equivalent, so onSentry.close()— and, more relevantly, on a re-init that goes throughclose(true)— the old limiter's timer and observers are stranded.Modest severity: the timer is only created once a rate limit has actually been applied, and it's a daemon (
new Timer(true)), so it can't block JVM exit. But it's a straightforward divergence from the reference transport.So the three things in that class, in the order they matter:
400. This one is fatal and is the reason the module cannot deliver an event as shipped.close()awaits a shutdown it never initiates, thenclose(GRACEFUL)adds up to a hardcoded 5s, makingisRestartinga no-op.RateLimiternever closed.All three are one- or two-line fixes. Still happy to send a PR covering them plus a test that asserts the request body inflates back to the serialized envelope — just say if you'd rather split them.
adinauer commentedon Aug 27, 2026
Thank you for opening this issue @alacoste and sorry for the troubles you're having.
I recommend just sticking with the default
AsyncHttpTransport.We haven't given
ApacheHttpClientTransportmuch love in the past and what you're experiencing makes me think we should just throw it out in the next major. We've been debating about this internally and as far as we're aware usage numbers are fairly low anyways.There's also more complex transport / queue related work we want to tackle (not soon though). This likely means further transport changes and time investment required where having two transports just adds a lot of overhead.
I'm afraid my suggestion if you want to keep using the
ApacheHttpClientTransportis to copy the code and fix it as we're not planning on working on it anymore. Again - Sorry!alacoste commentedon Aug 27, 2026
Thanks for the reply!
For context, the reason we wanted to switch to this new transport is because we noticed that the default one is making one TCP connection per event to send to sentry (even though all these connections are to the same sentry endpoint).
This can quickly be a lot of connections if you have something a bit spammy going on, and can threaten to starve the machine of outgoing connections through the NAT of our infra setup (Google kubernetes engine).
Granted, it's not great to have something spamming sentry events in the first place, but it's scary if it can lead to a much more serious outage by starving the machine of connections to the outside internet!
I don't know if this context is enough motivation for your team to consider keeping this code alive or providing another solution to that end!
In any case, if you guys change your mind about this lmk, and if we're successful in using the copy of this we're currently trying out, I'll happily propose a fix PR here!
adinauer commentedon Sep 1, 2026
Thanks for the feedback! We can check if there's a way to reuse connections in
AsyncHttpTransport. I've opened #6035 to track. Closing this issue now.