Repository: langchain4j/langchain4j (2026-07-20T04:07:35Z)

Issue:
[BUG] JdkHttpClient alters the error response body of streaming requests

**Describe the bug**

`JdkHttpClient.readBody(HttpResponse<InputStream>)` reads the error body of a streaming (SSE) request with `reader.lines().collect(joining(System.lineSeparator()))`. `lines()` discards the original line separators and drops the trailing one, so the string put into `HttpException` is not what the server sent. The `InputStreamReader` is created without a charset, so on a JVM whose default charset is not UTF-8 (Java 17 does not fix it to UTF-8) non-ASCII error text is decoded incorrectly.

The synchronous path in the same class already builds the message with `new String(body, UTF_8)`, so the two paths report different messages for the same response.

**Log and Stack trace**

N/A

**To Reproduce**

Server returns `400` with body `line1\r\nline2\r\n` for a streaming call:

```java
client.execute(request, new DefaultServerSentEventParser(), listener);
// listener.onError() receives HttpException with message "line1\nline2"
```

**Expected behavior**

`HttpException.getMessage()` equals the body the server sent: `line1\r\nline2\r\n`.

**Please complete the following information:**
- LangChain4j version: 1.19.0-SNAPSHOT (main)
- LLM(s) used: N/A
- Java version: 17
- Spring Boot version (if applicable): N/A

**Additional context**

`ApacheHttpClient.readBody` has the same pattern. It is left out of this report so the fix can be validated on one module first.




Discussion:

Patch:
diff --git a/http-clients/langchain4j-http-client-jdk/src/main/java/dev/langchain4j/http/client/jdk/JdkHttpClient.java b/http-clients/langchain4j-http-client-jdk/src/main/java/dev/langchain4j/http/client/jdk/JdkHttpClient.java
index 7fea8efcbe3..b1d5efe4295 100644
--- a/http-clients/langchain4j-http-client-jdk/src/main/java/dev/langchain4j/http/client/jdk/JdkHttpClient.java
+++ b/http-clients/langchain4j-http-client-jdk/src/main/java/dev/langchain4j/http/client/jdk/JdkHttpClient.java
@@ -2,7 +2,6 @@
 
 import static dev.langchain4j.http.client.sse.ServerSentEventListenerUtils.ignoringExceptions;
 import static dev.langchain4j.internal.Utils.getOrDefault;
-import static java.util.stream.Collectors.joining;
 
 import dev.langchain4j.exception.HttpException;
 import dev.langchain4j.exception.TimeoutException;
@@ -12,10 +11,8 @@
 import dev.langchain4j.http.client.SuccessfulHttpResponse;
 import dev.langchain4j.http.client.sse.ServerSentEventListener;
 import dev.langchain4j.http.client.sse.ServerSentEventParser;
-import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.InputStream;
-import java.io.InputStreamReader;
 import java.net.URI;
 import java.net.http.HttpRequest.BodyPublisher;
 import java.net.http.HttpRequest.BodyPublishers;
@@ -154,9 +151,8 @@ private static boolean isSuccessful(java.net.http.HttpResponse<?> response) {
     }
 
     private static String readBody(java.net.http.HttpResponse<InputStream> response) {
-        try (InputStream inputStream = response.body();
-                BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) {
-            return reader.lines().collect(joining(System.lineSeparator()));
+        try (InputStream inputStream = response.body()) {
+            return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
         } catch (IOException e) {
             return "Cannot read error response body: " + e.getMessage();
         }


Test patch:
diff --git a/http-clients/langchain4j-http-client-jdk/src/test/java/dev/langchain4j/http/client/jdk/JdkHttpClientErrorBodyTest.java b/http-clients/langchain4j-http-client-jdk/src/test/java/dev/langchain4j/http/client/jdk/JdkHttpClientErrorBodyTest.java
new file mode 100644
index 00000000000..01bbd50af75
--- /dev/null
+++ b/http-clients/langchain4j-http-client-jdk/src/test/java/dev/langchain4j/http/client/jdk/JdkHttpClientErrorBodyTest.java
@@ -0,0 +1,120 @@
+package dev.langchain4j.http.client.jdk;
+
+import static dev.langchain4j.http.client.HttpMethod.GET;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+import com.github.tomakehurst.wiremock.client.WireMock;
+import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
+import dev.langchain4j.exception.HttpException;
+import dev.langchain4j.http.client.HttpRequest;
+import dev.langchain4j.http.client.sse.DefaultServerSentEventParser;
+import dev.langchain4j.http.client.sse.ServerSentEvent;
+import dev.langchain4j.http.client.sse.ServerSentEventListener;
+import java.util.concurrent.CompletableFuture;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class JdkHttpClientErrorBodyTest {
+
+    private WireMockServer wireMockServer;
+
+    @BeforeEach
+    void beforeEach() {
+        wireMockServer = new WireMockServer(WireMockConfiguration.options().dynamicPort());
+        wireMockServer.start();
+    }
+
+    @AfterEach
+    void afterEach() {
+        if (wireMockServer != null) {
+            wireMockServer.stop();
+        }
+    }
+
+    @Test
+    void should_preserve_line_separators_of_error_response_body() throws Exception {
+
+        // given
+        String body = "line1\r\nline2\r\n";
+        stubError(body);
+
+        // when
+        Throwable error = executeStreamingAndWaitForError();
+
+        // then
+        assertThat(error).isInstanceOf(HttpException.class).hasMessage(body);
+        assertThat(((HttpException) error).statusCode()).isEqualTo(400);
+    }
+
+    @Test
+    void should_decode_error_response_body_as_utf8() throws Exception {
+
+        // given
+        String body = "모델 오류";
+        stubError(body);
+
+        // when
+        Throwable error = executeStreamingAndWaitForError();
+
+        // then
+        assertThat(error).isInstanceOf(HttpException.class).hasMessage(body);
+    }
+
+    @Test
+    void should_not_fail_on_successful_response() throws Exception {
+
+        // given
+        wireMockServer.stubFor(WireMock.get("/endpoint")
+                .willReturn(WireMock.aResponse()
+                        .withStatus(200)
+                        .withHeader("Content-Type", "text/event-stream")
+                        .withBody("data: hello\n\n")));
+
+        CompletableFuture<ServerSentEvent> event = new CompletableFuture<>();
+        CompletableFuture<Throwable> error = new CompletableFuture<>();
+
+        // when
+        JdkHttpClient.builder()
+                .build()
+                .execute(request(), new DefaultServerSentEventParser(), new ServerSentEventListener() {
+
+                    @Override
+                    public void onEvent(ServerSentEvent sse) {
+                        event.complete(sse);
+                    }
+
+                    @Override
+                    public void onError(Throwable throwable) {
+                        error.complete(throwable);
+                    }
+                });
+
+        // then
+        assertThat(event.get(10, SECONDS).data()).isEqualTo("hello");
+        assertThat(error).isNotCompleted();
+    }
+
+    private void stubError(String body) {
+        wireMockServer.stubFor(WireMock.get("/endpoint")
+                .willReturn(WireMock.aResponse().withStatus(400).withBody(body.getBytes(UTF_8))));
+    }
+
+    private Throwable executeStreamingAndWaitForError() throws Exception {
+        CompletableFuture<Throwable> error = new CompletableFuture<>();
+
+        JdkHttpClient.builder().build().execute(request(), new DefaultServerSentEventParser(), error::complete);
+
+        return error.get(10, SECONDS);
+    }
+
+    private HttpRequest request() {
+        return HttpRequest.builder()
+                .method(GET)
+                .url("http://localhost:" + wireMockServer.port() + "/endpoint")
+                .build();
+    }
+}