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

+ * + * + *

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):

+ *
    + *
  1. Compute the exponential cap: {@code cap = clamp(baseMs * factor^(attempt-1))}.
  2. + *
  3. Pick a uniformly distributed random value in {@code [0, cap]} (full jitter).
  4. + *
  5. Clamp the result into {@code [minMs, maxMs]}.
  6. + *
+ * + *

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