Implementing Server-Sent Events for Streaming Conversational AI Responses

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;
}

Tags: Server-Sent-Events OkHttp3 Forest spring-boot Streaming-API

Posted on Fri, 17 Jul 2026 17:00:49 +0000 by tbone-tw