Repository navigation
Conversation
…mpression SSE clients (OKHttp, Jersey 2, Jersey 3): - Preserve Last-Event-ID across reconnects and send it back to the server; clear it when a server evaluated context change alters the x-featurehub header. - A context change no longer stops the retryer (Jersey), so the client can reconnect with the new context. - Connections replaced by a context change or closed are ignored: late events, failures and closes from them no longer update features, record ids or trigger reconnects (avoids duplicate/leaked OKHttp connections). - Context changes and event processing are serialised so an event for the old context cannot be applied part way through a context change. - Jersey: close() always stops the retryer; a server bye/close now triggers a reconnect; superseded connections are closed; close() and context changes close the connection in the background instead of blocking until the read times out. - Jersey 3 reports its X-SDK header as Java-Jersey3. - OKHttp SSE uses the retryer's connect/read timeouts instead of OKHttp's 10 second read timeout default, which dropped idle streams. - The SSE read timeout default is 3 minutes (180000ms), matching the README. REST clients: - Jersey 2/3 REST clients request gzip/deflate and decompress responses (OKHttp already does this transparently). Also includes example and pom updates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rvowles
force-pushed
the
fix-sse-last-event-id
branch
from
October 16, 2026 04:00
4932807 to
fa8837b
Compare
OKHttp SSE client: - Read the context and claim the connection atomically when connecting, and only connect if no connection is current, so a reconnect racing a context change can no longer replace the newer connection or send one context's header with another's Last-Event-ID. - Cancel any previous connection and cancel a connection that was superseded while it was being made, so no connection is left open. - Release the connection on close/failure under the lock before reconnecting, so a concurrent context change and a scheduled reconnect do not both connect. - The connection's bye flag is per connection. Jersey 2/3 SSE clients: - Read the context header and Last-Event-ID together under the lock and pass them to makeEventSource(header, lastEventId), so the request matches the context that is checked after connecting. - When the retryer stops, close the loop's own connection directly. - Close connections on a small shared daemon pool rather than a new thread per close. Both: - Only the "is this connection current" check and the id are done under the lock; features and readiness futures are processed outside it, so listeners and future callbacks that change context cannot deadlock. - waitingClients is thread safe and futures are removed once completed. Examples: request/response payload logging is at debug (FINE) rather than INFO; tidy up a stray comma and comment indentation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OKHttp: - context change resets the connection and decides whether to wait in one step, so a racing reconnect can't complete the change before the new context's features arrive - failing to create the connection now retries with backoff (or, for an OKHttp version conflict, completes waiting callers) instead of hanging - once closed, the client never connects again and completes waiting callers Jersey 2 and 3: - a connection replaced by a context change stops when its read returns nothing, rather than reading again and holding up the new connection - track a pending connection so repeated polls, reconnects and context changes can't queue duplicate connections - background closes run concurrently instead of queueing behind each other - once closed, the client never connects again and completes waiting callers Both: - only record Last-Event-ID once an event has been applied, so the server resends an event we failed to apply Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
All SSE clients: - waiting callers are only released by the current connection, so features from a connection replaced while they were being applied can't tell a new context it is ready - once the retryer has stopped (e.g. a bad API key or edge.stale), a context change or poll no longer connects and completes immediately - closing is checked and set under the connection lock, so a racing context change can't wait forever or hit the shut-down executor OKHttp: - the OkHttpClient is created under a lock, so two connections being made at once can't each build (and leak) one Jersey 2 and 3: - when the server rejects the API key, mark the repository failed and release waiting callers instead of leaving them hanging - simplify the end-of-connection handling, which had an unreachable branch Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eatures All SSE clients: - when the current connection fails, can't be made or is closed by the server, release waiting callers with the current readiness rather than leaving them waiting for a reconnect that may never come (e.g. Edge is down, or the retryer has stopped) - callers wait for the current connection's first features, rather than being released as soon as a connection exists - waitingClients is a plain list, as it is only used under the lock OKHttp: - 401/403 responses stop the retryer as a permission failure, and other 4xx responses as an unknown API key, instead of reconnecting in a tight loop Jersey 2 and 3: - check 401/403 before other client errors, so they are reported as a permission failure rather than an unknown API key Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the server rejects our requests (400/401/403/404), or we have been closed, the client previously set makeRequests = false but nothing checked it, so it kept polling. Now poll (and so contextChange) makes no further requests, and isStopped reports it, so active polling stops scheduling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The polling delegate skipped closing its client when the client had stopped (e.g. the server rejected our requests or the usage limit was reached), so the client's resources (OKHttp's thread pools) were left to time out by themselves. Both REST clients' close is safe to call when stopped, so always close. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A failure before the connection opened (an unresolvable host, a 5xx from a proxy, etc.) was treated as a working stream being dropped, which reconnects immediately by default, so the client reconnected in a tight loop (over 100,000 attempts in 3 seconds for an unresolvable host). It now backs off as it does for a refused connection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SSE clients: - OKHttp only treats 400/404 as an unknown API key (and 401/403 as no permission). Other 4xx responses, such as 429 too many requests, back off and retry rather than stopping the client for good. - When a connection is lost, those waiting on it are taken in the same locked step that gives it up, so a caller who starts waiting on the replacement isn't released before its features arrive. If the retryer has stopped, everyone is released, as no reconnect is coming. - An event that fails to apply releases those waiting, rather than leaving them waiting while the stream stays open. - OKHttp: a connection that was starting when the client closed no longer builds a new OkHttpClient that nothing shuts down. - Jersey: closing the client also closes its JAX-RS Client (in the background, as it can block while being read). Jersey REST clients, to match the OKHttp REST client: - stop on 400/401/403/404, whether the status is returned or thrown - stop when closed - make no further requests once stopped Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test server's threads add to the map of server outputs while the test's cleanup iterates it, which failed CI with a ConcurrentModificationException. Use a ConcurrentHashMap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- While the retryer is backing off after a lost connection, a poll or context build (OKHttp and Jersey) no longer connects early; it leaves the reconnect to the retryer. A server evaluated context change still connects straight away for the new context. - Callers only wait until we have features for the current context. A reconnect (e.g. after BYE) no longer makes every context build wait for the new connection to send features again. - Jersey SSE: only 400/404 and 401/403 stop retrying; other client errors (e.g. 429, 408) back off and retry, as OKHttp does. - Jersey SSE: a read failure on a stream that has sent us something (e.g. an idle read timeout) reconnects straight away rather than backing off. - OKHttp REST: polling now uses the buildRequest() hook. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…retryer - OKHttp and Jersey SSE: a stream that ends before sending anything (e.g. a proxy that accepts and immediately closes) now backs off as a connection failure instead of reconnecting in a tight loop. - Jersey SSE: connections are made and read on the client's own thread rather than the retryer's, so a context change during a backoff no longer waits for the backoff to finish. - Jersey SSE: a connection made for an earlier context is discarded even if the context has changed back since (A -> B -> A), so it can't resume from a stale Last-Event-ID and leave callers waiting. - Jersey SSE: network failures wrapped by Jersey (ProcessingException) are recognised by their cause, so a refused or unresolvable connection backs off as a connection failure. - EdgeRetryer: the stopped flags are volatile, as callers' threads now read them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t thread - OKHttp and Jersey SSE: count the reconnects asked of the retryer, and only let the last one connect. A context change can connect while a backoff is running; if that connection also fails, the earlier backoff's reconnect no longer skips the later backoff. - Jersey SSE: the connection thread is a daemon, so a client that is never closed doesn't keep the application running. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- OKHttp and Jersey SSE: a stream counts as working if it sent anything other than an ack or config (including bye), or stayed open for at least the connect timeout (minimum 1s). A stream resumed with a Last-Event-ID the server is up to date with legitimately sends nothing until bye or a disconnect, so a quiet stream that times out or is dropped later reconnects straight away. One that sends nothing, or only an ack, and is dropped quickly backs off. - Only a full feature set (or a failure) marks the context as having its features; other events and events that fail to apply still release those waiting, but later callers keep waiting. - Last-Event-ID is only sent once we have the context's features, so the server never treats us as up to date when we aren't. - Jersey SSE: a failed connection attempt for an earlier context no longer counts against the new one; we connect again straight away. - Jersey SSE: closing while connecting is not reported as a failure. - Jersey SSE: any non-terminal failure to connect (5xx, timeouts, wrapped network errors) backs off as a connection failure, as OKHttp does. - Jersey SSE: close() stops the executor returned by connectExecutor(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 18, 2026
- New StreamingConnectionTracker in client-java-core holds the connection, context, waiting-client and retry state that was copied across the OKHttp, Jersey2 and Jersey3 SSE clients. The clients now only make and read connections. Jersey uses the same claim-then-connect model as OKHttp. - Client evaluated keys never wait on contextChange or poll; they only make sure the connection has started (CLAUDE.md rule 2). - Jersey connections run on a shared cached pool of daemon threads, so a new context's connection no longer queues behind the old context's blocked read, and there is no thread per client. close() interrupts that client's connection rather than shutting a pool down. - Jersey catches any Throwable while connecting: a LinkageError gives up without retrying, anything else is retried, and nobody is left waiting. - The repository is only marked Failed when no reconnect is coming (retryer stopped, or a library clash), not on every transient failure. - EdgeRetryer.cancelReconnects() interrupts a backoff in progress so it never reconnects. A server evaluated context change that overrides a pending retry calls it, replacing the scheduledRetries counters and stopping a stale backoff holding later reconnects up. - A failure event with no data is now recorded in the repository by every client. - Only a 400 or 404 is treated as permanent, in every SSE and REST client (EdgeStatus). 401, 403, 429, 5xx and network errors keep readiness as it is and are retried. - README: document the 400/404 rule and that streaming client evaluated init() doesn't wait for features. CLAUDE.md: client vs server evaluation isolation model. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- StreamingConnectionTracker queues a lost connection's reconnect, and a context change cancels the retryer's reconnects, while holding the lock, so a cancel can't run between a connection being released and its reconnect being queued. - A stream that worked without sending features (e.g. a resumed one the server is up to date with) reports success once when it is lost, so the retryer's backoff resets. - A cancelled backoff no longer grows the backoff multiplier. - 401 and 403 are permanent stops again in every SSE and REST client, alongside 400 and 404 (a 401/403 is often a wrong url). SSE reports them as FAILURE, 400/404 as API_KEY_NOT_FOUND. - EdgeRetryer always waits featurehub.edge.bye-backoff-ms (default 300ms, EdgeRetryerBuilder.withByeBackoffMs) after a bye before reconnecting, on top of server-by-reconnect-ms, so a server that says bye straight away can't cause a tight reconnect loop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Move the Jersey SSE connect/read/close loop into a new core BlockingStreamEdgeService (no Jersey dependency); both Jersey clients are now thin subclasses that open the stream and map a failure's status. - Closing interrupts every connection task, including replaced connections. - After 3 streams in a row that only counted as working by staying open, sending nothing, later ones are treated as a CONNECTION_FAILURE so the backoff applies (e.g. a proxy that passes nothing and drops idle connections). Any real event, or a context change, resets the count. - EdgeRetryer's backoff multiplier is volatile. - Deprecate SERVER_READ_TIMEOUT (no supplied client reports it) and document that streaming retryers must implement cancelReconnects(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes across the SSE and REST clients (OKHttp, Jersey 2, Jersey 3).
Shared Jersey read loop, quiet-stream backoff and tidy-ups (latest round)
BlockingStreamEdgeServiceinclient-java-core, for any library whose connections are read by blocking. Core sees the library only through smallStream/Eventinterfaces, so it gains no Jersey (or other) dependency. EachJerseySSEClientis now a thin subclass that opens the stream, says what HTTP status a failure carries and closes its JAX-RSClient.close()cancels every connection task the client started, including connections a context change replaced that are still finishing, not just the current one. Finished tasks are forgotten as new connections are made.StreamingConnectionTracker.MAX_QUIET_STREAMS), later ones count as aCONNECTION_FAILURE, so the normal backoff applies and grows. Any real event (including abye) or a server-evaluated context change resets the count.EdgeRetryer's backoff multiplier is nowvolatile, so a reset made on one thread can't be lost on another.cancelReconnects()docs: its javadoc now says a retryer used with the streaming clients must implement it (the default does nothing), and what goes wrong if it doesn't.SERVER_READ_TIMEOUTdeprecated: no supplied client reports it any more. It is kept, and still handled byEdgeRetryer, as otherEdgeServiceimplementations may report it.Shared connection handling, client vs server evaluation, and failure rules
StreamingConnectionTrackerinclient-java-core. The clients only make and read connections. Jersey now uses the same claim-then-connect model as OKHttp. A newEdgeStatusholds the HTTP status rule used by every client.contextChangeandpollonly make sure the connection has started and return straight away. With streaming,init()andbuild().get()therefore return before features arrive. UsewaitForReady()or a readiness listener if you need them. Server-evaluated contexts still wait for their own features. The REST path is unchanged.Failed. SSE reports 400/404 as an unknown API key (API_KEY_NOT_FOUND) and 401/403 as not permitted (FAILURE), which is often a wrong URL. 429, 5xx, network errors and non-304 redirects keep readiness as it is and are retried.Failedonly when no reconnect is coming: the repository is markedFailedonly once the retryer has stopped or a library clash means retrying can't help. A brief failure at startup leaves readiness atNotReady(previously OKHttp, and in this PR Jersey, setFailedon every lost connection while stillNotReady).EdgeRetryer.cancelReconnects()(a new default method onEdgeRetryService) interrupts a backoff in progress so it never reconnects. A server-evaluated context change that overrides a pending retry calls it. This replaces thescheduledRetriescounters, and stops an old backoff holding a later reconnect up behind it on the retryer's single thread. The tracker queues a lost connection's reconnect, and cancels reconnects on a context change, while holding its lock, so a cancel can never run between a connection being released and its reconnect being queued. A cancelled backoff doesn't grow the backoff multiplier.bye, the SSE clients always waitfeaturehub.edge.bye-backoff-ms(default 300ms,EdgeRetryerBuilder.withByeBackoffMs()) before reconnecting, on top offeaturehub.edge.server-by-reconnect-ms. A stream that says bye straight away counts as working, so without this a misbehaving server could cause a tight reconnect loop.close()no longer leaves an idle thread behind.close()interrupts that client's connection rather than shutting a pool down. The constructor warning about context changes taking up to the read timeout has been removed.Errors while connecting: anyThrowableis caught. ALinkageError(e.g. a library version clash) gives up without retrying and marks us failed; anything else is retried. Previously anErrorleft the connection pending forever and callers hung.SSE: Last-Event-ID
Last-Event-IDwhen reconnecting. Events without an id leave it unchanged.x-featurehubheader, the last event id is cleared, so the new context's stream starts fresh.SSE: context changes and reconnects
RejectedExecutionException.Last-Event-IDtogether and claims the connection in the same step. A reconnect that races a context change therefore can't replace the newer connection or mix one context's header with another's id.bye) doesn't make callers wait for the new connection to send them again. Only a full feature set (or a failure) counts; other events, or one that fails to apply, release the callers already waiting, but later callers still wait.edge.stale), a client never connects again, and context changes and polls complete immediately. Closing is checked and set under the lock, so a racing context change can't wait forever.OkHttpClientis created under a lock, so two connections being made at once can't each build (and leak) one.newContext().build()started a new connection attempt while Edge was down). The reconnect is left to the retryer, and callers get the current readiness rather than waiting. A server-evaluated context change still connects straight away, as the new context needs its own features, and cancels the retryer's pending reconnect. Jersey makes and reads connections on daemon threads of its own rather than the retryer's, so that connection isn't held up behind the retryer's backoff.byecounts), or stayed open for at least the connect timeout (minimum 1s). Losing a working stream reconnects straight away. A stream that sends nothing, or only an ack, and is dropped quickly (e.g. a proxy that accepts the connection and immediately closes it) backs off as a connection failure rather than reconnecting in a tight loop. This matters because a stream resumed with aLast-Event-IDthe server is up to date with legitimately sends nothing until the server saysbyeor a disconnect, so a quiet stream that hits the read timeout (typically 3 minutes) or is dropped by the server reconnects straight away.Last-Event-IDis only sent once we have the context's features. Otherwise the server would treat us as up to date and send nothing, and callers would wait for features that never come.EdgeRetryer's stopped flags are nowvolatile, as callers' threads read them to decide whether to wait.OkHttpClientthat nothing shuts down.close()always stops the retryer.byeor close now triggers a reconnect, using the bye or disconnect timing as appropriate.Last-Event-IDis never kept.ProcessingException) backs off as a connection failure (starting around 5s), as OKHttp does, rather than as a read timeout capped at 30s.close()and context changes close the connection on a shared pool of daemon threads, and closes run concurrently rather than queueing behind each other. Closing a JerseyEventInputwhile another thread is reading it otherwise blocks until the read times out.close()also closes the client's JAX-RSClient, again in the background.makeEventSource()is nowmakeEventSource(header, lastEventId). Subclasses that override it need updating.X-SDKasJava-Jersey3instead ofJava-Jersey2.SSE: timeouts
featurehub.edge.server-sse-read-timeout-ms) is now 3 minutes (180000 ms), which is what the README documents. It was previously 1800000 ms (30 minutes). This also affects the Jersey SSE clients.REST: compression
gzip/deflateand decompress responses. OKHttp already does this by default.REST: stopping when requests are pointless
poll()andcontextChange()return the current readiness without a request, andisStopped()reports it, so active polling stops scheduling.Failedon these.buildRequest()hook (as the SSE client does), so subclass overrides such as extra auth headers apply.close()now always closes the client behind it. Previously it skipped a stopped client (after a rejection or the usage limit being reached), leaving OKHttp's thread pools to time out by themselves.Other
FINE) rather than INFO.java11_changed.txt,release_modules.txtandjava17_changed.txtregenerated withdetect_module_changes.sh.Known limitations
Follow-ups
BlockingStreamEdgeService; the REST clients only shareEdgeStatus)cancelReconnects())Testing
New Spock tests cover:
close()stopping the retryer and not blockingbuildRequest()StreamingConnectionTracker: client-evaluated contexts never waiting, server-evaluated contexts waiting for their features, replaced connections being ignored, the 400/404 rule,Failedonly once the retryer stops, failure events with no data, abandoning a connection, andLast-Event-IDonly resumed once we have featuresErrorwhile connecting not leaving anyone waiting, and a new context's connection not waiting behind the old context's blocked readLast-Event-IDbefore we have featuresBlockingStreamEdgeService: events being applied, the failure status deciding whether we stop, close interrupting every connection task (including replaced ones), finished tasks being forgotten, client-evaluated contexts not waiting, and concurrent closesThe Jersey modules gain
objenesisas a test-scope dependency, so Spock can mock Jersey's SSE classes.All module tests pass locally: core 174, OKHttp 98, Jersey 2 100, Jersey 3 100 (counting each data-driven case), and
./build_only.shbuilds the whole reactor.🤖 Generated with Claude Code