From 173a45b578f26fd130bc15ed0019092e26841701 Mon Sep 17 00:00:00 2001
From: Arturo Bernal
Date: Mon, 1 Sep 2025 13:17:07 +0200
Subject: [PATCH] HTTPCLIENT-2396 - add Server-Sent Events client to
HttpClient5. EventSource API + SseExecutor, CHAR/BYTE parsers, pluggable
BackoffStrategy, Last-Event-ID and Retry-After.
---
httpclient5-sse/pom.xml | 118 ++++
.../hc/client5/http/sse/BackoffStrategy.java | 57 ++
.../http/sse/ByteSseEntityConsumer.java | 286 ++++++++++
.../client5/http/sse/DefaultEventSource.java | 506 ++++++++++++++++++
.../hc/client5/http/sse/EventSource.java | 131 +++++
.../client5/http/sse/EventSourceConfig.java | 150 ++++++
.../client5/http/sse/EventSourceListener.java | 102 ++++
.../http/sse/ExponentialJitterBackoff.java | 126 +++++
.../http/sse/FixedBackoffStrategy.java | 85 +++
.../client5/http/sse/NoBackoffStrategy.java | 76 +++
.../http/sse/ServerSentEventReader.java | 195 +++++++
.../hc/client5/http/sse/SseCallbacks.java | 67 +++
.../client5/http/sse/SseEntityConsumer.java | 149 ++++++
.../hc/client5/http/sse/SseExecutor.java | 308 +++++++++++
.../client5/http/sse/SseExecutorBuilder.java | 150 ++++++
.../apache/hc/client5/http/sse/SseParser.java | 50 ++
.../client5/http/sse/SseResponseConsumer.java | 157 ++++++
.../hc/client5/http/sse/package-info.java | 131 +++++
.../http/sse/ByteSseEntityConsumerTest.java | 97 ++++
.../DefaultEventSourceIntegrationTest.java | 220 ++++++++
.../sse/ExponentialJitterBackoffTest.java | 59 ++
.../http/sse/NoBackoffStrategyTest.java | 41 ++
.../http/sse/ServerSentEventReaderTest.java | 103 ++++
.../http/sse/SseEntityConsumerTest.java | 87 +++
.../http/sse/SseExecutorBuilderTest.java | 109 ++++
.../http/sse/SseResponseConsumerTest.java | 228 ++++++++
.../http/sse/example/ClientSseExample.java | 191 +++++++
.../sse/example/performance/LogHistogram.java | 96 ++++
.../example/performance/SsePerfClient.java | 366 +++++++++++++
.../example/performance/SsePerfServer.java | 250 +++++++++
.../test/resources/log4j2-debug.xml.template | 34 ++
httpclient5-sse/src/test/resources/log4j2.xml | 29 +
.../DefaultAsyncClientConnectionOperator.java | 2 +-
pom.xml | 6 +
34 files changed, 4761 insertions(+), 1 deletion(-)
create mode 100644 httpclient5-sse/pom.xml
create mode 100644 httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/BackoffStrategy.java
create mode 100644 httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/ByteSseEntityConsumer.java
create mode 100644 httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/DefaultEventSource.java
create mode 100644 httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/EventSource.java
create mode 100644 httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/EventSourceConfig.java
create mode 100644 httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/EventSourceListener.java
create mode 100644 httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/ExponentialJitterBackoff.java
create mode 100644 httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/FixedBackoffStrategy.java
create mode 100644 httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/NoBackoffStrategy.java
create mode 100644 httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/ServerSentEventReader.java
create mode 100644 httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/SseCallbacks.java
create mode 100644 httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/SseEntityConsumer.java
create mode 100644 httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/SseExecutor.java
create mode 100644 httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/SseExecutorBuilder.java
create mode 100644 httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/SseParser.java
create mode 100644 httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/SseResponseConsumer.java
create mode 100644 httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/package-info.java
create mode 100644 httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/ByteSseEntityConsumerTest.java
create mode 100644 httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/DefaultEventSourceIntegrationTest.java
create mode 100644 httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/ExponentialJitterBackoffTest.java
create mode 100644 httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/NoBackoffStrategyTest.java
create mode 100644 httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/ServerSentEventReaderTest.java
create mode 100644 httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/SseEntityConsumerTest.java
create mode 100644 httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/SseExecutorBuilderTest.java
create mode 100644 httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/SseResponseConsumerTest.java
create mode 100644 httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/example/ClientSseExample.java
create mode 100644 httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/example/performance/LogHistogram.java
create mode 100644 httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/example/performance/SsePerfClient.java
create mode 100644 httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/example/performance/SsePerfServer.java
create mode 100644 httpclient5-sse/src/test/resources/log4j2-debug.xml.template
create mode 100644 httpclient5-sse/src/test/resources/log4j2.xml
diff --git a/httpclient5-sse/pom.xml b/httpclient5-sse/pom.xml
new file mode 100644
index 0000000000..2bb708c85b
--- /dev/null
+++ b/httpclient5-sse/pom.xml
@@ -0,0 +1,118 @@
+
+
+ 4.0.0
+
+ org.apache.httpcomponents.client5
+ httpclient5-parent
+ 5.6-alpha1-SNAPSHOT
+
+ httpclient5-sse
+ Apache HttpClient sse
+ 2011
+ Apache HttpComponents Client Server-Sent
+ jar
+
+
+ org.apache.httpcomponents.client5.httpclient5.sse
+
+
+
+
+ org.apache.httpcomponents.client5
+ httpclient5
+
+
+ org.apache.httpcomponents.client5
+ httpclient5
+ test
+ tests
+
+
+ org.slf4j
+ slf4j-api
+
+
+ org.apache.logging.log4j
+ log4j-slf4j-impl
+ test
+
+
+ org.apache.logging.log4j
+ log4j-core
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+ org.hamcrest
+ hamcrest
+ test
+
+
+ org.mockito
+ mockito-core
+ test
+
+
+
+
+
+
+ maven-project-info-reports-plugin
+ false
+
+
+
+ index
+ dependencies
+ dependency-info
+ summary
+
+
+
+
+
+
+
+
+
+
+ com.github.siom79.japicmp
+ japicmp-maven-plugin
+ 0.21.2
+
+ true
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/BackoffStrategy.java b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/BackoffStrategy.java
new file mode 100644
index 0000000000..6dc936081f
--- /dev/null
+++ b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/BackoffStrategy.java
@@ -0,0 +1,57 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse;
+
+/**
+ * Computes the next reconnect delay for SSE (in milliseconds).
+ *
+ * Implementations may also override {@link #shouldReconnect(int, long, Long)}
+ * to decline reconnects entirely (e.g., a "no strategy" that never reconnects).
+ */
+public interface BackoffStrategy {
+
+ /**
+ * @param attempt consecutive reconnect attempt number (1-based)
+ * @param previousDelayMs last delay used (0 for first attempt)
+ * @param serverRetryHintMs value from server 'retry:' (ms) or HTTP Retry-After, or null if none
+ * @return delay in milliseconds (>= 0)
+ */
+ long nextDelayMs(int attempt, long previousDelayMs, Long serverRetryHintMs);
+
+ /**
+ * Whether a reconnect should be attempted at all.
+ * Default is {@code true} for backward compatibility.
+ *
+ * @param attempt consecutive reconnect attempt number (1-based)
+ * @param previousDelayMs last delay used (0 for first attempt)
+ * @param serverRetryHintMs value from server 'retry:' (ms) or HTTP Retry-After, or null if none
+ * @return true to reconnect, false to stop
+ */
+ default boolean shouldReconnect(final int attempt, final long previousDelayMs, final Long serverRetryHintMs) {
+ return true;
+ }
+}
diff --git a/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/ByteSseEntityConsumer.java b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/ByteSseEntityConsumer.java
new file mode 100644
index 0000000000..129b4ee176
--- /dev/null
+++ b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/ByteSseEntityConsumer.java
@@ -0,0 +1,286 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.hc.core5.annotation.Internal;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.nio.entity.AbstractBinAsyncEntityConsumer;
+
+/**
+ * Low-allocation binary consumer for Server-Sent Events (SSE).
+ *
+ *
This consumer parses {@code text/event-stream} responses directly from a
+ * {@link ByteBuffer} without intermediate {@code char[]} conversion. It performs
+ * ASCII field matching in-place, accumulates lines until a blank line is reached,
+ * then emits one logical SSE event via the supplied {@link SseCallbacks}.
+ *
+ * Behavior
+ *
+ * - Validates {@code Content-Type} equals {@code text/event-stream}
+ * in {@link #streamStart(ContentType)}; otherwise throws {@link HttpException}.
+ * - Strips a UTF-8 BOM if present in the first chunk.
+ * - Accepts LF and CRLF line endings; tolerates CRLF split across buffers.
+ * - Implements WHATWG SSE fields: {@code data}, {@code id}, {@code event}, {@code retry}.
+ * Unknown fields and malformed {@code retry} values are ignored.
+ * - At end of stream, flushes any partially accumulated line and forces a final
+ * dispatch of the current event if it has data.
+ *
+ *
+ * Thread-safety
+ * Instances are not thread-safe and are intended to be used by a single I/O thread
+ * per HTTP message, as per {@link AbstractBinAsyncEntityConsumer} contract.
+ *
+ * Internal: this type is not part of the public API and may change
+ * without notice.
+ *
+ */
+@Internal
+final class ByteSseEntityConsumer extends AbstractBinAsyncEntityConsumer {
+
+ private static final byte LF = (byte) '\n';
+ private static final byte CR = (byte) '\r';
+ private static final byte COLON = (byte) ':';
+ private static final byte SPACE = (byte) ' ';
+
+ private final SseCallbacks cb;
+
+ // line accumulator
+ private byte[] lineBuf = new byte[256];
+ private int lineLen = 0;
+
+ // event accumulator
+ private final StringBuilder data = new StringBuilder(256);
+ private String id;
+ private String type; // defaults to "message"
+
+ // Robust BOM skipper (works across multiple chunks)
+ // Matches 0xEF 0xBB 0xBF at the very beginning of the stream
+ private int bomMatched = 0; // 0..3 bytes matched so far
+ private boolean bomDone = false; // once true, no further BOM detection
+
+ ByteSseEntityConsumer(final SseCallbacks callbacks) {
+ this.cb = callbacks;
+ }
+
+ @Override
+ protected void streamStart(final ContentType contentType) throws HttpException, IOException {
+ final String mt = contentType != null ? contentType.getMimeType() : null;
+ if (!"text/event-stream".equalsIgnoreCase(mt)) {
+ throw new HttpException("Unexpected Content-Type: " + mt);
+ }
+ cb.onOpen();
+ }
+
+ @Override
+ protected void data(final ByteBuffer src, final boolean endOfStream) {
+ if (!bomDone) {
+ while (src.hasRemaining() && bomMatched < 3) {
+ final int expected = (bomMatched == 0) ? 0xEF : (bomMatched == 1 ? 0xBB : 0xBF);
+ final int b = src.get() & 0xFF;
+ if (b == expected) {
+ bomMatched++;
+ if (bomMatched == 3) {
+ // Full BOM consumed, mark as done and proceed
+ bomDone = true;
+ }
+ continue;
+ }
+ if (bomMatched > 0) {
+ appendByte((byte) 0xEF);
+ if (bomMatched >= 2) {
+ appendByte((byte) 0xBB);
+ }
+ }
+ appendByte((byte) b);
+ bomMatched = 0;
+ bomDone = true;
+ break; // drop into normal loop below for the rest of 'src'
+ }
+ if (!bomDone && !src.hasRemaining()) {
+ if (endOfStream) {
+ flushEndOfStream();
+ }
+ return;
+ }
+ }
+
+ while (src.hasRemaining()) {
+ final byte b = src.get();
+ if (b == LF) {
+ int len = lineLen;
+ if (len > 0 && lineBuf[len - 1] == CR) {
+ len--;
+ }
+ handleLine(lineBuf, len);
+ lineLen = 0;
+ } else {
+ appendByte(b);
+ }
+ }
+
+ if (endOfStream) {
+ flushEndOfStream();
+ }
+ }
+
+ private void flushEndOfStream() {
+ if (lineLen > 0) {
+ int len = lineLen;
+ if (lineBuf[len - 1] == CR) {
+ len--;
+ }
+ handleLine(lineBuf, len);
+ lineLen = 0;
+ }
+ handleLine(lineBuf, 0);
+ }
+
+ private void appendByte(final byte b) {
+ ensureCapacity(lineLen + 1);
+ lineBuf[lineLen++] = b;
+ }
+
+ @Override
+ protected int capacityIncrement() {
+ return 8192;
+ }
+
+ @Override
+ protected Void generateContent() {
+ return null;
+ }
+
+ @Override
+ public void releaseResources() {
+ lineBuf = new byte[0];
+ data.setLength(0);
+ id = null;
+ type = null;
+ bomMatched = 0;
+ bomDone = false;
+ }
+
+ private void handleLine(final byte[] buf, final int len) {
+ if (len == 0) {
+ dispatch();
+ return;
+ }
+ if (buf[0] == (byte) ':') {
+ // comment -> ignore
+ return;
+ }
+ int colon = -1;
+ for (int i = 0; i < len; i++) {
+ if (buf[i] == COLON) {
+ colon = i;
+ break;
+ }
+ }
+ final int fEnd = colon >= 0 ? colon : len;
+ int vStart = colon >= 0 ? colon + 1 : len;
+ if (vStart < len && buf[vStart] == SPACE) {
+ vStart++;
+ }
+
+ final int fLen = fEnd; // since field starts at 0
+
+ // Compare ASCII field name without allocations
+ if (fLen == 4 && buf[0] == 'd' && buf[1] == 'a' && buf[2] == 't' && buf[3] == 'a') {
+ final String v = new String(buf, vStart, len - vStart, StandardCharsets.UTF_8);
+ data.append(v).append('\n');
+ } else if (fLen == 5 && buf[0] == 'e' && buf[1] == 'v' && buf[2] == 'e' && buf[3] == 'n' && buf[4] == 't') {
+ type = new String(buf, vStart, len - vStart, StandardCharsets.UTF_8);
+ } else if (fLen == 2 && buf[0] == 'i' && buf[1] == 'd') {
+ // ignore if contains NUL per spec
+ boolean hasNul = false;
+ for (int i = vStart; i < len; i++) {
+ if (buf[i] == 0) {
+ hasNul = true;
+ break;
+ }
+ }
+ if (!hasNul) {
+ id = new String(buf, vStart, len - vStart, StandardCharsets.UTF_8);
+ }
+ } else if (fLen == 5 && buf[0] == 'r' && buf[1] == 'e' && buf[2] == 't' && buf[3] == 'r' && buf[4] == 'y') {
+ final long retry = parseLongAscii(buf, vStart, len - vStart);
+ if (retry >= 0) {
+ cb.onRetry(retry);
+ }
+ }
+ }
+
+ private void dispatch() {
+ if (data.length() == 0) {
+ type = null;
+ return;
+ }
+ final int n = data.length();
+ if (n > 0 && data.charAt(n - 1) == '\n') {
+ data.setLength(n - 1);
+ }
+ cb.onEvent(id, type != null ? type : "message", data.toString());
+ data.setLength(0);
+ type = null; // id persists
+ }
+
+ private void ensureCapacity(final int cap) {
+ if (cap <= lineBuf.length) {
+ return;
+ }
+ int n = lineBuf.length << 1;
+ if (n < cap) {
+ n = cap;
+ }
+ final byte[] nb = new byte[n];
+ System.arraycopy(lineBuf, 0, nb, 0, lineLen);
+ lineBuf = nb;
+ }
+
+ private static long parseLongAscii(final byte[] arr, final int off, final int len) {
+ if (len <= 0) {
+ return -1L;
+ }
+ long v = 0L;
+ for (int i = 0; i < len; i++) {
+ final int d = arr[off + i] - '0';
+ if (d < 0 || d > 9) {
+ return -1L;
+ }
+ v = v * 10L + d;
+ if (v < 0) {
+ return -1L;
+ }
+ }
+ return v;
+ }
+}
diff --git a/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/DefaultEventSource.java b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/DefaultEventSource.java
new file mode 100644
index 0000000000..37662d2c79
--- /dev/null
+++ b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/DefaultEventSource.java
@@ -0,0 +1,506 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse;
+
+import static org.apache.hc.core5.http.ContentType.TEXT_EVENT_STREAM;
+
+import java.net.URI;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Future;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
+import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
+import org.apache.hc.client5.http.async.methods.SimpleRequestProducer;
+import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
+import org.apache.hc.core5.annotation.Internal;
+import org.apache.hc.core5.concurrent.DefaultThreadFactory;
+import org.apache.hc.core5.concurrent.FutureCallback;
+import org.apache.hc.core5.http.ConnectionClosedException;
+import org.apache.hc.core5.http.HttpHeaders;
+import org.apache.hc.core5.http.RequestNotExecutedException;
+import org.apache.hc.core5.http.nio.AsyncEntityConsumer;
+import org.apache.hc.core5.http.nio.AsyncResponseConsumer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Default {@link EventSource} implementation that manages the SSE connection lifecycle:
+ * establishing the connection, parsing events, handling failures, and performing
+ * bounded, policy-driven reconnects.
+ *
+ * Key responsibilities:
+ *
+ * - Builds and executes an HTTP GET with {@code Accept: text/event-stream}.
+ * - Parses SSE using either a char-based or byte-based parser as configured
+ * by {@link SseParser}.
+ * - Tracks {@code Last-Event-ID} and forwards events to the user listener
+ * on a caller-provided or inline executor.
+ * - Applies {@link BackoffStrategy} with optional server-provided hints
+ * ({@code retry:} field and {@code Retry-After} header) to schedule reconnects.
+ * - Honors a maximum reconnect count and emits {@link EventSourceListener#onClosed()}
+ * exactly once at the end of the lifecycle.
+ *
+ *
+ * Thread-safety
+ * Instances are safe for typical usage: public methods are idempotent and guarded by atomics.
+ * Callbacks are dispatched on {@code callbackExecutor} (inline by default) and must not block.
+ *
+ * Internal: this class is not part of the public API and can change without notice.
+ *
+ * @since 5.6
+ */
+@Internal
+final class DefaultEventSource implements EventSource {
+
+ private static final Logger LOG = LoggerFactory.getLogger(DefaultEventSource.class);
+
+ /**
+ * Scalable shared scheduler used when callers do not provide their own.
+ * Uses a small daemon pool; canceled tasks are removed to reduce heap churn.
+ */
+ private static final ScheduledExecutorService SHARED_SCHED;
+
+ static {
+ final int nThreads = Math.max(2, Math.min(8, Runtime.getRuntime().availableProcessors()));
+ final ScheduledThreadPoolExecutor exec = new ScheduledThreadPoolExecutor(
+ nThreads, new DefaultThreadFactory("hc-sse", true));
+ exec.setRemoveOnCancelPolicy(true);
+ exec.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
+ SHARED_SCHED = exec;
+ }
+
+ private final CloseableHttpAsyncClient client;
+ private final URI uri;
+ private final Map headers;
+ private final EventSourceListener listener;
+
+ private final ScheduledExecutorService scheduler;
+ private final boolean ownScheduler;
+ private final Executor callbackExecutor;
+ private final BackoffStrategy backoff;
+ private final int maxReconnects;
+ private final SseParser parser;
+
+ private final AtomicBoolean started = new AtomicBoolean(false);
+ private final AtomicBoolean cancelled = new AtomicBoolean(false);
+ private final AtomicBoolean closedOnce = new AtomicBoolean(false);
+ private final AtomicBoolean connected = new AtomicBoolean(false);
+
+ private volatile String lastEventId;
+ /**
+ * Sticky retry from SSE {@code retry:} field (ms); {@code -1} if not set.
+ */
+ private volatile long stickyRetryMs = -1L;
+ /**
+ * One-shot hint from HTTP {@code Retry-After} (ms); {@code -1} if absent.
+ */
+ private volatile long retryAfterHintMs = -1L;
+
+ private final AtomicInteger attempts = new AtomicInteger(0);
+ private volatile long previousDelayMs = 0L;
+ private volatile Future> inFlight;
+
+ /**
+ * Creates a new {@code DefaultEventSource} using the shared scheduler, inline callback execution,
+ * default config, and char-based parser.
+ *
+ * @param client non-null async client
+ * @param uri non-null SSE endpoint
+ * @param headers initial headers (copied)
+ * @param listener listener to receive events; may be {@code null} for a no-op
+ */
+ DefaultEventSource(final CloseableHttpAsyncClient client,
+ final URI uri,
+ final Map headers,
+ final EventSourceListener listener) {
+ this(client, uri, headers, listener, null, null, null, SseParser.CHAR);
+ }
+
+ /**
+ * Creates a new {@code DefaultEventSource} with full control over scheduling, callback dispatch,
+ * reconnect policy, and parser selection.
+ *
+ * @param client non-null async client
+ * @param uri non-null SSE endpoint
+ * @param headers initial headers (copied)
+ * @param listener listener to receive events; may be {@code null} for a no-op
+ * @param scheduler optional scheduler; if {@code null}, a shared pool is used
+ * @param callbackExecutor optional executor for listener callbacks; if {@code null}, runs inline
+ * @param config optional configuration; if {@code null}, {@link EventSourceConfig#DEFAULT} is used
+ * @param parser parser strategy ({@link SseParser#CHAR} or {@link SseParser#BYTE}); defaults to CHAR if {@code null}
+ */
+ DefaultEventSource(final CloseableHttpAsyncClient client,
+ final URI uri,
+ final Map headers,
+ final EventSourceListener listener,
+ final ScheduledExecutorService scheduler,
+ final Executor callbackExecutor,
+ final EventSourceConfig config,
+ final SseParser parser) {
+ this.client = Objects.requireNonNull(client, "client");
+ this.uri = Objects.requireNonNull(uri, "uri");
+ this.headers = new ConcurrentHashMap<>(Objects.requireNonNull(headers, "headers"));
+ this.listener = listener != null ? listener : (id, type, data) -> { /* no-op */ };
+
+ if (scheduler != null) {
+ this.scheduler = scheduler;
+ this.ownScheduler = scheduler != SHARED_SCHED;
+ } else {
+ this.scheduler = SHARED_SCHED;
+ this.ownScheduler = false;
+ }
+
+ this.callbackExecutor = callbackExecutor != null ? callbackExecutor : Runnable::run;
+
+ final EventSourceConfig cfg = (config != null) ? config : EventSourceConfig.DEFAULT;
+ this.backoff = cfg.backoff;
+ this.maxReconnects = cfg.maxReconnects;
+ this.parser = parser != null ? parser : SseParser.CHAR;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Idempotent. Resets retry state and attempts the first connection immediately.
+ *
+ */
+ @Override
+ public void start() {
+ if (started.compareAndSet(false, true)) {
+ attempts.set(0);
+ previousDelayMs = 0;
+ connect(0L);
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Idempotent. Cancels any in-flight exchange, shuts down the owned scheduler (if any),
+ * and ensures {@link EventSourceListener#onClosed()} is invoked exactly once.
+ *
+ */
+ @Override
+ public void cancel() {
+ final Future> f = inFlight;
+ if (f != null) {
+ f.cancel(true);
+ }
+ if (cancelled.compareAndSet(false, true)) {
+ connected.set(false);
+ if (ownScheduler) {
+ try {
+ scheduler.shutdownNow();
+ } catch (final Exception ignore) {
+ }
+ }
+ notifyClosedOnce();
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public String lastEventId() {
+ return lastEventId;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void setLastEventId(final String id) {
+ this.lastEventId = id;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void setHeader(final String name, final String value) {
+ headers.put(name, value);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void removeHeader(final String name) {
+ headers.remove(name);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Map getHeaders() {
+ return new ConcurrentHashMap<>(headers);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean isConnected() {
+ return connected.get();
+ }
+
+ /**
+ * Schedules or immediately performs a connection attempt.
+ *
+ * @param delayMs delay in milliseconds; non-positive runs immediately
+ */
+ private void connect(final long delayMs) {
+ if (cancelled.get()) {
+ return;
+ }
+ final Runnable task = this::doConnect;
+ try {
+ if (delayMs <= 0L) {
+ task.run();
+ } else {
+ scheduler.schedule(task, delayMs, TimeUnit.MILLISECONDS);
+ }
+ } catch (final RejectedExecutionException e) {
+ if (!cancelled.get()) {
+ dispatch(() -> listener.onFailure(e, false));
+ notifyClosedOnce();
+ }
+ }
+ }
+
+ /**
+ * Builds the request, installs the response consumer, and executes the exchange.
+ *
+ * Completion/failure callbacks determine whether to reconnect based on
+ * {@link #willReconnectNext()} and {@link #scheduleReconnect()}.
+ *
+ */
+ private void doConnect() {
+ if (cancelled.get()) {
+ return;
+ }
+
+ final SimpleRequestBuilder rb = SimpleRequestBuilder.get(uri);
+ rb.setHeader(HttpHeaders.ACCEPT, TEXT_EVENT_STREAM.getMimeType());
+ rb.setHeader(HttpHeaders.CACHE_CONTROL, "no-cache");
+ if (lastEventId != null) {
+ rb.setHeader("Last-Event-ID", lastEventId);
+ }
+ for (final Map.Entry e : headers.entrySet()) {
+ rb.setHeader(e.getKey(), e.getValue());
+ }
+ final SimpleHttpRequest req = rb.build();
+
+ final AsyncResponseConsumer consumer = getAsyncResponseConsumer();
+
+ inFlight = client.execute(SimpleRequestProducer.create(req), consumer, new FutureCallback() {
+ @Override
+ public void completed(final Void v) {
+ connected.set(false);
+ if (cancelled.get()) {
+ notifyClosedOnce();
+ return;
+ }
+ if (willReconnectNext()) {
+ scheduleReconnect();
+ } else {
+ notifyClosedOnce();
+ }
+ }
+
+ @Override
+ public void failed(final Exception ex) {
+ connected.set(false);
+ if (cancelled.get() || isBenignCancel(ex)) {
+ notifyClosedOnce();
+ return;
+ }
+ final boolean will = willReconnectNext();
+ dispatch(() -> listener.onFailure(ex, will));
+ if (will) {
+ scheduleReconnect();
+ } else {
+ notifyClosedOnce();
+ }
+ }
+
+ @Override
+ public void cancelled() {
+ connected.set(false);
+ notifyClosedOnce();
+ }
+ });
+ }
+
+ /**
+ * Creates the {@link AsyncResponseConsumer} chain for SSE, selecting the low-level
+ * entity consumer per {@link SseParser} and capturing {@code Retry-After} hints.
+ *
+ * @return response consumer that feeds parsed events into the listener
+ */
+ private AsyncResponseConsumer getAsyncResponseConsumer() {
+ final SseCallbacks cbs = new SseCallbacks() {
+ @Override
+ public void onOpen() {
+ connected.set(true);
+ attempts.set(0);
+ previousDelayMs = 0;
+ dispatch(listener::onOpen);
+ }
+
+ @Override
+ public void onEvent(final String id, final String type, final String data) {
+ if (id != null) {
+ lastEventId = id;
+ }
+ dispatch(() -> listener.onEvent(id, type, data));
+ }
+
+ @Override
+ public void onRetry(final long retryMs) {
+ stickyRetryMs = Math.max(0L, retryMs);
+ }
+ };
+
+ final AsyncEntityConsumer entity =
+ (parser == SseParser.BYTE) ? new ByteSseEntityConsumer(cbs)
+ : new SseEntityConsumer(cbs);
+
+ return new SseResponseConsumer(entity, ms -> retryAfterHintMs = Math.max(0L, ms));
+ }
+
+ /**
+ * Decides whether a subsequent reconnect should be attempted, without mutating state.
+ *
+ * Respects {@code maxReconnects}. Delegates to {@link BackoffStrategy#shouldReconnect(int, long, Long)}.
+ * If the strategy throws, the method returns {@code false} to avoid spin.
+ *
+ * @return {@code true} if a reconnect should be attempted
+ */
+ private boolean willReconnectNext() {
+ if (cancelled.get()) {
+ return false;
+ }
+ if (maxReconnects >= 0 && attempts.get() >= maxReconnects) {
+ return false;
+ }
+
+ final int nextAttempt = attempts.get() + 1;
+ final Long hint = (retryAfterHintMs >= 0L) ? Long.valueOf(retryAfterHintMs)
+ : (stickyRetryMs >= 0L ? stickyRetryMs : null);
+ boolean decision;
+ try {
+ decision = backoff.shouldReconnect(nextAttempt, previousDelayMs, hint);
+ } catch (final RuntimeException rex) {
+ // be conservative: if strategy blew up, do not spin forever
+ LOG.warn("BackoffStrategy.shouldReconnect threw: {}; stopping reconnects", rex.toString());
+ decision = false;
+ }
+ return decision;
+ }
+
+ /**
+ * Computes the next delay using the {@link BackoffStrategy} and schedules a reconnect.
+ *
+ * Consumes the one-shot {@code Retry-After} hint if present; the SSE {@code retry:}
+ * hint remains sticky until overridden by the server.
+ *
+ */
+ private void scheduleReconnect() {
+ if (!willReconnectNext()) {
+ notifyClosedOnce();
+ return;
+ }
+ final int attempt = attempts.incrementAndGet();
+ final Long hint = (retryAfterHintMs >= 0L) ? Long.valueOf(retryAfterHintMs)
+ : (stickyRetryMs >= 0L ? stickyRetryMs : null);
+ long d;
+ try {
+ d = backoff.nextDelayMs(attempt, previousDelayMs, hint);
+ } catch (final RuntimeException rex) {
+ LOG.warn("BackoffStrategy.nextDelayMs threw: {}; defaulting to 1000ms", rex.toString());
+ d = 1000L;
+ }
+ previousDelayMs = Math.max(0L, d);
+ retryAfterHintMs = -1L; // one-shot hint consumed
+ connect(previousDelayMs);
+ }
+
+ /**
+ * Dispatches a listener task using the configured executor, falling back
+ * to the caller thread if submission fails.
+ *
+ * @param r task to run
+ */
+ private void dispatch(final Runnable r) {
+ try {
+ callbackExecutor.execute(r);
+ } catch (final RuntimeException e) {
+ try {
+ r.run();
+ } catch (final Exception ex) {
+ LOG.error("EventSource listener failed after submit failure: {}", ex, ex);
+
+ }
+ }
+ }
+
+ /**
+ * Ensures {@link EventSourceListener#onClosed()} is invoked at most once.
+ *
+ */
+ private void notifyClosedOnce() {
+ if (closedOnce.compareAndSet(false, true)) {
+ connected.set(false);
+ dispatch(listener::onClosed);
+ }
+ }
+
+ /**
+ * Returns {@code true} for failure types that are expected during cancel/close.
+ *
+ * @param ex the exception to inspect
+ * @return {@code true} if the exception represents a benign cancellation
+ */
+ private static boolean isBenignCancel(final Exception ex) {
+ return ex instanceof RequestNotExecutedException
+ || ex instanceof ConnectionClosedException
+ || ex instanceof java.util.concurrent.CancellationException
+ || ex instanceof java.io.InterruptedIOException;
+ }
+}
diff --git a/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/EventSource.java b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/EventSource.java
new file mode 100644
index 0000000000..6171e78cc3
--- /dev/null
+++ b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/EventSource.java
@@ -0,0 +1,131 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse;
+
+import java.util.Map;
+
+/**
+ * Represents a Server-Sent Events (SSE) connection to a remote resource.
+ *
+ * This interface exposes the minimal control surface for an SSE stream:
+ * starting, cancelling, header management, and basic state and offset (Last-Event-ID).
+ * Implementations are provided by this module (for example, via
+ * a factory such as {@code SseExecutor#open(...)}), which wires the
+ * {@link EventSourceListener} that receives events.
+ *
+ * Thread-safety
+ * Implementations should be safe for concurrent use: {@link #start()} and
+ * {@link #cancel()} are expected to be idempotent, and header methods should be
+ * safe to call at any time prior to (re)connect.
+ *
+ * Last-Event-ID
+ * {@code Last-Event-ID} follows the SSE spec: the most recent non-null event
+ * id is remembered by the implementation and can be sent on subsequent reconnects
+ * so the server can resume the stream.
+ *
+ * @since 5.6
+ */
+public interface EventSource {
+
+ /**
+ * Begins streaming events.
+ *
+ * - Idempotent: calling when already started is a no-op.
+ * - Non-blocking: returns immediately.
+ * - Reconnects: implementation-specific and driven by configuration.
+ *
+ *
+ * @since 5.6
+ */
+ void start();
+
+ /**
+ * Cancels the stream and prevents further reconnects.
+ *
+ * - Idempotent: safe to call multiple times.
+ * - Implementations should eventually invoke the listener’s
+ * {@code onClosed()} exactly once.
+ *
+ *
+ * @since 5.6
+ */
+ void cancel();
+
+ /**
+ * Returns the last seen event id or {@code null} if none has been observed.
+ *
+ * @return the last event id, or {@code null}
+ * @since 5.6
+ */
+ String lastEventId();
+
+ /**
+ * Sets the outbound {@code Last-Event-ID} that will be sent on the next connect
+ * or reconnect attempt. Passing {@code null} clears the value.
+ *
+ * @param id the id to send, or {@code null} to clear
+ * @since 5.6
+ */
+ void setLastEventId(String id);
+
+ /**
+ * Sets or replaces a request header for subsequent (re)connects.
+ * Header names are case-insensitive per RFC 7230/9110.
+ *
+ * @param name header name (non-null)
+ * @param value header value (may be empty but not null)
+ * @since 5.6
+ */
+ void setHeader(String name, String value);
+
+ /**
+ * Removes a previously set request header for subsequent (re)connects.
+ *
+ * @param name header name to remove (non-null)
+ * @since 5.6
+ */
+ void removeHeader(String name);
+
+ /**
+ * Returns a snapshot of the currently configured headers that will be used
+ * for the next request. The returned map is a copy and may be modified
+ * by the caller without affecting the {@code EventSource}.
+ *
+ * @return copy of headers to be sent
+ * @since 5.6
+ */
+ Map getHeaders();
+
+ /**
+ * Indicates whether the SSE connection is currently open.
+ * Implementations may report {@code false} during backoff between reconnects.
+ *
+ * @return {@code true} if the transport is open and events may be received
+ * @since 5.6
+ */
+ boolean isConnected();
+}
diff --git a/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/EventSourceConfig.java b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/EventSourceConfig.java
new file mode 100644
index 0000000000..978f548217
--- /dev/null
+++ b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/EventSourceConfig.java
@@ -0,0 +1,150 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse;
+
+import java.util.Objects;
+
+/**
+ * Immutable configuration for {@link EventSource} behavior, primarily covering
+ * reconnect policy and limits.
+ *
+ * Use {@link #builder()} to create instances. If you do not provide a config,
+ * implementations will typically use {@link #DEFAULT}.
+ *
+ * Fields
+ *
+ * - {@link #backoff}: strategy that decides if/when to reconnect, and the delay between attempts.
+ * The strategy can also honor server hints (SSE {@code retry:} field or HTTP {@code Retry-After}).
+ * - {@link #maxReconnects}: maximum number of reconnect attempts before giving up.
+ * A value of {@code -1} means unlimited attempts.
+ *
+ *
+ * Thread-safety
+ * Instances are immutable and thread-safe.
+ *
+ * @since 5.6
+ */
+public final class EventSourceConfig {
+
+ /**
+ * Reconnect decision and delay computation.
+ *
+ * See {@link BackoffStrategy} for the contract. The default is
+ * {@link ExponentialJitterBackoff} with base=1000 ms, max=30000 ms, factor=2.0, min=250 ms.
+ *
+ * @since 5.6
+ */
+ public final BackoffStrategy backoff;
+
+ /**
+ * Maximum number of reconnect attempts.
+ *
+ * - {@code -1}: unlimited reconnects.
+ * - {@code 0}: never reconnect.
+ * - {@code >0}: number of attempts after the initial connect.
+ *
+ *
+ * @since 5.6
+ */
+ public final int maxReconnects;
+
+ /**
+ * Default configuration:
+ *
+ * - {@link #backoff} = {@code new ExponentialJitterBackoff(1000, 30000, 2.0, 250)}
+ * - {@link #maxReconnects} = {@code -1} (unlimited)
+ *
+ *
+ * @since 5.6
+ */
+ public static final EventSourceConfig DEFAULT = builder().build();
+
+ private EventSourceConfig(final BackoffStrategy backoff, final int maxReconnects) {
+ this.backoff = backoff;
+ this.maxReconnects = maxReconnects;
+ }
+
+ /**
+ * Creates a new builder initialized with sensible defaults.
+ *
+ * @return a new {@link Builder}
+ * @since 5.6
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Builder for {@link EventSourceConfig}.
+ *
+ * Not thread-safe.
+ *
+ * @since 5.6
+ */
+ public static final class Builder {
+ private BackoffStrategy backoff =
+ new ExponentialJitterBackoff(1_000L, 30_000L, 2.0, 250L);
+ private int maxReconnects = -1;
+
+ /**
+ * Sets the reconnect/backoff strategy.
+ *
+ * @param backoff non-null strategy implementation (e.g., {@link ExponentialJitterBackoff}
+ * or a custom {@link BackoffStrategy})
+ * @return this builder
+ * @since 5.6
+ */
+ public Builder backoff(final BackoffStrategy backoff) {
+ this.backoff = Objects.requireNonNull(backoff, "backoff");
+ return this;
+ }
+
+ /**
+ * Sets the maximum number of reconnect attempts.
+ *
+ * Use {@code -1} for unlimited; {@code 0} to disable reconnects.
+ *
+ * @param nmaxReconnects max attempts
+ * @return this builder
+ * @since 5.6
+ */
+ public Builder maxReconnects(final int nmaxReconnects) {
+ this.maxReconnects = nmaxReconnects;
+ return this;
+ }
+
+ /**
+ * Builds an immutable {@link EventSourceConfig}.
+ *
+ * @return the config
+ * @since 5.6
+ */
+ public EventSourceConfig build() {
+ return new EventSourceConfig(backoff, maxReconnects);
+ }
+ }
+}
diff --git a/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/EventSourceListener.java b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/EventSourceListener.java
new file mode 100644
index 0000000000..a8ee5440c0
--- /dev/null
+++ b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/EventSourceListener.java
@@ -0,0 +1,102 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse;
+
+/**
+ * Callback interface for receiving Server-Sent Events (SSE) and lifecycle
+ * notifications from an {@link EventSource}.
+ *
+ * Implementations should keep handlers lightweight and non-blocking.
+ * If you need to do heavy work, offload to your own executor.
+ *
+ * Invocation & threading
+ *
+ * - Methods may be invoked on an internal callback executor supplied to the
+ * {@code EventSource} (or on the caller thread if none was supplied).
+ * Do not assume a specific thread.
+ * - Handlers may be invoked concurrently; make your listener thread-safe.
+ * - Exceptions thrown by handlers are caught and logged by the caller; they
+ * do not stop the stream.
+ *
+ *
+ * Event semantics
+ *
+ * - {@link #onOpen()} is called once the HTTP response is accepted and the
+ * SSE stream is ready.
+ * - {@link #onEvent(String, String, String)} is called for each SSE event.
+ * The {@code type} defaults to {@code "message"} when {@code null}.
+ * - {@link #onFailure(Throwable, boolean)} is called when the stream fails.
+ * If {@code willReconnect} is {@code true}, a reconnect attempt has been scheduled.
+ * - {@link #onClosed()} is called exactly once when the stream is permanently
+ * closed (either by {@link EventSource#cancel()} or after giving up on reconnects).
+ *
+ *
+ * @since 5.6
+ */
+@FunctionalInterface
+public interface EventSourceListener {
+
+ /**
+ * Called for each SSE event received.
+ *
+ * @param id the event id, or {@code null} if not present
+ * @param type the event type, or {@code null} (treat as {@code "message"})
+ * @param data the event data (never {@code null})
+ * @since 5.6
+ */
+ void onEvent(String id, String type, String data);
+
+ /**
+ * Called when the SSE stream is opened and ready to receive events.
+ *
+ * @since 5.6
+ */
+ default void onOpen() {
+ }
+
+ /**
+ * Called once when the stream is permanently closed (no further reconnects).
+ *
+ * @since 5.6
+ */
+ default void onClosed() {
+ }
+
+ /**
+ * Called when the stream fails.
+ *
+ * If {@code willReconnect} is {@code true}, the implementation has scheduled
+ * a reconnect attempt according to the configured {@link EventSourceConfig} and
+ * {@link BackoffStrategy}.
+ *
+ * @param t the failure cause (never {@code null})
+ * @param willReconnect {@code true} if a reconnect has been scheduled
+ * @since 5.6
+ */
+ default void onFailure(final Throwable t, final boolean willReconnect) {
+ }
+}
diff --git a/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/ExponentialJitterBackoff.java b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/ExponentialJitterBackoff.java
new file mode 100644
index 0000000000..514a04c199
--- /dev/null
+++ b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/ExponentialJitterBackoff.java
@@ -0,0 +1,126 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse;
+
+import java.util.concurrent.ThreadLocalRandom;
+
+/**
+ * Backoff strategy that computes exponential delays with full jitter and
+ * honors server retry hints when provided.
+ *
+ * Algorithm (when no server hint is present):
+ *
+ * - Compute the exponential cap: {@code cap = clamp(baseMs * factor^(attempt-1))}.
+ * - Pick a uniformly distributed random value in {@code [0, cap]} (full jitter).
+ * - Clamp the result into {@code [minMs, maxMs]}.
+ *
+ *
+ * Server hints:
+ *
+ * - If {@code serverRetryHintMs} is non-{@code null}, that value is used directly
+ * (clamped to {@code [minMs, maxMs]}), ignoring the exponential step.
+ *
+ *
+ * This strategy is stateless and thread-safe.
+ *
+ * @since 5.6
+ */
+public final class ExponentialJitterBackoff implements BackoffStrategy {
+
+ /**
+ * Base delay (milliseconds) used for the first attempt. Must be ≥ 1.
+ */
+ private final long baseMs;
+
+ /**
+ * Maximum delay (milliseconds). Always ≥ {@link #baseMs}.
+ */
+ private final long maxMs;
+
+ /**
+ * Minimum delay (milliseconds). Always ≥ 0.
+ */
+ private final long minMs;
+
+ /**
+ * Exponential factor. Must be ≥ 1.0.
+ */
+ private final double factor;
+
+ /**
+ * Creates a new exponential+jitter backoff strategy.
+ *
+ * @param baseMs base delay in milliseconds for attempt 1 (will be coerced to ≥ 1)
+ * @param maxMs maximum delay in milliseconds (will be coerced to ≥ baseMs)
+ * @param factor exponential growth factor (will be coerced to ≥ 1.0)
+ * @param minMs minimum delay in milliseconds (will be coerced to ≥ 0)
+ * @since 5.6
+ */
+ public ExponentialJitterBackoff(final long baseMs, final long maxMs, final double factor, final long minMs) {
+ this.baseMs = Math.max(1, baseMs);
+ this.maxMs = Math.max(this.baseMs, maxMs);
+ this.factor = Math.max(1.0, factor);
+ this.minMs = Math.max(0, minMs);
+ }
+
+ /**
+ * Computes the next reconnect delay in milliseconds.
+ *
+ * If {@code serverRetryHintMs} is non-{@code null}, that value wins (after clamping).
+ * Otherwise the delay is drawn uniformly at random from {@code [0, cap]}, where
+ * {@code cap = clamp(min(maxMs, round(baseMs * factor^(attempt-1))))}.
+ *
+ * Notes:
+ *
+ * - {@code attempt} is 1-based. Values < 1 are treated as 1.
+ * - {@code previousDelayMs} is accepted for API symmetry but not used by this strategy.
+ * - The returned value is always in {@code [minMs, maxMs]}.
+ *
+ *
+ * @param attempt consecutive reconnect attempt number (1-based)
+ * @param previousDelayMs last delay used (ignored by this implementation)
+ * @param serverRetryHintMs value from server {@code retry:} (ms) or HTTP {@code Retry-After}
+ * converted to ms; {@code null} if none
+ * @return delay in milliseconds (≥ 0)
+ * @since 5.6
+ */
+ @Override
+ public long nextDelayMs(final int attempt, final long previousDelayMs, final Long serverRetryHintMs) {
+ if (serverRetryHintMs != null) {
+ return clamp(serverRetryHintMs);
+ }
+ final int a = Math.max(1, attempt);
+ final double exp = Math.pow(factor, a - 1);
+ final long cap = clamp(Math.min(maxMs, Math.round(baseMs * exp)));
+ final long jitter = ThreadLocalRandom.current().nextLong(cap + 1L); // full jitter in [0, cap]
+ return Math.max(minMs, jitter);
+ }
+
+ private long clamp(final long x) {
+ return Math.max(minMs, Math.min(maxMs, x));
+ }
+}
diff --git a/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/FixedBackoffStrategy.java b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/FixedBackoffStrategy.java
new file mode 100644
index 0000000000..105ff6026b
--- /dev/null
+++ b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/FixedBackoffStrategy.java
@@ -0,0 +1,85 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse;
+
+/**
+ * Backoff strategy that uses a fixed delay between reconnect attempts and
+ * honors server-provided retry hints when present.
+ *
+ * If the server supplies a hint (via the SSE {@code retry:} field or an HTTP
+ * {@code Retry-After} header converted to milliseconds), that value is used
+ * for the next delay. Otherwise, the constant delay configured at construction
+ * time is returned.
+ *
+ * Characteristics
+ *
+ * - Immutable and thread-safe.
+ * - Ignores {@code attempt} and {@code previousDelayMs} (kept for API symmetry).
+ * - Negative inputs are coerced to {@code 0} ms.
+ *
+ *
+ * @see BackoffStrategy
+ * @see EventSourceConfig
+ * @since 5.6
+ */
+public final class FixedBackoffStrategy implements BackoffStrategy {
+
+ /**
+ * Constant delay (milliseconds) to use when no server hint is present.
+ */
+ private final long delayMs;
+
+ /**
+ * Creates a fixed-delay backoff strategy.
+ *
+ * @param delayMs constant delay in milliseconds (negative values are coerced to {@code 0})
+ * @since 5.6
+ */
+ public FixedBackoffStrategy(final long delayMs) {
+ this.delayMs = Math.max(0L, delayMs);
+ }
+
+ /**
+ * Returns the next delay.
+ *
+ * If {@code serverRetryHintMs} is non-{@code null}, that value is used
+ * (coerced to {@code >= 0}). Otherwise, returns the configured constant delay.
+ *
+ * @param attempt consecutive reconnect attempt number (ignored)
+ * @param previousDelayMs last delay used (ignored)
+ * @param serverRetryHintMs server-provided retry delay in ms, or {@code null}
+ * @return delay in milliseconds (always {@code >= 0})
+ * @since 5.6
+ */
+ @Override
+ public long nextDelayMs(final int attempt, final long previousDelayMs, final Long serverRetryHintMs) {
+ if (serverRetryHintMs != null) {
+ return Math.max(0L, serverRetryHintMs);
+ }
+ return delayMs;
+ }
+}
diff --git a/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/NoBackoffStrategy.java b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/NoBackoffStrategy.java
new file mode 100644
index 0000000000..0b350d90e0
--- /dev/null
+++ b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/NoBackoffStrategy.java
@@ -0,0 +1,76 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse;
+
+/**
+ * Backoff policy that never reconnects.
+ *
+ * Use this when you want a single-shot SSE connection: after the stream ends
+ * (or fails), no reconnect attempts will be made.
+ *
+ * Behavior
+ *
+ * - {@link #shouldReconnect(int, long, Long)} always returns {@code false}.
+ * - {@link #nextDelayMs(int, long, Long)} returns {@code 0} but is ignored
+ * by callers because {@code shouldReconnect(..)} is {@code false}.
+ *
+ *
+ * Stateless and thread-safe.
+ *
+ * @since 5.6
+ */
+public final class NoBackoffStrategy implements BackoffStrategy {
+
+ /**
+ * Always returns {@code 0}. This value is ignored because
+ * {@link #shouldReconnect(int, long, Long)} returns {@code false}.
+ *
+ * @param attempt consecutive reconnect attempt number (unused)
+ * @param previousDelayMs last delay used (unused)
+ * @param serverRetryHintMs server-provided retry hint (unused)
+ * @return {@code 0}
+ * @since 5.6
+ */
+ @Override
+ public long nextDelayMs(final int attempt, final long previousDelayMs, final Long serverRetryHintMs) {
+ return 0L; // ignored since shouldReconnect(..) is false
+ }
+
+ /**
+ * Always returns {@code false}: no reconnects will be attempted.
+ *
+ * @param attempt consecutive reconnect attempt number (unused)
+ * @param previousDelayMs last delay used (unused)
+ * @param serverRetryHintMs server-provided retry hint (unused)
+ * @return {@code false}
+ * @since 5.6
+ */
+ @Override
+ public boolean shouldReconnect(final int attempt, final long previousDelayMs, final Long serverRetryHintMs) {
+ return false;
+ }
+}
diff --git a/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/ServerSentEventReader.java b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/ServerSentEventReader.java
new file mode 100644
index 0000000000..30dddfed36
--- /dev/null
+++ b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/ServerSentEventReader.java
@@ -0,0 +1,195 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse;
+
+import org.apache.hc.core5.annotation.Internal;
+
+/**
+ * Minimal-allocation SSE line parser.
+ *
+ * Notes:
+ *
+ * - {@code line} is {@code final}; we use indices instead of reassigning.
+ * - Field dispatch is done by length+char checks to avoid a temporary "field" string.
+ * - {@code retry} is parsed without creating a substring; only {@code data/event/id} values
+ * create substrings when needed.
+ *
+ *
+ */
+@Internal
+final class ServerSentEventReader {
+
+ interface Callback {
+ void onEvent(String id, String type, String data);
+
+ void onComment(String comment);
+
+ void onRetryChange(long retryMs);
+ }
+
+ private final Callback cb;
+ private final StringBuilder data = new StringBuilder(128);
+ private String type; // defaults to "message"
+ private String id;
+
+ ServerSentEventReader(final Callback cb) {
+ this.cb = cb;
+ }
+
+ void line(final String line) {
+ // Trim possible trailing CR without reallocating
+ final int L0 = line.length();
+ int end = L0;
+ if (end > 0 && line.charAt(end - 1) == '\r') {
+ end--;
+ }
+
+ if (end == 0) {
+ // blank line -> dispatch accumulated event
+ dispatch();
+ return;
+ }
+
+ // Comment line: ":" [ " " ] comment
+ if (line.charAt(0) == ':') {
+ final int cStart = end > 1 && line.charAt(1) == ' ' ? 2 : 1;
+ if (cStart < end) {
+ cb.onComment(line.substring(cStart, end));
+ } else {
+ cb.onComment("");
+ }
+ return;
+ }
+
+ // Find colon (if any) up to 'end'
+ int colon = -1;
+ for (int i = 0; i < end; i++) {
+ if (line.charAt(i) == ':') {
+ colon = i;
+ break;
+ }
+ }
+
+ final int fStart = 0;
+ final int fEnd = colon >= 0 ? colon : end;
+ int vStart = colon >= 0 ? colon + 1 : end;
+ if (vStart < end && line.charAt(vStart) == ' ') {
+ vStart++;
+ }
+
+ final int fLen = fEnd - fStart;
+
+ // Fast ASCII field dispatch (lowercase per spec)
+ if (fLen == 4 &&
+ line.charAt(0) == 'd' &&
+ line.charAt(1) == 'a' &&
+ line.charAt(2) == 't' &&
+ line.charAt(3) == 'a') {
+
+ // data: (append newline; removed on dispatch)
+ if (vStart <= end) {
+ data.append(line, vStart, end).append('\n');
+ }
+
+ } else if (fLen == 5 &&
+ line.charAt(0) == 'e' &&
+ line.charAt(1) == 'v' &&
+ line.charAt(2) == 'e' &&
+ line.charAt(3) == 'n' &&
+ line.charAt(4) == 't') {
+
+ // event:
+ type = (vStart <= end) ? line.substring(vStart, end) : "";
+
+ } else if (fLen == 2 &&
+ line.charAt(0) == 'i' &&
+ line.charAt(1) == 'd') {
+
+ // id: (ignore if contains NUL per spec)
+ boolean hasNul = false;
+ for (int i = vStart; i < end; i++) {
+ if (line.charAt(i) == '\u0000') {
+ hasNul = true;
+ break;
+ }
+ }
+ if (!hasNul) {
+ id = vStart <= end ? line.substring(vStart, end) : "";
+ }
+
+ } else if (fLen == 5 &&
+ line.charAt(0) == 'r' &&
+ line.charAt(1) == 'e' &&
+ line.charAt(2) == 't' &&
+ line.charAt(3) == 'r' &&
+ line.charAt(4) == 'y') {
+
+ // retry: (non-negative integer), parse without substring
+ final long retry = parseLongAscii(line, vStart, end);
+ if (retry >= 0) {
+ cb.onRetryChange(retry);
+ }
+
+ } else {
+ // Unknown field -> ignore
+ }
+ }
+
+ private void dispatch() {
+ if (data.length() == 0) {
+ // spec: a blank line with no "data:" accumulates nothing -> just clear type
+ type = null;
+ return;
+ }
+ final int n = data.length();
+ if (n > 0 && data.charAt(n - 1) == '\n') {
+ data.setLength(n - 1);
+ }
+ cb.onEvent(id, type != null ? type : "message", data.toString());
+ data.setLength(0);
+ // id persists across events; type resets per spec
+ type = null;
+ }
+
+ private static long parseLongAscii(final String s, final int start, final int end) {
+ if (start >= end) {
+ return -1L;
+ }
+ long v = 0L;
+ for (int i = start; i < end; i++) {
+ final char ch = s.charAt(i);
+ if (ch < '0' || ch > '9') {
+ return -1L;
+ }
+ v = v * 10L + (ch - '0');
+ if (v < 0L) {
+ return -1L; // overflow guard
+ }
+ }
+ return v;
+ }
+}
diff --git a/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/SseCallbacks.java b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/SseCallbacks.java
new file mode 100644
index 0000000000..5f0a128f11
--- /dev/null
+++ b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/SseCallbacks.java
@@ -0,0 +1,67 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse;
+
+import org.apache.hc.core5.annotation.Internal;
+
+/**
+ * Internal callback contract used by SSE entity consumers to
+ * report lifecycle and parsed event data back to the owning
+ * {@code EventSource} implementation.
+ *
+ * This interface is package-private by design and not part of the
+ * public API. Methods may be invoked on I/O or decoder threads;
+ * implementations must be lightweight and non-blocking.
+ *
+ * @since 5.6
+ */
+@Internal
+interface SseCallbacks {
+
+ /**
+ * Signals that the HTTP response has been accepted and the
+ * SSE stream is ready to deliver events.
+ */
+ void onOpen();
+
+ /**
+ * Delivers a parsed SSE event.
+ *
+ * @param id the event id, or {@code null} if not present
+ * @param type the event type, or {@code null} (treat as {@code "message"})
+ * @param data the event payload (never {@code null})
+ */
+ void onEvent(String id, String type, String data);
+
+ /**
+ * Notifies of a change to the client-side reconnect delay as
+ * advertised by the server via the {@code retry:} field.
+ *
+ * @param retryMs new retry delay in milliseconds (non-negative)
+ */
+ void onRetry(long retryMs);
+}
diff --git a/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/SseEntityConsumer.java b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/SseEntityConsumer.java
new file mode 100644
index 0000000000..788c902a11
--- /dev/null
+++ b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/SseEntityConsumer.java
@@ -0,0 +1,149 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse;
+
+import static org.apache.hc.core5.http.ContentType.TEXT_EVENT_STREAM;
+
+import java.io.IOException;
+import java.nio.CharBuffer;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.hc.core5.annotation.Internal;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.nio.entity.AbstractCharAsyncEntityConsumer;
+
+/**
+ * Internal char-level SSE consumer that converts decoded UTF-8 characters
+ * into Server-Sent Events using {@link ServerSentEventReader} and forwards
+ * them via {@link SseCallbacks}.
+ *
+ * Responsibilities
+ *
+ * - Validates {@code Content-Type == text/event-stream}.
+ * - Ensures UTF-8 decoding and strips optional UTF-8 BOM.
+ * - Accumulates CR/LF-delimited lines and feeds them into
+ * {@link ServerSentEventReader}, which applies the SSE field rules
+ * (data/event/id/retry).
+ * - Emits lifecycle and parsed events to the owning {@code EventSource}
+ * through {@link SseCallbacks}.
+ *
+ *
+ * Thread-safety: Not thread-safe. One instance is expected to be
+ * used by a single decoding flow on an I/O thread.
+ *
+ * @since 5.6
+ */
+@Internal
+final class SseEntityConsumer extends AbstractCharAsyncEntityConsumer
+ implements ServerSentEventReader.Callback {
+
+ private final SseCallbacks cb;
+ private final StringBuilder partial = new StringBuilder(256);
+ private ServerSentEventReader reader;
+ private boolean firstChunk = true;
+
+ SseEntityConsumer(final SseCallbacks callbacks) {
+ this.cb = callbacks;
+ }
+
+ @Override
+ protected void streamStart(final ContentType contentType) throws HttpException, IOException {
+ final String mt = contentType != null ? contentType.getMimeType() : null;
+ if (!TEXT_EVENT_STREAM.getMimeType().equalsIgnoreCase(mt)) {
+ throw new HttpException("Unexpected Content-Type: " + mt);
+ }
+ setCharset(StandardCharsets.UTF_8);
+ reader = new ServerSentEventReader(this);
+ cb.onOpen();
+ }
+
+ @Override
+ protected void data(final CharBuffer src, final boolean endOfStream) {
+ if (firstChunk) {
+ firstChunk = false;
+ // Strip UTF-8 BOM if present.
+ if (src.remaining() >= 1 && src.get(src.position()) == '\uFEFF') {
+ src.position(src.position() + 1);
+ }
+ }
+ while (src.hasRemaining()) {
+ final char c = src.get();
+ if (c == '\n') {
+ final int len = partial.length();
+ if (len > 0 && partial.charAt(len - 1) == '\r') {
+ partial.setLength(len - 1);
+ }
+ reader.line(partial.toString());
+ partial.setLength(0);
+ } else {
+ partial.append(c);
+ }
+ }
+ if (endOfStream) {
+ if (partial.length() > 0) {
+ reader.line(partial.toString());
+ partial.setLength(0);
+ }
+ // Flush any accumulated fields into a final event.
+ reader.line("");
+ }
+ }
+
+ @Override
+ protected int capacityIncrement() {
+ return 8192;
+ }
+
+ @Override
+ protected Void generateContent() {
+ return null;
+ }
+
+ @Override
+ public void releaseResources() {
+ partial.setLength(0);
+ reader = null;
+ }
+
+ // ServerSentEventReader.Callback
+
+ @Override
+ public void onEvent(final String id, final String type, final String data) {
+ cb.onEvent(id, type, data);
+ }
+
+ @Override
+ public void onComment(final String comment) {
+ // ignored
+ }
+
+ @Override
+ public void onRetryChange(final long retryMs) {
+ cb.onRetry(retryMs);
+ }
+}
diff --git a/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/SseExecutor.java b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/SseExecutor.java
new file mode 100644
index 0000000000..8e89646b41
--- /dev/null
+++ b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/SseExecutor.java
@@ -0,0 +1,308 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.locks.ReentrantLock;
+
+import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
+import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder;
+import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder;
+import org.apache.hc.core5.reactor.IOReactorStatus;
+import org.apache.hc.core5.util.Args;
+import org.apache.hc.core5.util.TimeValue;
+
+/**
+ * Entry point for creating and managing {@link EventSource} instances backed by an
+ * {@link CloseableHttpAsyncClient}.
+ *
+ * This type provides:
+ *
+ * - A process-wide shared async client (see {@link #newInstance()}),
+ * - Factory methods that accept a caller-supplied client (see {@link #newInstance(CloseableHttpAsyncClient)}),
+ * - A builder for fine-grained defaults (headers, backoff, parser, executors) applied to
+ * all streams opened via this executor (see {@link #custom()}).
+ *
+ *
+ * Lifecycle. When using the shared client, {@link #close()} is a no-op,
+ * and the client remains available process-wide until {@link #closeSharedClient()} is called.
+ * When you supply your own client, {@link #close()} will close that client.
+ *
+ * Thread-safety. Instances are thread-safe. Methods may be called from any thread.
+ *
+ * Usage example
+ * {@code
+ * SseExecutor exec = SseExecutor.custom()
+ * .setDefaultBackoff(new ExponentialJitterBackoff(1000, 30000, 2.0, 250))
+ * .setDefaultMaxReconnects(-1)
+ * .build();
+ *
+ * EventSource es = exec.open(URI.create("https://example/sse"),
+ * Collections.singletonMap("X-Token", "abc"),
+ * new EventSourceListener() {
+ * public void onEvent(String id, String type, String data) {
+ * System.out.println("event " + type + ": " + data);
+ * }
+ * });
+ *
+ * es.start();
+ * }
+ *
+ * @since 5.6
+ */
+public final class SseExecutor {
+
+ // Visible for tests
+ static final ReentrantLock LOCK = new ReentrantLock();
+ static volatile CloseableHttpAsyncClient SHARED_CLIENT;
+
+ /**
+ * Returns the lazily-initialized shared async client. If it does not yet exist, it is
+ * created with a pooling connection manager and started.
+ */
+ static CloseableHttpAsyncClient getSharedClient() {
+ CloseableHttpAsyncClient c = SHARED_CLIENT;
+ if (c != null) {
+ return c;
+ }
+ LOCK.lock();
+ try {
+ c = SHARED_CLIENT;
+ if (c == null) {
+ c = HttpAsyncClientBuilder.create()
+ .setConnectionManager(PoolingAsyncClientConnectionManagerBuilder.create()
+ .useSystemProperties()
+ .setMaxConnPerRoute(100)
+ .setMaxConnTotal(200)
+ .setMessageMultiplexing(true)
+ .build())
+ .useSystemProperties()
+ .evictExpiredConnections()
+ .evictIdleConnections(TimeValue.ofMinutes(1))
+ .build();
+ c.start();
+ SHARED_CLIENT = c;
+ }
+ return c;
+ } finally {
+ LOCK.unlock();
+ }
+ }
+
+ /**
+ * Creates a builder for a fully configurable {@link SseExecutor}.
+ *
+ * Use this when you want to set defaults such as headers, backoff,
+ * parser strategy (char vs. byte), or custom executors for scheduling and callbacks.
+ */
+ public static SseExecutorBuilder custom() {
+ return new SseExecutorBuilder();
+ }
+
+ /**
+ * Creates an {@code SseExecutor} that uses a process-wide shared async client.
+ *
+ * Streams opened by this executor will share one underlying {@link CloseableHttpAsyncClient}
+ * instance. {@link #close()} will be a no-op; call {@link #closeSharedClient()} to
+ * explicitly shut the shared client down (for tests / application shutdown).
+ */
+ public static SseExecutor newInstance() {
+ final CloseableHttpAsyncClient c = getSharedClient();
+ return new SseExecutor(c, true, null, null, EventSourceConfig.DEFAULT,
+ Collections.emptyMap(), SseParser.CHAR);
+ }
+
+ /**
+ * Creates an {@code SseExecutor} using the caller-supplied async client.
+ *
+ * The caller owns the lifecycle of the given client. {@link #close()} will close it.
+ *
+ * @param client an already constructed async client
+ * @throws NullPointerException if {@code client} is {@code null}
+ * @throws IllegalStateException if the client is shutting down or shut down
+ */
+ public static SseExecutor newInstance(final CloseableHttpAsyncClient client) {
+ Args.notNull(client, "HTTP Async Client");
+ final boolean isShared = client == SHARED_CLIENT;
+ return new SseExecutor(client, isShared, null, null, EventSourceConfig.DEFAULT,
+ Collections.emptyMap(), SseParser.CHAR);
+ }
+
+ /**
+ * Closes and clears the shared async client, if present.
+ *
+ * Useful for tests or orderly application shutdown.
+ */
+ public static void closeSharedClient() throws IOException {
+ LOCK.lock();
+ try {
+ if (SHARED_CLIENT != null) {
+ SHARED_CLIENT.close();
+ SHARED_CLIENT = null;
+ }
+ } finally {
+ LOCK.unlock();
+ }
+ }
+
+ private final CloseableHttpAsyncClient client;
+ private final boolean isSharedClient;
+ private final ScheduledExecutorService defaultScheduler; // nullable
+ private final Executor defaultCallbackExecutor; // nullable
+ private final EventSourceConfig defaultConfig;
+ private final Map defaultHeaders; // unmodifiable
+ private final SseParser defaultParser;
+
+ SseExecutor(final CloseableHttpAsyncClient client,
+ final boolean isSharedClient,
+ final ScheduledExecutorService defaultScheduler,
+ final Executor defaultCallbackExecutor,
+ final EventSourceConfig defaultConfig,
+ final Map defaultHeaders,
+ final SseParser defaultParser) {
+ this.client = client;
+ this.isSharedClient = isSharedClient;
+ this.defaultScheduler = defaultScheduler;
+ this.defaultCallbackExecutor = defaultCallbackExecutor;
+ this.defaultConfig = defaultConfig != null ? defaultConfig : EventSourceConfig.DEFAULT;
+ this.defaultHeaders = defaultHeaders != null
+ ? Collections.unmodifiableMap(new LinkedHashMap<>(defaultHeaders))
+ : Collections.emptyMap();
+ this.defaultParser = defaultParser != null ? defaultParser : SseParser.CHAR;
+
+ final IOReactorStatus status = client.getStatus();
+ if (status == IOReactorStatus.INACTIVE) {
+ client.start();
+ } else if (status == IOReactorStatus.SHUTTING_DOWN || status == IOReactorStatus.SHUT_DOWN) {
+ throw new IllegalStateException("Async client not usable: " + status);
+ }
+ }
+
+ /**
+ * Closes the underlying async client if this executor does not use
+ * the process-wide shared client. No-op otherwise.
+ */
+ public void close() throws IOException {
+ if (!isSharedClient) {
+ client.close();
+ }
+ }
+
+ /**
+ * Opens an {@link EventSource} with the executor's defaults (headers, config, parser, executors).
+ *
+ * @param uri target SSE endpoint (must produce {@code text/event-stream})
+ * @param listener event callbacks
+ */
+ public EventSource open(final URI uri, final EventSourceListener listener) {
+ return open(uri, this.defaultHeaders, listener, this.defaultConfig,
+ this.defaultParser, this.defaultScheduler, this.defaultCallbackExecutor);
+ }
+
+ /**
+ * Opens an {@link EventSource} overriding headers; other defaults are inherited.
+ *
+ * @param uri target SSE endpoint
+ * @param headers extra request headers (merged with executor defaults)
+ * @param listener event callbacks
+ */
+ public EventSource open(final URI uri,
+ final Map headers,
+ final EventSourceListener listener) {
+ return open(uri, mergeHeaders(this.defaultHeaders, headers), listener, this.defaultConfig,
+ this.defaultParser, this.defaultScheduler, this.defaultCallbackExecutor);
+ }
+
+ /**
+ * Opens an {@link EventSource} overriding headers and reconnect policy; other defaults are inherited.
+ *
+ * @param uri target SSE endpoint
+ * @param headers extra request headers (merged with executor defaults)
+ * @param listener event callbacks
+ * @param config reconnect/backoff config
+ */
+ public EventSource open(final URI uri,
+ final Map headers,
+ final EventSourceListener listener,
+ final EventSourceConfig config) {
+ return open(uri, mergeHeaders(this.defaultHeaders, headers), listener, config,
+ this.defaultParser, this.defaultScheduler, this.defaultCallbackExecutor);
+ }
+
+ /**
+ * Full-control open allowing a custom parser strategy and executors.
+ *
+ * @param uri target SSE endpoint
+ * @param headers request headers (not {@code null}, may be empty)
+ * @param listener event callbacks
+ * @param config reconnect/backoff config (uses {@link EventSourceConfig#DEFAULT} if {@code null})
+ * @param parser parsing strategy ({@link SseParser#CHAR} or {@link SseParser#BYTE})
+ * @param scheduler scheduler for reconnects (nullable → internal shared scheduler)
+ * @param callbackExecutor executor for listener callbacks (nullable → run inline)
+ */
+ public EventSource open(final URI uri,
+ final Map headers,
+ final EventSourceListener listener,
+ final EventSourceConfig config,
+ final SseParser parser,
+ final ScheduledExecutorService scheduler,
+ final Executor callbackExecutor) {
+ return new DefaultEventSource(
+ client,
+ uri,
+ headers != null ? headers : Collections.emptyMap(),
+ listener,
+ scheduler,
+ callbackExecutor,
+ config,
+ parser != null ? parser : this.defaultParser);
+ }
+
+ /**
+ * Returns the underlying {@link CloseableHttpAsyncClient}.
+ */
+ public CloseableHttpAsyncClient getClient() {
+ return client;
+ }
+
+ private static Map mergeHeaders(final Map base, final Map extra) {
+ if (base == null || base.isEmpty()) {
+ return extra != null ? extra : Collections.emptyMap();
+ }
+ final LinkedHashMap merged = new LinkedHashMap<>(base);
+ if (extra != null && !extra.isEmpty()) {
+ merged.putAll(extra);
+ }
+ return merged;
+ }
+}
diff --git a/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/SseExecutorBuilder.java b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/SseExecutorBuilder.java
new file mode 100644
index 0000000000..f2c1f57935
--- /dev/null
+++ b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/SseExecutorBuilder.java
@@ -0,0 +1,150 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ScheduledExecutorService;
+
+import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
+import org.apache.hc.core5.util.Args;
+
+/**
+ * Builder for {@link SseExecutor}.
+ *
+ * Use this builder when you want to provide defaults (headers, reconnect policy,
+ * parser strategy, custom executors, etc.) for all {@link EventSource}s opened
+ * through the resulting {@link SseExecutor}.
+ *
+ * If no {@link CloseableHttpAsyncClient} is supplied, the process-wide shared client
+ * from {@link SseExecutor#getSharedClient()} is used and {@link SseExecutor#close()} becomes
+ * a no-op.
+ *
+ * Example
+ * {@code
+ * SseExecutor exec = SseExecutor.custom()
+ * .setEventSourceConfig(
+ * EventSourceConfig.builder()
+ * .backoff(new ExponentialJitterBackoff(1000, 30000, 2.0, 250))
+ * .maxReconnects(-1)
+ * .build())
+ * .addDefaultHeader("User-Agent", "my-sse-client/1.0")
+ * .setParserStrategy(SseParser.BYTE)
+ * .build();
+ * }
+ *
+ * @since 5.6
+ */
+public final class SseExecutorBuilder {
+
+ private CloseableHttpAsyncClient client;
+ private ScheduledExecutorService scheduler; // optional
+ private Executor callbackExecutor; // optional
+ private EventSourceConfig config = EventSourceConfig.DEFAULT;
+ private final LinkedHashMap defaultHeaders = new LinkedHashMap<>();
+ private SseParser parserStrategy = SseParser.CHAR;
+
+ SseExecutorBuilder() {
+ }
+
+ /**
+ * Supplies a custom async HTTP client. The caller owns its lifecycle and
+ * {@link SseExecutor#close()} will close it.
+ */
+ public SseExecutorBuilder setHttpClient(final CloseableHttpAsyncClient client) {
+ this.client = Args.notNull(client, "HTTP Async Client");
+ return this;
+ }
+
+ /**
+ * Sets the scheduler to use for reconnect delays. If not provided, the internal shared
+ * scheduler is used.
+ */
+ public SseExecutorBuilder setScheduler(final ScheduledExecutorService scheduler) {
+ this.scheduler = scheduler;
+ return this;
+ }
+
+ /**
+ * Sets the executor used to dispatch {@link EventSourceListener} callbacks.
+ * If not provided, callbacks run inline on the I/O thread.
+ */
+ public SseExecutorBuilder setCallbackExecutor(final Executor callbackExecutor) {
+ this.callbackExecutor = callbackExecutor;
+ return this;
+ }
+
+ /**
+ * Sets the default reconnect/backoff configuration applied to opened streams.
+ */
+ public SseExecutorBuilder setEventSourceConfig(final EventSourceConfig cfg) {
+ this.config = Args.notNull(cfg, "EventSourceConfig");
+ return this;
+ }
+
+ /**
+ * Replaces the default headers (sent on every opened stream).
+ */
+ public SseExecutorBuilder setDefaultHeaders(final Map headers) {
+ this.defaultHeaders.clear();
+ if (headers != null && !headers.isEmpty()) {
+ this.defaultHeaders.putAll(headers);
+ }
+ return this;
+ }
+
+ /**
+ * Adds or replaces a single default header.
+ */
+ public SseExecutorBuilder addDefaultHeader(final String name, final String value) {
+ this.defaultHeaders.put(Args.notNull(name, "name"), value);
+ return this;
+ }
+
+ /**
+ * Chooses the parser strategy: {@link SseParser#CHAR} (spec-level, default)
+ * or {@link SseParser#BYTE} (byte-level framing with minimal decoding).
+ */
+ public SseExecutorBuilder setParserStrategy(final SseParser parser) {
+ this.parserStrategy = parser != null ? parser : SseParser.CHAR;
+ return this;
+ }
+
+ /**
+ * Builds the {@link SseExecutor}.
+ */
+ public SseExecutor build() {
+ final CloseableHttpAsyncClient c = (client != null) ? client : SseExecutor.getSharedClient();
+ final boolean isShared = c == SseExecutor.SHARED_CLIENT;
+ final Map dh = defaultHeaders.isEmpty()
+ ? Collections.emptyMap()
+ : new LinkedHashMap<>(defaultHeaders);
+ return new SseExecutor(c, isShared, scheduler, callbackExecutor, config, dh, parserStrategy);
+ }
+}
diff --git a/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/SseParser.java b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/SseParser.java
new file mode 100644
index 0000000000..ba8de41caf
--- /dev/null
+++ b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/SseParser.java
@@ -0,0 +1,50 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse;
+
+/**
+ * Parser strategy for SSE entity consumption.
+ *
+ *
+ * - {@link #CHAR}: Uses a {@code CharBuffer} with UTF-8 decoding and a spec-compliant
+ * line parser. Safer and simpler; good default.
+ * - {@link #BYTE}: Uses a {@code ByteBuffer} with byte-level line framing and minimal
+ * string allocation. Can be slightly faster at very high rates.
+ *
+ *
+ * @since 5.6
+ */
+public enum SseParser {
+ /**
+ * CharBuffer → spec reader.
+ */
+ CHAR,
+ /**
+ * ByteBuffer → byte-level framing & minimal decode.
+ */
+ BYTE
+}
diff --git a/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/SseResponseConsumer.java b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/SseResponseConsumer.java
new file mode 100644
index 0000000000..e7643e0ec3
--- /dev/null
+++ b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/SseResponseConsumer.java
@@ -0,0 +1,157 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.time.Duration;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import java.util.function.LongConsumer;
+
+import org.apache.hc.core5.annotation.Internal;
+import org.apache.hc.core5.http.EntityDetails;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.HttpResponse;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.nio.AsyncEntityConsumer;
+import org.apache.hc.core5.http.nio.AsyncResponseConsumer;
+import org.apache.hc.core5.http.nio.CapacityChannel;
+import org.apache.hc.core5.http.protocol.HttpContext;
+
+/**
+ * Internal response consumer that bridges an HTTP response to an SSE entity consumer.
+ *
+ * Responsibilities:
+ *
+ * - Validate that the status is {@code 200 OK}; otherwise propagate a failure.
+ * - Extract and pass a {@code Retry-After} hint (seconds or RFC-1123 date) to the caller
+ * via the provided {@link LongConsumer}.
+ * - Treat {@code 204 No Content} as a terminal close (no reconnect), signaled with
+ * {@link StopReconnectException}.
+ *
+ *
+ * This class is used internally by {@code DefaultEventSource}.
+ *
+ * @since 5.6
+ */
+@Internal
+final class SseResponseConsumer implements AsyncResponseConsumer {
+
+ private final AsyncEntityConsumer entity;
+ private final LongConsumer retryHintSink; // may be null
+
+ /**
+ * Signals that the server requested a terminal close (no reconnect).
+ */
+ static final class StopReconnectException extends HttpException {
+ StopReconnectException(final String msg) {
+ super(msg);
+ }
+ }
+
+ SseResponseConsumer(final AsyncEntityConsumer entity, final LongConsumer retryHintSink) {
+ this.entity = entity;
+ this.retryHintSink = retryHintSink;
+ }
+
+ @Override
+ public void consumeResponse(final HttpResponse rsp, final EntityDetails ed, final HttpContext ctx,
+ final org.apache.hc.core5.concurrent.FutureCallback cb)
+ throws HttpException, IOException {
+ final int code = rsp.getCode();
+ if (code != HttpStatus.SC_OK) {
+ final Header h = rsp.getFirstHeader(org.apache.hc.core5.http.HttpHeaders.RETRY_AFTER);
+ if (h != null && retryHintSink != null) {
+ final long ms = parseRetryAfterMillis(h.getValue());
+ if (ms >= 0) {
+ retryHintSink.accept(ms);
+ }
+ }
+ if (code == HttpStatus.SC_NO_CONTENT) { // 204 => do not reconnect
+ throw new StopReconnectException("Server closed stream (204)");
+ }
+ throw new HttpException("Unexpected status: " + code);
+ }
+ entity.streamStart(ed, cb);
+ }
+
+ @Override
+ public void informationResponse(final HttpResponse response, final HttpContext context) {
+ // no-op
+ }
+
+ @Override
+ public void updateCapacity(final CapacityChannel channel) throws IOException {
+ entity.updateCapacity(channel);
+ }
+
+ @Override
+ public void consume(final ByteBuffer src) throws IOException {
+ entity.consume(src);
+ }
+
+ @Override
+ public void streamEnd(final List extends Header> trailers) throws HttpException, IOException {
+ entity.streamEnd(trailers);
+ }
+
+ @Override
+ public void failed(final Exception cause) {
+ entity.failed(cause);
+ }
+
+ @Override
+ public void releaseResources() {
+ entity.releaseResources();
+ }
+
+ /**
+ * Parses an HTTP {@code Retry-After} header value into milliseconds.
+ * Accepts either a positive integer (seconds) or an RFC-1123 date.
+ *
+ * @return milliseconds to wait, or {@code -1} if unparseable.
+ */
+ private static long parseRetryAfterMillis(final String v) {
+ final String s = v != null ? v.trim() : "";
+ try {
+ final long sec = Long.parseLong(s);
+ return sec >= 0 ? sec * 1000L : -1L;
+ } catch (final NumberFormatException ignore) {
+ try {
+ final ZonedDateTime t = ZonedDateTime.parse(s, DateTimeFormatter.RFC_1123_DATE_TIME);
+ final long ms = Duration.between(ZonedDateTime.now(ZoneOffset.UTC), t).toMillis();
+ return Math.max(0L, ms);
+ } catch (final Exception ignore2) {
+ return -1L;
+ }
+ }
+ }
+}
diff --git a/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/package-info.java b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/package-info.java
new file mode 100644
index 0000000000..b52046d201
--- /dev/null
+++ b/httpclient5-sse/src/main/java/org/apache/hc/client5/http/sse/package-info.java
@@ -0,0 +1,131 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+
+/**
+ * Client-side Server-Sent Events (SSE) support for HttpClient 5.
+ *
+ * This package provides a small, focused API for consuming {@code text/event-stream}
+ * resources with automatic reconnection, pluggable backoff strategies, and a
+ * configurable parsing pipeline. It is designed for very low-latency, high-fan-out
+ * read workloads and integrates with HttpClient 5's asynchronous I/O stack.
+ *
+ * Key types
+ *
+ * - {@link org.apache.hc.client5.http.sse.SseExecutor} — entry point that opens
+ * {@link org.apache.hc.client5.http.sse.EventSource} instances and manages the underlying
+ * {@code CloseableHttpAsyncClient} lifecycle (shared or caller-supplied).
+ * - {@link org.apache.hc.client5.http.sse.EventSource} — represents a single SSE connection:
+ * start, cancel, inspect connection state, manipulate headers, and manage {@code Last-Event-ID}.
+ * - {@link org.apache.hc.client5.http.sse.EventSourceListener} — callback interface for
+ * open/close, events, and failures (with a flag indicating whether a reconnect is scheduled).
+ * - {@link org.apache.hc.client5.http.sse.EventSourceConfig} — policy and limits
+ * (e.g., {@link org.apache.hc.client5.http.sse.BackoffStrategy}, max reconnects).
+ * - {@link org.apache.hc.client5.http.sse.BackoffStrategy} — reconnection policy SPI
+ * with built-ins:
+ * {@link org.apache.hc.client5.http.sse.ExponentialJitterBackoff},
+ * {@link org.apache.hc.client5.http.sse.FixedBackoffStrategy},
+ * {@link org.apache.hc.client5.http.sse.NoBackoffStrategy}.
+ * - {@link org.apache.hc.client5.http.sse.SseParser} — choose between a spec-friendly
+ * char parser or a byte parser optimized for minimal allocations.
+ *
+ *
+ * Quick start
+ * {@code
+ * import java.net.URI;
+ * import java.util.Collections;
+ * import org.apache.hc.client5.http.sse.*;
+ *
+ * SseExecutor exec = SseExecutor.newInstance(); // shared async client
+ *
+ * EventSourceListener listener = new EventSourceListener() {
+ * @Override public void onOpen() { System.out.println("open"); }
+ * @Override public void onEvent(String id, String type, String data) {
+ * System.out.println(type + " id=" + id + " data=" + data);
+ * }
+ * @Override public void onClosed() { System.out.println("closed"); }
+ * @Override public void onFailure(Throwable t, boolean willReconnect) {
+ * t.printStackTrace();
+ * }
+ * };
+ *
+ * EventSource es = exec.open(URI.create("https://example.com/stream"),
+ * Collections.emptyMap(),
+ * listener);
+ * es.start();
+ *
+ * Runtime.getRuntime().addShutdownHook(new Thread(es::cancel));
+ * }
+ *
+ * Configuration
+ *
+ * - Backoff: Provide a {@link org.apache.hc.client5.http.sse.BackoffStrategy} via
+ * {@link org.apache.hc.client5.http.sse.EventSourceConfig}. Server {@code retry:} lines and
+ * HTTP {@code Retry-After} headers are honored when present.
+ * - Headers: Add defaults on the {@link org.apache.hc.client5.http.sse.SseExecutorBuilder}
+ * or per-connection using {@link org.apache.hc.client5.http.sse.EventSource#setHeader(String, String)}.
+ * - Parser: {@link org.apache.hc.client5.http.sse.SseParser#CHAR} (default) is spec-compliant;
+ * {@link org.apache.hc.client5.http.sse.SseParser#BYTE} reduces intermediate allocations for
+ * very high event rates.
+ * - Executors: You can supply a {@code ScheduledExecutorService} for reconnect delays and an
+ * {@code Executor} for listener callbacks. If not provided, a shared scheduler is used
+ * and callbacks execute inline; keep your listener lightweight.
+ * - Resumption: {@code Last-Event-ID} is tracked automatically and sent on reconnects.
+ * You can seed it with {@link org.apache.hc.client5.http.sse.EventSource#setLastEventId(String)}.
+ *
+ *
+ * Resource management
+ *
+ * - {@link org.apache.hc.client5.http.sse.SseExecutor#newInstance()} uses a process-wide shared
+ * async client; {@link org.apache.hc.client5.http.sse.SseExecutor#close()} is a no-op in this case.
+ * - If you supply your own client via the builder, you own it and {@code close()} will shut it down.
+ * - Call {@link org.apache.hc.client5.http.sse.EventSource#cancel()} to stop a stream and deliver
+ * {@link org.apache.hc.client5.http.sse.EventSourceListener#onClosed()}.
+ *
+ *
+ * HTTP/2 and scaling
+ * When used with {@code httpcore5-h2} and a pooling connection manager configured for
+ * message multiplexing, multiple SSE streams can share the same HTTP/2 connection to reduce
+ * socket overhead. This package does not require HTTP/2; it also operates over HTTP/1.1.
+ *
+ * Threading
+ *
+ * - Methods on {@link org.apache.hc.client5.http.sse.EventSource} are thread-safe.
+ * - Listener callbacks may run on the I/O thread unless a callback executor was supplied.
+ * Keep callbacks fast and non-blocking.
+ *
+ *
+ * Compatibility
+ * All public types in this package are source- and binary-compatible with Java 8.
+ *
+ * Internals
+ * Implementation classes annotated with {@code @Internal} (for example,
+ * {@code DefaultEventSource}, {@code SseResponseConsumer}, {@code ServerSentEventReader},
+ * and the concrete entity consumers) are not part of the public API and may change without notice.
+ *
+ * @since 5.6
+ */
+package org.apache.hc.client5.http.sse;
diff --git a/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/ByteSseEntityConsumerTest.java b/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/ByteSseEntityConsumerTest.java
new file mode 100644
index 0000000000..4824e58152
--- /dev/null
+++ b/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/ByteSseEntityConsumerTest.java
@@ -0,0 +1,97 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.hc.core5.http.ContentType;
+import org.junit.jupiter.api.Test;
+
+class ByteSseEntityConsumerTest {
+
+ static final class Cb implements SseCallbacks {
+ boolean opened;
+ String id, type, data;
+ Long retry;
+
+ @Override
+ public void onOpen() {
+ opened = true;
+ }
+
+ @Override
+ public void onEvent(final String id, final String type, final String data) {
+ this.id = id;
+ this.type = type;
+ this.data = data;
+ }
+
+ @Override
+ public void onRetry(final long retryMs) {
+ retry = retryMs;
+ }
+ }
+
+
+ @Test
+ void handlesBomCrLfAndDispatch() throws Exception {
+ final Cb cb = new Cb();
+ final ByteSseEntityConsumer c = new ByteSseEntityConsumer(cb);
+
+ c.streamStart(ContentType.parse("text/event-stream"));
+
+ // UTF-8 BOM + CRLF split across two chunks
+ final byte[] p1 = new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF};
+ final byte[] p2 = "event: ping\r\nid: 1\r\ndata: hi\r\n\r\n".getBytes(StandardCharsets.UTF_8);
+
+ c.consume(ByteBuffer.wrap(p1));
+ c.consume(ByteBuffer.wrap(p2));
+ c.streamEnd(null);
+
+ assertTrue(cb.opened);
+ assertEquals("1", cb.id);
+ assertEquals("ping", cb.type);
+ assertEquals("hi", cb.data);
+ }
+
+ @Test
+ void emitsRetry() throws Exception {
+ final Cb cb = new Cb();
+ final ByteSseEntityConsumer c = new ByteSseEntityConsumer(cb);
+ c.streamStart(ContentType.parse("text/event-stream"));
+
+ final byte[] p = "retry: 2500\n\n".getBytes(StandardCharsets.UTF_8);
+ c.consume(ByteBuffer.wrap(p));
+ c.streamEnd(null);
+
+ assertEquals(Long.valueOf(2500L), cb.retry);
+ }
+}
diff --git a/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/DefaultEventSourceIntegrationTest.java b/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/DefaultEventSourceIntegrationTest.java
new file mode 100644
index 0000000000..31ef320500
--- /dev/null
+++ b/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/DefaultEventSourceIntegrationTest.java
@@ -0,0 +1,220 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
+import org.apache.hc.core5.concurrent.FutureCallback;
+import org.apache.hc.core5.function.Supplier;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.message.BasicHttpResponse;
+import org.apache.hc.core5.http.nio.AsyncPushConsumer;
+import org.apache.hc.core5.http.nio.AsyncRequestProducer;
+import org.apache.hc.core5.http.nio.AsyncResponseConsumer;
+import org.apache.hc.core5.http.nio.HandlerFactory;
+import org.apache.hc.core5.http.protocol.BasicHttpContext;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.reactor.IOReactorStatus;
+import org.junit.jupiter.api.Test;
+
+final class DefaultEventSourceIntegrationTest {
+
+ @Test
+ void openStreamReceivesEventAndCloses() throws Exception {
+ final CapturingClient fake = new CapturingClient();
+ final SseExecutor exec = SseExecutor.newInstance(fake);
+
+ final CountDownLatch opened = new CountDownLatch(1);
+ final CountDownLatch got = new CountDownLatch(1);
+ final CountDownLatch closed = new CountDownLatch(1);
+
+ final EventSourceListener listener = new EventSourceListener() {
+ @Override
+ public void onOpen() {
+ opened.countDown();
+ }
+
+ @Override
+ public void onEvent(final String id, final String type, final String data) {
+ if ("1".equals(id) && "ping".equals(type) && "hello".equals(data)) {
+ got.countDown();
+ }
+ }
+
+ @Override
+ public void onClosed() {
+ closed.countDown();
+ }
+ };
+
+ final EventSource es = exec.open(
+ new URI("http://example.org/sse"),
+ Collections.emptyMap(),
+ listener,
+ EventSourceConfig.DEFAULT,
+ SseParser.BYTE,
+ null,
+ null);
+
+ es.start();
+
+ final AsyncResponseConsumer c = fake.lastConsumer;
+ assertNotNull(c, "consumer captured");
+
+ c.consumeResponse(
+ new BasicHttpResponse(HttpStatus.SC_OK, "OK"),
+ new TestEntityDetails("text/event-stream"),
+ new BasicHttpContext(), // FIX: concrete HttpContext
+ new FutureCallback() {
+ @Override
+ public void completed(final Void result) {
+ }
+
+ @Override
+ public void failed(final Exception ex) {
+ }
+
+ @Override
+ public void cancelled() {
+ }
+ });
+
+ c.consume(ByteBuffer.wrap("id: 1\nevent: ping\n".getBytes(StandardCharsets.UTF_8)));
+ c.consume(ByteBuffer.wrap("data: hello\n\n".getBytes(StandardCharsets.UTF_8)));
+ c.streamEnd(null);
+
+ assertTrue(opened.await(1, TimeUnit.SECONDS), "opened");
+ assertTrue(got.await(1, TimeUnit.SECONDS), "event received");
+
+ es.cancel();
+ assertTrue(closed.await(1, TimeUnit.SECONDS), "closed");
+
+ exec.close();
+ }
+
+ // ---- fake async client that captures the consumer & callback via doExecute() ----
+ static final class CapturingClient extends CloseableHttpAsyncClient {
+ volatile AsyncResponseConsumer lastConsumer;
+ volatile FutureCallback lastCallback;
+ volatile boolean closed;
+
+ @Override
+ public void start() { /* no-op */ }
+
+ @Override
+ public IOReactorStatus getStatus() {
+ return closed ? IOReactorStatus.SHUT_DOWN : IOReactorStatus.ACTIVE;
+ }
+
+ @Override
+ public void awaitShutdown(final org.apache.hc.core5.util.TimeValue waitTime) throws InterruptedException { /* no-op */ }
+
+ @Override
+ public void initiateShutdown() { /* no-op */ }
+
+ @Override
+ public void close(final CloseMode closeMode) {
+ closed = true;
+ }
+
+ @Override
+ public void close() {
+ closed = true;
+ }
+
+ @Override
+ protected Future doExecute(
+ final HttpHost target,
+ final AsyncRequestProducer requestProducer,
+ final AsyncResponseConsumer responseConsumer,
+ final HandlerFactory pushHandlerFactory,
+ final HttpContext context,
+ final FutureCallback callback) {
+
+ @SuppressWarnings("unchecked") final AsyncResponseConsumer c = (AsyncResponseConsumer) responseConsumer;
+ this.lastConsumer = c;
+
+ @SuppressWarnings("unchecked") final FutureCallback cb = (FutureCallback) callback;
+ this.lastCallback = cb;
+
+ return new CompletableFuture<>(); // never completed; fine for this test
+ }
+
+ @Override
+ @Deprecated
+ public void register(final String hostname, final String uriPattern, final Supplier supplier) {
+ // deprecated; not used
+ }
+ }
+
+ // Minimal EntityDetails stub
+ static final class TestEntityDetails implements org.apache.hc.core5.http.EntityDetails {
+ private final String ct;
+
+ TestEntityDetails(final String ct) {
+ this.ct = ct;
+ }
+
+ @Override
+ public long getContentLength() {
+ return -1;
+ }
+
+ @Override
+ public String getContentType() {
+ return ct;
+ }
+
+ @Override
+ public String getContentEncoding() {
+ return null;
+ }
+
+ @Override
+ public boolean isChunked() {
+ return true;
+ }
+
+ @Override
+ public java.util.Set getTrailerNames() {
+ return java.util.Collections.emptySet();
+ }
+ }
+}
diff --git a/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/ExponentialJitterBackoffTest.java b/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/ExponentialJitterBackoffTest.java
new file mode 100644
index 0000000000..dba46d3e8c
--- /dev/null
+++ b/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/ExponentialJitterBackoffTest.java
@@ -0,0 +1,59 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.RepeatedTest;
+import org.junit.jupiter.api.Test;
+
+class ExponentialJitterBackoffTest {
+
+ @Test
+ void usesServerHintAndClamps() {
+ final ExponentialJitterBackoff b = new ExponentialJitterBackoff(1000, 30000, 2.0, 250);
+ long d = b.nextDelayMs(5, 0, 40L); // < min -> clamp to min
+ assertEquals(250L, d);
+
+ d = b.nextDelayMs(5, 0, 999999L); // > max -> clamp to max
+ assertEquals(30000L, d);
+ }
+
+ @RepeatedTest(5)
+ void jitterWithinRange() {
+ final ExponentialJitterBackoff b = new ExponentialJitterBackoff(1000, 8000, 2.0, 250);
+ final long d1 = b.nextDelayMs(1, 0, null); // cap=1000
+ assertTrue(d1 >= 250 && d1 <= 1000, "attempt1 in [250,1000]");
+
+ final long d2 = b.nextDelayMs(2, d1, null); // cap=2000
+ assertTrue(d2 >= 250 && d2 <= 2000, "attempt2 in [250,2000]");
+
+ final long d4 = b.nextDelayMs(4, d2, null); // cap=8000
+ assertTrue(d4 >= 250 && d4 <= 8000, "attempt4 in [250,8000]");
+ }
+}
diff --git a/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/NoBackoffStrategyTest.java b/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/NoBackoffStrategyTest.java
new file mode 100644
index 0000000000..15a4e7f107
--- /dev/null
+++ b/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/NoBackoffStrategyTest.java
@@ -0,0 +1,41 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+import org.junit.jupiter.api.Test;
+
+final class NoBackoffStrategyTest {
+ @Test
+ void neverReconnects() {
+ final BackoffStrategy s = new NoBackoffStrategy();
+ assertFalse(s.shouldReconnect(1, 0L, null));
+ assertEquals(0L, s.nextDelayMs(1, 0L, null));
+ }
+}
diff --git a/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/ServerSentEventReaderTest.java b/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/ServerSentEventReaderTest.java
new file mode 100644
index 0000000000..32efd2589e
--- /dev/null
+++ b/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/ServerSentEventReaderTest.java
@@ -0,0 +1,103 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import org.junit.jupiter.api.Test;
+
+class ServerSentEventReaderTest {
+
+ static final class Capt implements ServerSentEventReader.Callback {
+ String id, type, data, comment;
+ Long retry;
+
+ @Override
+ public void onEvent(final String id, final String type, final String data) {
+ this.id = id;
+ this.type = type;
+ this.data = data;
+ }
+
+ @Override
+ public void onComment(final String comment) {
+ this.comment = comment;
+ }
+
+ @Override
+ public void onRetryChange(final long retryMs) {
+ this.retry = retryMs;
+ }
+ }
+
+ @Test
+ void parsesMultiLineEventWithDefaults() {
+ final Capt c = new Capt();
+ final ServerSentEventReader r = new ServerSentEventReader(c);
+
+ r.line("id: 42");
+ r.line("data: hello");
+ r.line("data: world");
+ r.line(""); // dispatch
+
+ assertEquals("42", c.id);
+ assertEquals("message", c.type);
+ assertEquals("hello\nworld", c.data);
+ }
+
+ @Test
+ void parsesEventTypeAndCommentAndRetry() {
+ final Capt c = new Capt();
+ final ServerSentEventReader r = new ServerSentEventReader(c);
+
+ r.line(": this is a comment");
+ assertEquals("this is a comment", c.comment);
+
+ r.line("event: update");
+ r.line("retry: 1500");
+ r.line("data: x");
+ r.line(""); // dispatch
+
+ assertEquals("update", c.type);
+ assertEquals("x", c.data);
+ assertEquals(Long.valueOf(1500L), c.retry);
+ }
+
+ @Test
+ void ignoresIdWithNul() {
+ final Capt c = new Capt();
+ final ServerSentEventReader r = new ServerSentEventReader(c);
+
+ r.line("id: a\u0000b");
+ r.line("data: d");
+ r.line("");
+
+ assertNull(c.id);
+ assertEquals("d", c.data);
+ }
+}
diff --git a/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/SseEntityConsumerTest.java b/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/SseEntityConsumerTest.java
new file mode 100644
index 0000000000..05242ff854
--- /dev/null
+++ b/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/SseEntityConsumerTest.java
@@ -0,0 +1,87 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.nio.CharBuffer;
+
+import org.apache.hc.core5.http.ContentType;
+import org.junit.jupiter.api.Test;
+
+class SseEntityConsumerTest {
+
+ static final class Cb implements SseCallbacks {
+ boolean opened;
+ String id, type, data;
+ Long retry;
+
+ @Override
+ public void onOpen() {
+ opened = true;
+ }
+
+ @Override
+ public void onEvent(final String id, final String type, final String data) {
+ this.id = id;
+ this.type = type;
+ this.data = data;
+ }
+
+ @Override
+ public void onRetry(final long retryMs) {
+ retry = retryMs;
+ }
+ }
+
+ @Test
+ void parsesLinesAndFlushesOnEndOfStream() throws Exception {
+ final Cb cb = new Cb();
+ final SseEntityConsumer c = new SseEntityConsumer(cb);
+
+ c.streamStart(ContentType.parse("text/event-stream"));
+ c.data(CharBuffer.wrap("id: 9\nevent: t\ndata: v\n"), false);
+ c.data(CharBuffer.wrap("\n"), true); // end -> flush
+
+ assertTrue(cb.opened);
+ assertEquals("9", cb.id);
+ assertEquals("t", cb.type);
+ assertEquals("v", cb.data);
+ }
+
+ @Test
+ void rejectsWrongContentType() {
+ final Cb cb = new Cb();
+ final SseEntityConsumer c = new SseEntityConsumer(cb);
+ try {
+ c.streamStart(ContentType.APPLICATION_JSON);
+ fail("Should have thrown");
+ } catch (final Exception expected) { /* ok */ }
+ }
+}
diff --git a/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/SseExecutorBuilderTest.java b/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/SseExecutorBuilderTest.java
new file mode 100644
index 0000000000..c39158101a
--- /dev/null
+++ b/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/SseExecutorBuilderTest.java
@@ -0,0 +1,109 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Future;
+
+import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
+import org.apache.hc.core5.concurrent.FutureCallback;
+import org.apache.hc.core5.function.Supplier;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.nio.AsyncPushConsumer;
+import org.apache.hc.core5.http.nio.AsyncRequestProducer;
+import org.apache.hc.core5.http.nio.AsyncResponseConsumer;
+import org.apache.hc.core5.http.nio.HandlerFactory;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.reactor.IOReactorStatus;
+import org.apache.hc.core5.util.TimeValue;
+import org.junit.jupiter.api.Test;
+
+final class SseExecutorBuilderTest {
+
+ @Test
+ void defaultParserIsCharAndBuilds() {
+ final CloseableHttpAsyncClient client = new NoopAsyncClient(IOReactorStatus.ACTIVE);
+ final SseExecutor exec = SseExecutor.custom()
+ .setHttpClient(client)
+ .build();
+
+ assertNotNull(exec);
+ final EventSource es = exec.open(java.net.URI.create("http://example.org/"), (id, type, data) -> {
+ });
+ assertNotNull(es);
+ }
+
+ // ---- Minimal fake client that satisfies CloseableHttpAsyncClient ----
+ static final class NoopAsyncClient extends CloseableHttpAsyncClient {
+ private final IOReactorStatus status;
+
+ NoopAsyncClient(final IOReactorStatus status) {
+ this.status = status != null ? status : IOReactorStatus.ACTIVE;
+ }
+
+ @Override
+ public void start() { /* no-op */ }
+
+ @Override
+ public IOReactorStatus getStatus() {
+ return status;
+ }
+
+ @Override
+ public void awaitShutdown(final TimeValue waitTime) throws InterruptedException { /* no-op */ }
+
+ @Override
+ public void initiateShutdown() { /* no-op */ }
+
+ @Override
+ public void close(final CloseMode closeMode) { /* no-op */ }
+
+ @Override
+ public void close() { /* no-op */ }
+
+ @Override
+ protected Future doExecute(
+ final HttpHost target,
+ final AsyncRequestProducer requestProducer,
+ final AsyncResponseConsumer responseConsumer,
+ final HandlerFactory pushHandlerFactory,
+ final HttpContext context,
+ final FutureCallback callback) {
+ // We don't actually run anything here in this unit test.
+ return new CompletableFuture<>();
+ }
+
+ @Override
+ @Deprecated
+ public void register(final String hostname, final String uriPattern, final Supplier supplier) {
+ // deprecated; not used in tests
+ }
+ }
+}
diff --git a/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/SseResponseConsumerTest.java b/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/SseResponseConsumerTest.java
new file mode 100644
index 0000000000..5a7532b4da
--- /dev/null
+++ b/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/SseResponseConsumerTest.java
@@ -0,0 +1,228 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.Collections;
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.apache.hc.core5.concurrent.FutureCallback;
+import org.apache.hc.core5.http.EntityDetails;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.HttpResponse;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.ProtocolVersion;
+import org.apache.hc.core5.http.message.BasicHeader;
+import org.apache.hc.core5.http.message.BasicHttpResponse;
+import org.apache.hc.core5.http.nio.AsyncEntityConsumer;
+import org.apache.hc.core5.http.nio.CapacityChannel;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.junit.jupiter.api.Test;
+
+class SseResponseConsumerTest {
+
+ static final class DummyEntity implements AsyncEntityConsumer {
+ boolean started, ended, failed;
+
+ @Override
+ public void updateCapacity(final CapacityChannel channel) {
+ }
+
+ @Override
+ public void consume(final ByteBuffer src) {
+ }
+
+ @Override
+ public void streamEnd(final java.util.List extends Header> trailers) {
+ ended = true;
+ }
+
+ @Override
+ public void streamStart(final EntityDetails entityDetails, final FutureCallback resultCallback) throws HttpException, IOException {
+ started = true;
+ }
+
+ @Override
+ public void failed(final Exception cause) {
+ failed = true;
+ }
+
+ @Override
+ public void releaseResources() {
+ }
+
+
+ @Override
+ public Void getContent() {
+ return null;
+ }
+ }
+
+ @Test
+ void passesThrough200AndStartsEntity() throws Exception {
+ final DummyEntity ent = new DummyEntity();
+ final AtomicLong hint = new AtomicLong(-1);
+ final SseResponseConsumer c = new SseResponseConsumer(ent, hint::set);
+
+ final HttpResponse rsp = new BasicHttpResponse(HttpStatus.SC_OK, "OK");
+ c.consumeResponse(rsp, new TestEntityDetails("text/event-stream"), new HttpContext() {
+ @Override
+ public ProtocolVersion getProtocolVersion() {
+ return null;
+ }
+
+ @Override
+ public void setProtocolVersion(final ProtocolVersion version) {
+
+ }
+
+ @Override
+ public Object getAttribute(final String id) {
+ return null;
+ }
+
+ @Override
+ public Object setAttribute(final String id, final Object obj) {
+ return null;
+ }
+
+ @Override
+ public Object removeAttribute(final String id) {
+ return null;
+ }
+ }, new FutureCallback() {
+ @Override
+ public void completed(final Void result) {
+ }
+
+ @Override
+ public void failed(final Exception ex) {
+ }
+
+ @Override
+ public void cancelled() {
+ }
+ });
+
+ assertTrue(ent.started);
+ assertEquals(-1L, hint.get());
+ }
+
+ @Test
+ void extractsRetryAfterAndThrowsOnNon200() {
+ final DummyEntity ent = new DummyEntity();
+ final AtomicLong hint = new AtomicLong(-1);
+ final SseResponseConsumer c = new SseResponseConsumer(ent, hint::set);
+
+ final BasicHttpResponse rsp = new BasicHttpResponse(HttpStatus.SC_SERVICE_UNAVAILABLE, "busy");
+ rsp.addHeader(new BasicHeader(org.apache.hc.core5.http.HttpHeaders.RETRY_AFTER, "3"));
+
+ try {
+ c.consumeResponse(rsp, new TestEntityDetails("text/event-stream"), new HttpContext() {
+ @Override
+ public ProtocolVersion getProtocolVersion() {
+ return null;
+ }
+
+ @Override
+ public void setProtocolVersion(final ProtocolVersion version) {
+
+ }
+
+ @Override
+ public Object getAttribute(final String id) {
+ return null;
+ }
+
+ @Override
+ public Object setAttribute(final String id, final Object obj) {
+ return null;
+ }
+
+ @Override
+ public Object removeAttribute(final String id) {
+ return null;
+ }
+ }, new FutureCallback() {
+ @Override
+ public void completed(final Void result) {
+ }
+
+ @Override
+ public void failed(final Exception ex) {
+ }
+
+ @Override
+ public void cancelled() {
+ }
+ });
+ fail("Expected exception");
+ } catch (final Exception expected) {
+ assertEquals(3000L, hint.get());
+ }
+ }
+
+ // Minimal EntityDetails stub for tests
+ static final class TestEntityDetails implements org.apache.hc.core5.http.EntityDetails {
+ private final String ct;
+
+ TestEntityDetails(final String ct) {
+ this.ct = ct;
+ }
+
+ @Override
+ public long getContentLength() {
+ return -1;
+ }
+
+ @Override
+ public String getContentType() {
+ return ct;
+ }
+
+ @Override
+ public String getContentEncoding() {
+ return null;
+ }
+
+ @Override
+ public boolean isChunked() {
+ return true;
+ }
+
+ @Override
+ public java.util.Set getTrailerNames() {
+ return Collections.emptySet();
+ }
+ }
+}
diff --git a/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/example/ClientSseExample.java b/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/example/ClientSseExample.java
new file mode 100644
index 0000000000..8f339acdc6
--- /dev/null
+++ b/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/example/ClientSseExample.java
@@ -0,0 +1,191 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse.example;
+
+import java.net.URI;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+
+import org.apache.hc.client5.http.config.TlsConfig;
+import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
+import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder;
+import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManager;
+import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder;
+import org.apache.hc.client5.http.sse.EventSource;
+import org.apache.hc.client5.http.sse.EventSourceConfig;
+import org.apache.hc.client5.http.sse.EventSourceListener;
+import org.apache.hc.client5.http.sse.ExponentialJitterBackoff;
+import org.apache.hc.client5.http.sse.SseExecutor;
+import org.apache.hc.client5.http.sse.SseParser;
+import org.apache.hc.core5.concurrent.DefaultThreadFactory;
+import org.apache.hc.core5.http2.HttpVersionPolicy;
+import org.apache.hc.core5.http2.config.H2Config;
+import org.apache.hc.core5.reactor.IOReactorConfig;
+import org.apache.hc.core5.util.TimeValue;
+
+public final class ClientSseExample {
+
+ public static void main(final String[] args) throws Exception {
+ final URI uri = URI.create(args.length > 0
+ ? args[0]
+ : "https://stream.wikimedia.org/v2/stream/recentchange");
+
+ // 1) IO & pool tuned for low latency + H2 multiplexing
+ final IOReactorConfig ioCfg = IOReactorConfig.custom()
+ .setIoThreadCount(Math.max(2, Runtime.getRuntime().availableProcessors()))
+ .setSoKeepAlive(true)
+ .setTcpNoDelay(true)
+ .build();
+
+ final PoolingAsyncClientConnectionManager connMgr =
+ PoolingAsyncClientConnectionManagerBuilder.create()
+ .useSystemProperties()
+ .setMessageMultiplexing(true) // HTTP/2 stream multiplexing
+ .setMaxConnPerRoute(32)
+ .setMaxConnTotal(256)
+ .setDefaultTlsConfig(
+ TlsConfig.custom()
+ .setVersionPolicy(HttpVersionPolicy.NEGOTIATE) // or FORCE_HTTP_2 / FORCE_HTTP_1
+ .build())
+ .build();
+
+ final CloseableHttpAsyncClient httpClient = HttpAsyncClientBuilder.create()
+ .setIOReactorConfig(ioCfg)
+ .setConnectionManager(connMgr)
+ .setH2Config(H2Config.custom()
+ .setPushEnabled(false)
+ .setMaxConcurrentStreams(256)
+ .build())
+ .useSystemProperties()
+ .evictExpiredConnections()
+ .evictIdleConnections(TimeValue.ofMinutes(1))
+ .build();
+
+ // 2) Scheduler for reconnects (multithreaded; cancels are purged)
+ final ScheduledThreadPoolExecutor scheduler =
+ new ScheduledThreadPoolExecutor(4, new DefaultThreadFactory("sse-backoff", true));
+ scheduler.setRemoveOnCancelPolicy(true);
+
+ // 3) Callback executor (direct = lowest latency; swap for a small pool if your handler is heavy)
+ final Executor callbacks = Runnable::run;
+
+ // 4) Default EventSource policy (backoff + unlimited retries)
+ final EventSourceConfig defaultCfg = EventSourceConfig.builder()
+ .backoff(new ExponentialJitterBackoff(500L, 30_000L, 2.0, 250L))
+ .maxReconnects(-1)
+ .build();
+
+ // 5) Default headers for all streams
+ final Map defaultHeaders = new HashMap<>();
+ defaultHeaders.put("User-Agent", "Apache-HttpClient-SSE/5.x");
+ defaultHeaders.put("Accept-Language", "en");
+
+ // 6) Build SSE executor with BYTE parser (minimal allocations)
+ final SseExecutor exec = SseExecutor.custom()
+ .setHttpClient(httpClient)
+ .setScheduler(scheduler)
+ .setCallbackExecutor(callbacks)
+ .setEventSourceConfig(defaultCfg)
+ .setDefaultHeaders(defaultHeaders)
+ .setParserStrategy(SseParser.BYTE)
+ .build();
+
+ // 7) Listener
+ final CountDownLatch done = new CountDownLatch(1);
+ final EventSourceListener listener = new EventSourceListener() {
+ @Override
+ public void onOpen() {
+ System.out.println("[SSE] open: " + uri);
+ }
+
+ @Override
+ public void onEvent(final String id, final String type, final String data) {
+ final String shortData = data.length() > 120 ? data.substring(0, 120) + "…" : data;
+ System.out.printf(Locale.ROOT, "[SSE] %s id=%s %s%n",
+ type != null ? type : "message", id, shortData);
+ }
+
+ @Override
+ public void onClosed() {
+ System.out.println("[SSE] closed");
+ done.countDown();
+ }
+
+ @Override
+ public void onFailure(final Throwable t, final boolean willReconnect) {
+ System.err.println("[SSE] failure: " + t + " willReconnect=" + willReconnect);
+ if (!willReconnect) {
+ done.countDown();
+ }
+ }
+ };
+
+ // 8) Per-stream overrides (optional)
+ final Map perStreamHeaders = new HashMap<>();
+ final EventSourceConfig perStreamCfg = EventSourceConfig.builder()
+ .backoff(new ExponentialJitterBackoff(750L, 20_000L, 2.0, 250L))
+ .maxReconnects(-1)
+ .build();
+
+ final EventSource es = exec.open(
+ uri,
+ perStreamHeaders,
+ listener,
+ perStreamCfg,
+ SseParser.BYTE,
+ scheduler,
+ callbacks
+ );
+
+ // Clean shutdown on Ctrl+C
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+ try {
+ es.cancel();
+ } catch (final Exception ignore) {
+ }
+ try {
+ exec.close();
+ } catch (final Exception ignore) {
+ }
+ try {
+ scheduler.shutdownNow();
+ } catch (final Exception ignore) {
+ }
+ }, "sse-shutdown"));
+
+ es.start();
+ done.await();
+
+ es.cancel();
+ exec.close();
+ scheduler.shutdownNow();
+ }
+}
diff --git a/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/example/performance/LogHistogram.java b/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/example/performance/LogHistogram.java
new file mode 100644
index 0000000000..e40c9af937
--- /dev/null
+++ b/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/example/performance/LogHistogram.java
@@ -0,0 +1,96 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse.example.performance;
+
+import java.util.concurrent.atomic.LongAdder;
+
+/**
+ * Lock-free-ish log2 histogram in nanoseconds. 0..~2^63 range, 64 buckets.
+ */
+final class LogHistogram {
+ private final LongAdder[] buckets = new LongAdder[64];
+
+ LogHistogram() {
+ for (int i = 0; i < buckets.length; i++) {
+ buckets[i] = new LongAdder();
+ }
+ }
+
+ /**
+ * Record a non-negative value in nanoseconds (negative values ignored).
+ */
+ void recordNanos(final long v) {
+ if (v <= 0) {
+ buckets[0].increment();
+ return;
+ }
+ final int idx = 63 - Long.numberOfLeadingZeros(v);
+ buckets[Math.min(idx, 63)].increment();
+ }
+
+ /**
+ * Snapshot percentiles in nanoseconds.
+ */
+ Snapshot snapshot() {
+ final long[] c = new long[64];
+ long total = 0;
+ for (int i = 0; i < 64; i++) {
+ c[i] = buckets[i].sum();
+ total += c[i];
+ }
+ return new Snapshot(c, total);
+ }
+
+ static final class Snapshot {
+ final long[] counts;
+ final long total;
+
+ Snapshot(final long[] counts, final long total) {
+ this.counts = counts;
+ this.total = total;
+ }
+
+ long percentile(final double p) { // p in [0,100]
+ if (total == 0) {
+ return 0;
+ }
+ long rank = (long) Math.ceil((p / 100.0) * total);
+ if (rank <= 0) {
+ rank = 1;
+ }
+ long cum = 0;
+ for (int i = 0; i < 64; i++) {
+ cum += counts[i];
+ if (cum >= rank) {
+ // return upper bound of bucket (approx)
+ return (i == 63) ? Long.MAX_VALUE : ((1L << (i + 1)) - 1);
+ }
+ }
+ return (1L << 63) - 1;
+ }
+ }
+}
diff --git a/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/example/performance/SsePerfClient.java b/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/example/performance/SsePerfClient.java
new file mode 100644
index 0000000000..2a0b22f895
--- /dev/null
+++ b/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/example/performance/SsePerfClient.java
@@ -0,0 +1,366 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse.example.performance;
+
+import java.net.URI;
+import java.util.Locale;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.apache.hc.client5.http.config.TlsConfig;
+import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
+import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder;
+import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManager;
+import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder;
+import org.apache.hc.client5.http.sse.EventSource;
+import org.apache.hc.client5.http.sse.EventSourceListener;
+import org.apache.hc.client5.http.sse.SseExecutor;
+import org.apache.hc.client5.http.sse.SseParser;
+import org.apache.hc.core5.concurrent.DefaultThreadFactory;
+import org.apache.hc.core5.http2.HttpVersionPolicy;
+import org.apache.hc.core5.http2.config.H2Config;
+import org.apache.hc.core5.reactor.IOReactorConfig;
+import org.apache.hc.core5.util.TimeValue;
+
+/**
+ * Scaled SSE client harness with nano-time calibration and batched ramp-up.
+ *
+ * Args:
+ * uri connections durationSec parser(BYTE|CHAR) h2(true|false) openBatch openBatchPauseMs
+ *
+ * Examples:
+ * # Local server @ 50 eps per conn, 64B payload:
+ * # java ... SsePerfServer 8089
+ * java ... SsePerfClient ... 2000 120 BYTE false 200 100
+ *
+ * # External SSE (H2 negotiation):
+ * java ... SsePerfClient ... 1000 120 BYTE true 100 200
+ */
+public final class SsePerfClient {
+
+ public static void main(final String[] args) throws Exception {
+ final URI uri = URI.create(args.length > 0 ? args[0] : "http://localhost:8089/sse?rate=20&size=64");
+ final int connections = args.length > 1 ? Integer.parseInt(args[1]) : 200;
+ final int durationSec = args.length > 2 ? Integer.parseInt(args[2]) : 60;
+ final SseParser parser = args.length > 3 ? SseParser.valueOf(args[3]) : SseParser.BYTE;
+ final boolean h2 = args.length > 4 ? Boolean.parseBoolean(args[4]) : false;
+ final int openBatch = args.length > 5 ? Integer.parseInt(args[5]) : 200;
+ final int openBatchPauseMs = args.length > 6 ? Integer.parseInt(args[6]) : 100;
+
+ System.out.printf(Locale.ROOT,
+ "Target=%s%nConnections=%d Duration=%ds Parser=%s H2=%s Batch=%d Pause=%dms%n",
+ uri, connections, durationSec, parser, h2, openBatch, openBatchPauseMs);
+
+ // --- Client & pool tuned for fan-out ---
+ final IOReactorConfig ioCfg = IOReactorConfig.custom()
+ .setIoThreadCount(Math.max(2, Runtime.getRuntime().availableProcessors()))
+ .setSoKeepAlive(true)
+ .setTcpNoDelay(true)
+ .build();
+
+ final PoolingAsyncClientConnectionManager connMgr =
+ PoolingAsyncClientConnectionManagerBuilder.create()
+ .useSystemProperties()
+ .setMessageMultiplexing(true) // enable H2 multiplexing if negotiated
+ .setMaxConnPerRoute(Math.max(64, connections))
+ .setMaxConnTotal(Math.max(128, connections))
+ .setDefaultTlsConfig(
+ TlsConfig.custom()
+ .setVersionPolicy(h2 ? HttpVersionPolicy.NEGOTIATE : HttpVersionPolicy.FORCE_HTTP_1)
+ .build())
+ .build();
+
+ final CloseableHttpAsyncClient httpClient = HttpAsyncClientBuilder.create()
+ .setIOReactorConfig(ioCfg)
+ .setConnectionManager(connMgr)
+ .setH2Config(H2Config.custom()
+ .setPushEnabled(false)
+ .setMaxConcurrentStreams(512)
+ .build())
+ .useSystemProperties()
+ .evictExpiredConnections()
+ .evictIdleConnections(TimeValue.ofMinutes(1))
+ .build();
+
+ final ScheduledThreadPoolExecutor scheduler =
+ new ScheduledThreadPoolExecutor(Math.min(8, Math.max(2, Runtime.getRuntime().availableProcessors())),
+ new DefaultThreadFactory("sse-perf-backoff", true));
+ scheduler.setRemoveOnCancelPolicy(true);
+
+ final Executor callbacks = Runnable::run;
+
+ // --- Metrics ---
+ final AtomicInteger openCount = new AtomicInteger();
+ final AtomicInteger connectedNow = new AtomicInteger();
+ final AtomicLong events = new AtomicLong();
+ final AtomicLong reconnects = new AtomicLong();
+ final AtomicLong failures = new AtomicLong();
+ final LogHistogram latencyNs = new LogHistogram();
+
+ // --- SSE executor ---
+ final SseExecutor exec = SseExecutor.custom()
+ .setHttpClient(httpClient)
+ .setScheduler(scheduler)
+ .setCallbackExecutor(callbacks)
+ .setParserStrategy(parser)
+ .build();
+
+ // --- Open connections in batches to avoid thundering herd ---
+ final CountDownLatch started = new CountDownLatch(connections);
+ final CountDownLatch done = new CountDownLatch(connections);
+
+ int opened = 0;
+ while (opened < connections) {
+ final int toOpen = Math.min(openBatch, connections - opened);
+ for (int i = 0; i < toOpen; i++) {
+ final EventSource es = exec.open(uri,
+ newListener(events, reconnects, failures, openCount, connectedNow, latencyNs, done));
+ es.start();
+ started.countDown();
+ }
+ opened += toOpen;
+ if (opened < connections && openBatchPauseMs > 0) {
+ Thread.sleep(openBatchPauseMs);
+ }
+ }
+
+ final long startMs = System.currentTimeMillis();
+ final ScheduledFuture> reporter = scheduler.scheduleAtFixedRate(new Runnable() {
+ long lastEvents = 0;
+ long lastTs = System.currentTimeMillis();
+
+ @Override
+ public void run() {
+ final long now = System.currentTimeMillis();
+ final long ev = events.get();
+ final long deltaE = ev - lastEvents;
+ final long deltaMs = Math.max(1L, now - lastTs);
+ final double eps = (deltaE * 1000.0) / deltaMs;
+
+ final LogHistogram.Snapshot s = latencyNs.snapshot();
+ final long p50us = s.percentile(50) / 1000;
+ final long p95us = s.percentile(95) / 1000;
+ final long p99us = s.percentile(99) / 1000;
+
+ System.out.printf(Locale.ROOT,
+ "t=+%4ds con=%d open=%d ev=%d (%.0f/s) rec=%d fail=%d p50=%dµs p95=%dµs p99=%dµs%n",
+ (int) ((now - startMs) / 1000),
+ connectedNow.get(), openCount.get(), ev, eps,
+ reconnects.get(), failures.get(),
+ p50us, p95us, p99us);
+
+ lastEvents = ev;
+ lastTs = now;
+ }
+ }, 1000, 1000, TimeUnit.MILLISECONDS);
+
+ // --- Run for duration, then shutdown ---
+ started.await();
+ Thread.sleep(Math.max(1, durationSec) * 1000L);
+
+ reporter.cancel(true);
+ scheduler.shutdownNow();
+ exec.close();
+ httpClient.close();
+
+ done.await(5, TimeUnit.SECONDS);
+ System.out.println("DONE");
+ }
+
+ private static EventSourceListener newListener(
+ final AtomicLong events,
+ final AtomicLong reconnects,
+ final AtomicLong failures,
+ final AtomicInteger openCount,
+ final AtomicInteger connectedNow,
+ final LogHistogram latencyNs,
+ final CountDownLatch done) {
+
+ return new EventSourceListener() {
+ // Per-stream calibration state
+ volatile boolean calibrated;
+ volatile long nanoOffset; // clientNano - serverNano
+ volatile long lastArrivalNs;
+
+ @Override
+ public void onOpen() {
+ openCount.incrementAndGet();
+ connectedNow.incrementAndGet();
+ lastArrivalNs = System.nanoTime();
+ calibrated = false;
+ nanoOffset = 0L;
+ }
+
+ @Override
+ public void onEvent(final String id, final String type, final String data) {
+ final long nowNano = System.nanoTime();
+
+ if ("sync".equals(type)) {
+ final long sn = parseFieldLong(data, "tn=");
+ if (sn > 0) {
+ nanoOffset = nowNano - sn;
+ calibrated = true;
+ }
+ return;
+ }
+
+ events.incrementAndGet();
+
+ // Prefer monotonic tn if calibrated
+ final long sn = parseFieldLong(data, "tn=");
+ if (calibrated && sn > 0) {
+ final long oneWayNs = nowNano - (sn + nanoOffset);
+ if (oneWayNs > 0) {
+ latencyNs.recordNanos(oneWayNs);
+ }
+ } else {
+ // Fallbacks
+ final long ms = parseFieldLong(data, "t=");
+ if (ms > 0) {
+ final long oneWayNs = (System.currentTimeMillis() - ms) * 1_000_000L;
+ if (oneWayNs > 0) {
+ latencyNs.recordNanos(oneWayNs);
+ }
+ } else {
+ final long delta = nowNano - lastArrivalNs;
+ if (delta > 0) {
+ latencyNs.recordNanos(delta);
+ }
+ }
+ }
+ lastArrivalNs = nowNano;
+ }
+
+ @Override
+ public void onClosed() {
+ connectedNow.decrementAndGet();
+ done.countDown();
+ }
+
+ @Override
+ public void onFailure(final Throwable t, final boolean willReconnect) {
+ failures.incrementAndGet();
+ if (willReconnect) {
+ reconnects.incrementAndGet();
+ }
+ }
+ };
+ }
+
+ private static long parseFieldLong(final String data, final String keyEq) {
+ if (data == null) {
+ return -1;
+ }
+ final int i = data.indexOf(keyEq);
+ if (i < 0) {
+ return -1;
+ }
+ final int j = i + keyEq.length();
+ int end = j;
+ while (end < data.length()) {
+ final char c = data.charAt(end);
+ if (c < '0' || c > '9') {
+ break;
+ }
+ end++;
+ }
+ try {
+ return Long.parseLong(data.substring(j, end));
+ } catch (final Exception ignore) {
+ return -1;
+ }
+ }
+
+ // ---- Self-contained log2 histogram in nanoseconds ----
+ static final class LogHistogram {
+ private final java.util.concurrent.atomic.LongAdder[] buckets = new java.util.concurrent.atomic.LongAdder[64];
+
+ LogHistogram() {
+ for (int i = 0; i < buckets.length; i++) {
+ buckets[i] = new java.util.concurrent.atomic.LongAdder();
+ }
+ }
+
+ void recordNanos(final long v) {
+ if (v <= 0) {
+ buckets[0].increment();
+ return;
+ }
+ int idx = 63 - Long.numberOfLeadingZeros(v);
+ if (idx < 0) {
+ idx = 0;
+ }
+ else if (idx > 63) {
+ idx = 63;
+ }
+ buckets[idx].increment();
+ }
+
+ Snapshot snapshot() {
+ final long[] c = new long[64];
+ long total = 0;
+ for (int i = 0; i < 64; i++) {
+ c[i] = buckets[i].sum();
+ total += c[i];
+ }
+ return new Snapshot(c, total);
+ }
+
+ static final class Snapshot {
+ final long[] counts;
+ final long total;
+
+ Snapshot(final long[] counts, final long total) {
+ this.counts = counts;
+ this.total = total;
+ }
+
+ long percentile(final double p) {
+ if (total == 0) {
+ return 0;
+ }
+ long rank = (long) Math.ceil((p / 100.0) * total);
+ if (rank <= 0) {
+ rank = 1;
+ }
+ long cum = 0;
+ for (int i = 0; i < 64; i++) {
+ cum += counts[i];
+ if (cum >= rank) {
+ return (i == 63) ? Long.MAX_VALUE : ((1L << (i + 1)) - 1);
+ }
+ }
+ return (1L << 63) - 1;
+ }
+ }
+ }
+}
diff --git a/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/example/performance/SsePerfServer.java b/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/example/performance/SsePerfServer.java
new file mode 100644
index 0000000000..cfd2d84e1a
--- /dev/null
+++ b/httpclient5-sse/src/test/java/org/apache/hc/client5/http/sse/example/performance/SsePerfServer.java
@@ -0,0 +1,250 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.sse.example.performance;
+
+import java.io.BufferedOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.LockSupport;
+
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.impl.bootstrap.HttpServer;
+import org.apache.hc.core5.http.impl.bootstrap.ServerBootstrap;
+import org.apache.hc.core5.http.io.HttpRequestHandler;
+import org.apache.hc.core5.http.io.entity.AbstractHttpEntity;
+
+/**
+ * Scaled local SSE server (HTTP/1.1) implemented with Apache HttpComponents Core 5 classic server.
+ *
+ * Endpoint: {@code /sse}
+ *
Query params:
+ *
+ * - {@code rate} – events/sec per connection (default: 20)
+ * - {@code size} – payload size (bytes) inside {@code data:} (default: 64)
+ * - {@code sync} – send an {@code event: sync} with server nano time every N seconds (default: 10; 0 disables)
+ *
+ *
+ * Run (IntelliJ):
+ *
+ * - Program arguments: {@code 8089}
+ * - VM options (optional, GC/tuning):
+ * {@code -Xss256k -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -Xms1g -Xmx1g}
+ *
+ *
+ * Example client test:
+ *
+ * curl -N "..."
+ *
+ */
+public final class SsePerfServer {
+
+ private SsePerfServer() {
+ }
+
+ public static void main(final String[] args) throws Exception {
+ final int port = args.length > 0 ? Integer.parseInt(args[0]) : 8089;
+
+ final HttpRequestHandler sseHandler = (request, response, context) -> {
+ final URI uri = URI.create(request.getRequestUri());
+ final Map q = parseQuery(uri.getRawQuery());
+ final int rate = parseInt(q.get("rate"), 20);
+ final int size = Math.max(1, parseInt(q.get("size"), 64));
+ final int syncSec = Math.max(0, parseInt(q.get("sync"), 10));
+
+ response.setCode(HttpStatus.SC_OK);
+ response.addHeader("Content-Type", "text/event-stream");
+ response.addHeader("Cache-Control", "no-cache");
+ response.addHeader("Connection", "keep-alive");
+
+ response.setEntity(new SseStreamEntity(rate, size, syncSec));
+ };
+
+ final HttpServer server = ServerBootstrap.bootstrap()
+ .setListenerPort(port)
+ .register("/sse", sseHandler)
+ .create();
+
+ Runtime.getRuntime().addShutdownHook(new Thread(server::stop, "sse-server-stop"));
+
+ server.start();
+
+ System.out.printf(Locale.ROOT, "[SSE-SERVER] listening on %d%n", port);
+ System.out.println("[SSE-SERVER] try: curl -N \"http://localhost:" + port + "/sse?rate=50&size=64\"");
+ }
+
+ /**
+ * Streaming entity that writes an infinite SSE stream with tight nanosecond scheduling.
+ */
+ private static final class SseStreamEntity extends AbstractHttpEntity {
+
+ private final int rate;
+ private final int size;
+ private final int syncSec;
+
+ SseStreamEntity(final int rate, final int size, final int syncSec) {
+ super(ContentType.TEXT_EVENT_STREAM, null, true); // chunked
+ this.rate = rate;
+ this.size = size;
+ this.syncSec = syncSec;
+ }
+
+ @Override
+ public long getContentLength() {
+ return -1;
+ }
+
+ @Override
+ public void writeTo(final OutputStream outStream) throws IOException {
+ // buffered writes; still flush each event to keep latency low
+ final BufferedOutputStream os = new BufferedOutputStream(outStream, 8192);
+
+ // one-time random payload (base64) of requested size
+ final byte[] pad = new byte[size];
+ ThreadLocalRandom.current().nextBytes(pad);
+ final String padB64 = Base64.getEncoder().encodeToString(pad);
+
+ // initial sync with server monotonic time in nanoseconds
+ long nowNano = System.nanoTime();
+ writeAndFlush(os, "event: sync\ndata: tn=" + nowNano + "\n\n");
+
+ // schedule params
+ final long intervalNanos = (rate <= 0) ? 0L : (1_000_000_000L / rate);
+ long seq = 0L;
+ long next = System.nanoTime();
+ long nextSync = syncSec > 0 ? System.nanoTime() + TimeUnit.SECONDS.toNanos(syncSec) : Long.MAX_VALUE;
+
+ try {
+ while (!Thread.currentThread().isInterrupted()) {
+ nowNano = System.nanoTime();
+
+ // periodic sync tick
+ if (nowNano >= nextSync) {
+ writeAndFlush(os, "event: sync\ndata: tn=" + nowNano + "\n\n");
+ nextSync = nowNano + TimeUnit.SECONDS.toNanos(syncSec);
+ }
+
+ if (intervalNanos == 0L || nowNano >= next) {
+ // emit one event
+ final long tMs = System.currentTimeMillis();
+ final long tn = System.nanoTime();
+ final String frame =
+ "id: " + (++seq) + "\n" +
+ "event: m\n" +
+ "data: t=" + tMs + ",tn=" + tn + ",p=" + padB64 + "\n\n";
+ writeAndFlush(os, frame);
+
+ if (intervalNanos > 0L) {
+ // advance by exactly one period to avoid drift
+ next += intervalNanos;
+ // if we've fallen far behind (e.g. GC), realign to avoid bursts
+ if (nowNano - next > intervalNanos * 4L) {
+ next = nowNano + intervalNanos;
+ }
+ }
+ } else {
+ // tight, short sleep with nanosecond resolution
+ final long sleepNs = next - nowNano;
+ if (sleepNs > 0L) {
+ LockSupport.parkNanos(sleepNs);
+ }
+ }
+ }
+ } catch (final IOException closed) {
+ // client disconnected; finish quietly
+ }
+ }
+
+ @Override
+ public boolean isRepeatable() {
+ return false;
+ }
+
+ @Override
+ public InputStream getContent() throws IOException, UnsupportedOperationException {
+ return null;
+ }
+
+ @Override
+ public boolean isStreaming() {
+ return true;
+ }
+
+ @Override
+ public void close() throws IOException { /* no-op */ }
+
+ private static void writeAndFlush(final BufferedOutputStream os, final String s) throws IOException {
+ os.write(s.getBytes(StandardCharsets.UTF_8));
+ os.flush();
+ }
+ }
+
+ // -------- helpers --------
+
+ private static int parseInt(final String s, final int def) {
+ if (s == null) {
+ return def;
+ }
+ try {
+ return Integer.parseInt(s);
+ } catch (final Exception ignore) {
+ return def;
+ }
+ }
+
+ private static Map parseQuery(final String raw) {
+ final Map m = new HashMap<>();
+ if (raw == null || raw.isEmpty()) {
+ return m;
+ }
+ final String[] parts = raw.split("&");
+ for (final String part : parts) {
+ final int eq = part.indexOf('=');
+ if (eq > 0) {
+ m.put(urlDecode(part.substring(0, eq)), urlDecode(part.substring(eq + 1)));
+ }
+ }
+ return m;
+ }
+
+ private static String urlDecode(final String s) {
+ try {
+ return java.net.URLDecoder.decode(s, StandardCharsets.UTF_8.name());
+ } catch (final Exception e) {
+ return s;
+ }
+ }
+}
diff --git a/httpclient5-sse/src/test/resources/log4j2-debug.xml.template b/httpclient5-sse/src/test/resources/log4j2-debug.xml.template
new file mode 100644
index 0000000000..3386294619
--- /dev/null
+++ b/httpclient5-sse/src/test/resources/log4j2-debug.xml.template
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/httpclient5-sse/src/test/resources/log4j2.xml b/httpclient5-sse/src/test/resources/log4j2.xml
new file mode 100644
index 0000000000..dff8a53814
--- /dev/null
+++ b/httpclient5-sse/src/test/resources/log4j2.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/DefaultAsyncClientConnectionOperator.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/DefaultAsyncClientConnectionOperator.java
index d8ee3b90a2..e0f0d5050d 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/DefaultAsyncClientConnectionOperator.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/DefaultAsyncClientConnectionOperator.java
@@ -212,7 +212,7 @@ public void cancelled() {
return future;
}
- // The IOReactor does not support AFUNIXSocketChannel from JUnixSocket, so if a Unix domain socket was configured,
+ // The IOReactor does not support AFUNIXSocketChannel from JUnixSocket, so if a Unix domain socket was configured,
// we must use JEP 380 sockets and addresses.
private static SocketAddress createUnixSocketAddress(final Path socketPath) {
try {
diff --git a/pom.xml b/pom.xml
index d841a29aa3..f0ba94497b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -125,6 +125,11 @@
httpclient5-fluent
${project.version}
+
+ org.apache.httpcomponents.client5
+ httpclient5-sse
+ ${project.version}
+
org.slf4j
slf4j-api
@@ -221,6 +226,7 @@
httpclient5
+ httpclient5-sse
httpclient5-fluent
httpclient5-cache
httpclient5-testing