For streaming responses through Server-Sent Events (SSE), ensure this Nginx configuration is disabled: proxy_buffering off;
While OkHttp3 disables redirects by default, Forest implements its own redirect mechanism. To align OkHttp3 with Forest's behavior, configure custom redirect handling in an interceptor.
Project Dependencies
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>3.12.0</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp-sse</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
Custom Request Interceptor
package com.example.sse.client;
import okhttp3.*;
import okio.Buffer;
import java.io.IOException;
public class RedirectAwareInterceptor implements Interceptor {
@Override
public Response intercept(Chain chain) throws IOException {
Request originalRequest = chain.request();
Response initialResponse = chain.proceed(originalRequest);
logRequestDetails(originalRequest);
if (initialResponse.isRedirect()) {
String redirectLocation = initialResponse.header("Location");
if (redirectLocation != null && !redirectLocation.isEmpty()) {
System.out.println("Initiating redirect to: " + redirectLocation);
initialResponse.close();
Request redirectedRequest = originalRequest.newBuilder()
.url(redirectLocation)
.method(originalRequest.method(), originalRequest.body())
.build();
return chain.proceed(redirectedRequest);
}
}
return initialResponse;
}
private void logRequestDetails(Request request) throws IOException {
System.out.println("--- HTTP Request Details ---");
System.out.println("Target URL: " + request.url());
System.out.println("HTTP Method: " + request.method());
request.headers().forEach((name, value) ->
System.out.println(name + ": " + value));
if (request.body() != null) {
Buffer requestBodyBuffer = new Buffer();
request.body().writeTo(requestBodyBuffer);
System.out.println("Payload: " + requestBodyBuffer.readUtf8());
}
}
}
Client Configuration
Important: When implementing manual redirects, avoid using addNetworkInterceptor as it may interfere with authentication and prevent redirects from working properly. Use addInterceptor instead.
package com.example.sse.config;
import okhttp3.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.TimeUnit;
@Configuration
public class HttpClientConfiguration {
@Bean
public OkHttpClient streamingHttpClient() {
return new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.followRedirects(false)
.followSslRedirects(false)
.addInterceptor(new RedirectAwareInterceptor())
.build();
}
}
Stream Controller Implementation
@GetMapping(value = "/conversation", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter initiateConversation(@RequestParam @NotBlank String userInput) {
SseEmitter responseEmitter = new SseEmitter(0L);
try {
JSONObject conversationPayload = new JSONObject();
conversationPayload.put("prompt", userInput);
RequestBody bodyPayload = RequestBody.create(
MediaType.parse("application/json"),
conversationPayload.toString()
);
Request apiRequest = new Request.Builder()
.url("https://api.example.com/v1/chat/stream")
.post(bodyPayload)
.addHeader("Authorization", "Bearer " + apiKey)
.addHeader("Content-Type", "application/json")
.build();
return StreamingResponseHandler.forwardSseStream(apiRequest, streamingHttpClient);
} catch (Exception processingError) {
responseEmitter.completeWithError(processingError);
}
return responseEmitter;
}
Alternative Implementation Using Forest Framework
Version requirement: Forest 1.6.x or higher (tested with 1.6.4)
<dependency>
<groupId>com.dtflys.forest</groupId>
<artifactId>forest-spring-boot-starter</artifactId>
<version>1.6.4</version>
</dependency>
Note that setOnMessage operates synchronously. Using the listen() method will buffer all events and emit them at once, defeating the streaming purpose. Switch to asyncListen() for proper streaming behavior.
@GetMapping(value = "/streaming-chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter handleStreamingChat() {
SseEmitter eventEmitter = new SseEmitter(0L);
ForestSSE streamingClient = chatApiClient.initiateStream();
streamingClient
.setOnMessage(event -> {
try {
eventEmitter.send(SseEmitter.event().data(event.data()));
} catch (Exception e) {
eventEmitter.completeWithError(e);
}
})
.setOnClose(event -> eventEmitter.complete())
.asyncListen();
return eventEmitter;
}
If you experience data loss with async listening, implement this synchronized approach:
@GetMapping(value = "/streaming-chat-sync", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter handleSyncStreamingChat() {
SseEmitter eventEmitter = new SseEmitter(0L);
ForestSSE streamingClient = chatApiClient.initiateStream();
streamingClient.setOnMessage(event -> {
try {
System.out.println("Received chunk: " + event.data());
eventEmitter.send(SseEmitter.event().data(event.data()));
} catch (Exception e) {
eventEmitter.completeWithError(e);
}
}).setOnClose(event -> eventEmitter.complete());
CompletableFuture.runAsync(() -> {
streamingClient.listen(SSELinesMode.MULTI_LINES);
});
streamingClient.await();
return eventEmitter;
}