From 0eff2208eec48d95530af7e623ba6de93ff3210b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 23 Jun 2026 11:18:38 +0000 Subject: [PATCH 1/2] Fix null-body side-output writes for GCS, S3, and gzip wrapper 204/HEAD responses produce ProcessedContent with null body bytes. Writing those to side output previously threw NPE inside GCSOutput, S3Output, or CompressedOutputWrapper, causing silent side-output data loss. Co-authored-by: Erik Schultink --- .../gateway/impl/output/CompressedOutputWrapper.java | 7 ++++--- .../impl/output/CompressedOutputWrapperTest.java | 11 +++++++++++ .../main/java/co/worklytics/psoxy/aws/S3Output.java | 10 ++++++---- .../src/main/java/co/worklytics/psoxy/GCSOutput.java | 11 ++++++++--- 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/java/core/src/main/java/co/worklytics/psoxy/gateway/impl/output/CompressedOutputWrapper.java b/java/core/src/main/java/co/worklytics/psoxy/gateway/impl/output/CompressedOutputWrapper.java index 3439a96dda..6f4ef0c55c 100644 --- a/java/core/src/main/java/co/worklytics/psoxy/gateway/impl/output/CompressedOutputWrapper.java +++ b/java/core/src/main/java/co/worklytics/psoxy/gateway/impl/output/CompressedOutputWrapper.java @@ -33,9 +33,10 @@ public void write(ProcessedContent content) throws WriteFailure { @Override public void write(String key, ProcessedContent content) throws WriteFailure { try { - if (!Objects.equals(COMPRESSION_TYPE, content.getContentEncoding())) { + byte[] rawContent = content.getContent() != null ? content.getContent() : new byte[0]; + if (!Objects.equals(COMPRESSION_TYPE, content.getContentEncoding())) { log.info("Compressing response with gzip encoding through wrapper"); - byte[] compressedContent = gzipContent(content.getContent()); + byte[] compressedContent = gzipContent(rawContent); content = content.withContentEncoding(COMPRESSION_TYPE).withContent(compressedContent); } delegate.write(key, content); @@ -50,7 +51,7 @@ public void write(String key, ProcessedContent content) throws WriteFailure { * @param content to compress * @return a byte[] reflecting gzip-encoding of the content */ - byte[] gzipContent(@NonNull byte[] content) throws WriteFailure { + byte[] gzipContent(byte[] content) throws WriteFailure { try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); GZIPOutputStream gzipOutputStream = new GZIPOutputStream(byteArrayOutputStream)) { gzipOutputStream.write(content); diff --git a/java/core/src/test/java/co/worklytics/psoxy/gateway/impl/output/CompressedOutputWrapperTest.java b/java/core/src/test/java/co/worklytics/psoxy/gateway/impl/output/CompressedOutputWrapperTest.java index dfc0fab064..46c881d69c 100644 --- a/java/core/src/test/java/co/worklytics/psoxy/gateway/impl/output/CompressedOutputWrapperTest.java +++ b/java/core/src/test/java/co/worklytics/psoxy/gateway/impl/output/CompressedOutputWrapperTest.java @@ -1,5 +1,8 @@ package co.worklytics.psoxy.gateway.impl.output; +import co.worklytics.psoxy.gateway.ProcessedContent; +import co.worklytics.psoxy.gateway.output.Output; + import org.junit.jupiter.api.Test; import java.io.BufferedReader; @@ -13,6 +16,14 @@ class CompressedOutputWrapperTest { + @Test + void writeNullContent() throws Exception { + Output delegate = new NoOutput(); + CompressedOutputWrapper wrapper = CompressedOutputWrapper.wrap(delegate); + ProcessedContent content = ProcessedContent.builder().content(null).build(); + assertDoesNotThrow(() -> wrapper.write(content)); + } + @Test void gzipContent() throws Exception { String content = "Hello, world!"; diff --git a/java/impl/aws/src/main/java/co/worklytics/psoxy/aws/S3Output.java b/java/impl/aws/src/main/java/co/worklytics/psoxy/aws/S3Output.java index e17dcf8e7e..e1c0fda824 100644 --- a/java/impl/aws/src/main/java/co/worklytics/psoxy/aws/S3Output.java +++ b/java/impl/aws/src/main/java/co/worklytics/psoxy/aws/S3Output.java @@ -49,9 +49,10 @@ public S3Output(@Assisted OutputLocation location) { @Override public void write(String key, ProcessedContent content) throws WriteFailure { + byte[] body = content.getContent() != null ? content.getContent() : new byte[0]; if (key == null) { - key = DigestUtils.md5Hex(content.getContent()); + key = DigestUtils.md5Hex(body); } try { @@ -66,14 +67,14 @@ public void write(String key, ProcessedContent content) throws WriteFailure { PutObjectRequest.Builder putBuilder = PutObjectRequest.builder() .bucket(bucket) .key(pathPrefix + key) - .contentLength((long) content.getContent().length) + .contentLength((long) body.length) .metadata(userMetadata); // s3 client blows up if these are filled with 'null' values, so only set if present Optional.ofNullable(content.getContentEncoding()).ifPresent(putBuilder::contentEncoding); Optional.ofNullable(content.getContentType()).ifPresent(putBuilder::contentType); - s3Client.putObject(putBuilder.build(), RequestBody.fromBytes(content.getContent())); + s3Client.putObject(putBuilder.build(), RequestBody.fromBytes(body)); } catch (Exception e) { throw new WriteFailure("Failed to write to S3 output", e); } @@ -83,7 +84,8 @@ public void write(String key, ProcessedContent content) throws WriteFailure { public void write(ProcessedContent content) throws WriteFailure { // Generate a canonical key based on the content's hash // random UUID better?? - String key = DigestUtils.sha256Hex(content.getContent()); + byte[] body = content.getContent() != null ? content.getContent() : new byte[0]; + String key = DigestUtils.sha256Hex(body); write(key, content); } } diff --git a/java/impl/gcp/src/main/java/co/worklytics/psoxy/GCSOutput.java b/java/impl/gcp/src/main/java/co/worklytics/psoxy/GCSOutput.java index 8943e38041..2e09f89679 100644 --- a/java/impl/gcp/src/main/java/co/worklytics/psoxy/GCSOutput.java +++ b/java/impl/gcp/src/main/java/co/worklytics/psoxy/GCSOutput.java @@ -42,8 +42,10 @@ public GCSOutput(@Assisted OutputLocation location) { @Override public void write(String key, ProcessedContent content) throws WriteFailure { + byte[] body = content.getContent() != null ? content.getContent() : new byte[0]; + if (key == null) { - key = DigestUtils.md5Hex(content.getContent()); + key = DigestUtils.md5Hex(body); } try { @@ -58,7 +60,9 @@ public void write(String key, ProcessedContent content) throws WriteFailure { .setContentEncoding(content.getContentEncoding()) .setMetadata(metadata) .build())) { - writeChannel.write(java.nio.ByteBuffer.wrap(content.getContent(), 0, content.getContent().length)); + if (body.length > 0) { + writeChannel.write(java.nio.ByteBuffer.wrap(body)); + } } } catch (Exception e) { log.log(Level.WARNING, "Failed to write to GCS sideOutput", e); @@ -69,7 +73,8 @@ public void write(String key, ProcessedContent content) throws WriteFailure { @Override public void write(ProcessedContent content) throws WriteFailure { // Generate a canonical key for the response - String key = DigestUtils.md5Hex(content.getContent()); + byte[] body = content.getContent() != null ? content.getContent() : new byte[0]; + String key = DigestUtils.md5Hex(body); write(key, content); } From 5dd9b2693efb6a4d3a71a6176f9a114074fbeb93 Mon Sep 17 00:00:00 2001 From: Erik Schultink Date: Wed, 1 Jul 2026 10:39:34 -0700 Subject: [PATCH 2/2] Address PR review comments on null-body side outputs - Centralize null-body normalization in ProcessedContent.getContent() - Fix CompressedOutputWrapper gzip-already-encoded path for null bodies - Restore @NonNull on gzipContent; add regression test for pre-gzipped null content - Simplify GCSOutput and S3Output to use normalized getContent() Co-authored-by: Cursor --- .../psoxy/gateway/ProcessedContent.java | 18 +++++++++++++----- .../impl/output/CompressedOutputWrapper.java | 4 ++-- .../output/CompressedOutputWrapperTest.java | 11 +++++++++++ .../java/co/worklytics/psoxy/aws/S3Output.java | 5 ++--- .../java/co/worklytics/psoxy/GCSOutput.java | 5 ++--- 5 files changed, 30 insertions(+), 13 deletions(-) diff --git a/java/core/src/main/java/co/worklytics/psoxy/gateway/ProcessedContent.java b/java/core/src/main/java/co/worklytics/psoxy/gateway/ProcessedContent.java index 02d32b33f7..8c2c4ba348 100644 --- a/java/core/src/main/java/co/worklytics/psoxy/gateway/ProcessedContent.java +++ b/java/core/src/main/java/co/worklytics/psoxy/gateway/ProcessedContent.java @@ -50,19 +50,27 @@ public class ProcessedContent implements Serializable { Map metadata = new HashMap<>(); /** - * the actual content + * the actual content; may be null when the upstream response has no body (e.g. 204, HEAD) */ + @Getter(lombok.AccessLevel.NONE) byte[] content; + /** + * Returns content bytes for consumers that need to read or write the body. + * Missing bodies are treated as an empty array to avoid NPEs in output writers. + */ + public byte[] getContent() { + return content != null ? content : new byte[0]; + } + /** * for convenience, a method to get the content as a string - rather than byte array - * @return the content as a string, using the specified contentCharset + * @return the content as a string, using the specified contentCharset; null if no body */ public String getContentAsString() { - if (getContent() == null) { + if (content == null) { return null; - } else { - return new String(getContent(), contentCharset); } + return new String(content, contentCharset); } } diff --git a/java/core/src/main/java/co/worklytics/psoxy/gateway/impl/output/CompressedOutputWrapper.java b/java/core/src/main/java/co/worklytics/psoxy/gateway/impl/output/CompressedOutputWrapper.java index 6f4ef0c55c..8dd1c0e84c 100644 --- a/java/core/src/main/java/co/worklytics/psoxy/gateway/impl/output/CompressedOutputWrapper.java +++ b/java/core/src/main/java/co/worklytics/psoxy/gateway/impl/output/CompressedOutputWrapper.java @@ -33,7 +33,7 @@ public void write(ProcessedContent content) throws WriteFailure { @Override public void write(String key, ProcessedContent content) throws WriteFailure { try { - byte[] rawContent = content.getContent() != null ? content.getContent() : new byte[0]; + byte[] rawContent = content.getContent(); if (!Objects.equals(COMPRESSION_TYPE, content.getContentEncoding())) { log.info("Compressing response with gzip encoding through wrapper"); byte[] compressedContent = gzipContent(rawContent); @@ -51,7 +51,7 @@ public void write(String key, ProcessedContent content) throws WriteFailure { * @param content to compress * @return a byte[] reflecting gzip-encoding of the content */ - byte[] gzipContent(byte[] content) throws WriteFailure { + byte[] gzipContent(@NonNull byte[] content) throws WriteFailure { try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); GZIPOutputStream gzipOutputStream = new GZIPOutputStream(byteArrayOutputStream)) { gzipOutputStream.write(content); diff --git a/java/core/src/test/java/co/worklytics/psoxy/gateway/impl/output/CompressedOutputWrapperTest.java b/java/core/src/test/java/co/worklytics/psoxy/gateway/impl/output/CompressedOutputWrapperTest.java index 46c881d69c..84f95fc208 100644 --- a/java/core/src/test/java/co/worklytics/psoxy/gateway/impl/output/CompressedOutputWrapperTest.java +++ b/java/core/src/test/java/co/worklytics/psoxy/gateway/impl/output/CompressedOutputWrapperTest.java @@ -24,6 +24,17 @@ void writeNullContent() throws Exception { assertDoesNotThrow(() -> wrapper.write(content)); } + @Test + void writeNullContentAlreadyGzipEncoded() throws Exception { + Output delegate = new NoOutput(); + CompressedOutputWrapper wrapper = CompressedOutputWrapper.wrap(delegate); + ProcessedContent content = ProcessedContent.builder() + .content(null) + .contentEncoding(CompressedOutputWrapper.COMPRESSION_TYPE) + .build(); + assertDoesNotThrow(() -> wrapper.write(content)); + } + @Test void gzipContent() throws Exception { String content = "Hello, world!"; diff --git a/java/impl/aws/src/main/java/co/worklytics/psoxy/aws/S3Output.java b/java/impl/aws/src/main/java/co/worklytics/psoxy/aws/S3Output.java index e1c0fda824..7440d14101 100644 --- a/java/impl/aws/src/main/java/co/worklytics/psoxy/aws/S3Output.java +++ b/java/impl/aws/src/main/java/co/worklytics/psoxy/aws/S3Output.java @@ -49,7 +49,7 @@ public S3Output(@Assisted OutputLocation location) { @Override public void write(String key, ProcessedContent content) throws WriteFailure { - byte[] body = content.getContent() != null ? content.getContent() : new byte[0]; + byte[] body = content.getContent(); if (key == null) { key = DigestUtils.md5Hex(body); @@ -84,8 +84,7 @@ public void write(String key, ProcessedContent content) throws WriteFailure { public void write(ProcessedContent content) throws WriteFailure { // Generate a canonical key based on the content's hash // random UUID better?? - byte[] body = content.getContent() != null ? content.getContent() : new byte[0]; - String key = DigestUtils.sha256Hex(body); + String key = DigestUtils.sha256Hex(content.getContent()); write(key, content); } } diff --git a/java/impl/gcp/src/main/java/co/worklytics/psoxy/GCSOutput.java b/java/impl/gcp/src/main/java/co/worklytics/psoxy/GCSOutput.java index 2e09f89679..5c2965a0ba 100644 --- a/java/impl/gcp/src/main/java/co/worklytics/psoxy/GCSOutput.java +++ b/java/impl/gcp/src/main/java/co/worklytics/psoxy/GCSOutput.java @@ -42,7 +42,7 @@ public GCSOutput(@Assisted OutputLocation location) { @Override public void write(String key, ProcessedContent content) throws WriteFailure { - byte[] body = content.getContent() != null ? content.getContent() : new byte[0]; + byte[] body = content.getContent(); if (key == null) { key = DigestUtils.md5Hex(body); @@ -73,8 +73,7 @@ public void write(String key, ProcessedContent content) throws WriteFailure { @Override public void write(ProcessedContent content) throws WriteFailure { // Generate a canonical key for the response - byte[] body = content.getContent() != null ? content.getContent() : new byte[0]; - String key = DigestUtils.md5Hex(body); + String key = DigestUtils.md5Hex(content.getContent()); write(key, content); }