Skip to content

SSE and REST client fixes: Last-Event-ID, reconnects, timeouts and compression - #58

Open
rvowles wants to merge 17 commits into
mainfrom
fix-sse-last-event-id
Open

rvowles wants to merge 17 commits into
mainfrom
fix-sse-last-event-id

Conversation

@rvowles

@rvowles rvowles commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

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)

  • Jersey SSE read loop in core: the connect, read and close loop the two Jersey SSE clients duplicated now lives in a new abstract BlockingStreamEdgeService in client-java-core, for any library whose connections are read by blocking. Core sees the library only through small Stream/Event interfaces, so it gains no Jersey (or other) dependency. Each JerseySSEClient is now a thin subclass that opens the stream, says what HTTP status a failure carries and closes its JAX-RS Client.
  • Close interrupts every connection: 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.
  • Backing off on streams that never send anything: a stream that stays open past the connect timeout counts as working even if it sends nothing, as a resumed stream that is up to date is quiet. Something between us and Edge (e.g. a proxy) that accepts the connection, passes nothing through and drops it once idle therefore made us reconnect every few seconds forever, with no backoff. Now, after 3 such streams in a row that sent nothing at all (StreamingConnectionTracker.MAX_QUIET_STREAMS), later ones count as a CONNECTION_FAILURE, so the normal backoff applies and grows. Any real event (including a bye) or a server-evaluated context change resets the count.
  • Backoff multiplier visibility: EdgeRetryer's backoff multiplier is now volatile, 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_TIMEOUT deprecated: no supplied client reports it any more. It is kept, and still handled by EdgeRetryer, as other EdgeService implementations may report it.

Shared connection handling, client vs server evaluation, and failure rules

  • Shared code in core: the connection, context, waiting-caller and retry bookkeeping that was copied across the three SSE clients now lives in a new StreamingConnectionTracker in client-java-core. The clients only make and read connections. Jersey now uses the same claim-then-connect model as OKHttp. A new EdgeStatus holds the HTTP status rule used by every client.
  • Client-evaluated keys never wait: for client-evaluated keys, contextChange and poll only make sure the connection has started and return straight away. With streaming, init() and build().get() therefore return before features arrive. Use waitForReady() or a readiness listener if you need them. Server-evaluated contexts still wait for their own features. The REST path is unchanged.
  • Only 400, 401, 403 and 404 are permanent, in every client (SSE and REST): they stop requests and, if no features had arrived, mark the repository 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.
  • Failed only when no reconnect is coming: the repository is marked Failed only once the retryer has stopped or a library clash means retrying can't help. A brief failure at startup leaves readiness at NotReady (previously OKHttp, and in this PR Jersey, set Failed on every lost connection while still NotReady).
  • Cancelling stale backoffs: EdgeRetryer.cancelReconnects() (a new default method on EdgeRetryService) interrupts a backoff in progress so it never reconnects. A server-evaluated context change that overrides a pending retry calls it. This replaces the scheduledRetries counters, 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.
  • Backoff reset for quiet streams: 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 as it does when features arrive.
  • Bye backoff: after a bye, the SSE clients always wait featurehub.edge.bye-backoff-ms (default 300ms, EdgeRetryerBuilder.withByeBackoffMs()) before reconnecting, on top of featurehub.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.
  • Jersey threads: each connection runs on its own thread from a shared pool of daemon threads, rather than one thread per client. A new context's connection therefore starts straight away instead of queueing behind the old context's blocked read, and a client that is dropped without 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.
  • Jersey Errors while connecting: any Throwable is caught. A LinkageError (e.g. a library version clash) gives up without retrying and marks us failed; anything else is retried. Previously an Error left the connection pending forever and callers hung.
  • Failure event with no data: OKHttp now records it in the repository, as Jersey does, rather than releasing callers as if features had arrived.
  • Jersey context changes while connecting: each server-evaluated context change claims its own connection. An attempt for an earlier context that completes later is closed rather than reused.

SSE: Last-Event-ID

  • All three SSE clients now record the id of the last event received and send it back as Last-Event-ID when reconnecting. Events without an id leave it unchanged.
  • The id is recorded only once the event has been applied, so if applying it fails, the reconnect has the server send it again.
  • When a server-evaluated context change alters the x-featurehub header, the last event id is cleared, so the new context's stream starts fresh.

SSE: context changes and reconnects

  • Jersey: a context change no longer shuts down the retryer, which previously made the follow-up connection fail with RejectedExecutionException.
  • Stale connections: connections replaced by a context change, or closed, are now ignored. Late events, failures and closes from them no longer update features, record ids or trigger reconnects. In OKHttp, the failure caused by cancelling the old connection previously triggered a second connection that was never closed.
  • Locking: connecting, context changes and the "is this connection still current?" check share a lock.
    • A context change resets the connection and decides whether the caller must wait in one step, so a racing reconnect can't complete the change before the new context's features arrive.
    • When connecting, the client reads the context header and Last-Event-ID together 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.
    • A connection superseded while it was being made is cancelled. In OKHttp, close and failure release the connection before reconnecting, so a context change and a scheduled reconnect don't both connect.
    • Features and readiness futures are processed outside the lock, so listeners and future callbacks that change context can't deadlock. As a trade-off, an event that was already being applied when the context changed can still finish applying. The new context's connection then sends its own full feature set. Only the current connection releases waiting callers, so those old features never tell the new context's callers they are ready.
  • Waiting callers: the list of waiting readiness futures is guarded by the lock, and futures are removed once completed.
    • Callers wait until we have features for the current context, rather than being released as soon as a connection exists. Once we have them, a reconnect (e.g. after a server 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.
    • When the current connection fails, can't be made or is closed by the server, waiting callers are released with the current readiness. They no longer wait for a reconnect that may never come, e.g. while Edge is down or after the retryer has stopped.
    • Those waiting are taken in the same locked step that gives the connection up, so a caller who starts waiting on the replacement isn't released before its features arrive.
    • An event that fails to apply releases those waiting, rather than leaving them waiting while the stream stays open.
  • After close or a terminal stop: once closed, or once the retryer has stopped (e.g. a bad API key or 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.
  • OKHttp client creation: the OkHttpClient is created under a lock, so two connections being made at once can't each build (and leak) one.
  • OKHttp failed connects: if the connection can't even be created (e.g. a bad URL), the client retries with backoff and releases waiting callers. An OKHttp version conflict doesn't retry, as retrying can't fix it.
  • OKHttp connection never opened: a failure before the connection opened (an unresolvable host, a 5xx from a proxy, etc.) now backs off like a refused connection (about 5s, growing to the 30s cap). It was treated as a working stream dropping, which reconnects immediately by default, so the client reconnected in a tight loop: over 100,000 attempts in 3 seconds for an unresolvable host. A working stream that drops still reconnects immediately.
  • Respecting the backoff: while the retryer is backing off after a lost or failed connection, a poll or context build no longer connects early (it previously skipped the backoff, and with client evaluation every 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.
  • Streams that never worked, and quiet streams: a stream counts as working if it sent anything other than an ack or config (a bye counts), 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 a Last-Event-ID the server is up to date with legitimately sends nothing until the server says bye or a disconnect, so a quiet stream that hits the read timeout (typically 3 minutes) or is dropped by the server reconnects straight away.
  • Resuming: Last-Event-ID is 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.
  • Retryer visibility: EdgeRetryer's stopped flags are now volatile, as callers' threads read them to decide whether to wait.
  • Rejected requests: in both OKHttp and Jersey, 401/403 stop the retryer as a permission failure, and 400/404 as an unknown API key. Other 4xx responses, such as 429 too many requests or 408, back off and retry rather than stopping the client for good (Jersey previously stopped on any 4xx).
  • OKHttp close: a connection that was starting when the client closed no longer builds a new OkHttpClient that nothing shuts down.
  • Jersey connection handling:
    • close() always stops the retryer.
    • A server bye or close now triggers a reconnect, using the bye or disconnect timing as appropriate.
    • Superseded connections are closed.
    • A context change that happens while connecting is detected, and the client reconnects with the new context. This holds even if the context changed back again in the meantime, so a connection opened with an earlier context's Last-Event-ID is never kept.
    • Any non-terminal failure to connect (refused, unresolvable, timed out, 429, 5xx, whether or not Jersey wraps it in a ProcessingException) backs off as a connection failure (starting around 5s), as OKHttp does, rather than as a read timeout capped at 30s.
    • A failed connection attempt for an earlier context no longer counts against the new context; the client connects again for the new context straight away.
    • Closing the client while it is connecting is not reported as a failure.
    • When the server rejects us (400/401/403/404), the repository is marked failed and waiting callers are released, instead of hanging.
    • A read failure on a stream that has already sent us something (e.g. it was idle for the read timeout) reconnects straight away, as OKHttp does, rather than backing off for up to 30s.
    • The current connection is claimed before it is made, so repeated polls, reconnects and builds of the same context can't queue duplicate connections.
    • A connection replaced by a context change stops as soon as its read returns, rather than reading again.
  • Jersey non-blocking close: 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 Jersey EventInput while another thread is reading it otherwise blocks until the read times out. close() also closes the client's JAX-RS Client, again in the background.
  • Jersey API change: the protected makeEventSource() is now makeEventSource(header, lastEventId). Subclasses that override it need updating.
  • Jersey 3: now reports X-SDK as Java-Jersey3 instead of Java-Jersey2.

SSE: timeouts

  • The OKHttp SSE client now uses the retryer's connect and read timeouts. Previously it used OKHttp's 10-second default read timeout, so it dropped and reconnected any stream that was idle for 10 seconds.
  • The default SSE read timeout (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

  • The Jersey 2 and 3 REST clients now request gzip/deflate and decompress responses. OKHttp already does this by default.

REST: stopping when requests are pointless

  • When the server rejects the OKHttp REST client's requests (400/401/403/404), or it has been closed, it now makes no further requests. Previously it set a flag that nothing checked, so it kept polling. poll() and contextChange() return the current readiness without a request, and isStopped() reports it, so active polling stops scheduling.
  • The Jersey REST clients now behave the same way: they stop on 400/401/403/404 (previously only 400/404), whether the status is returned or thrown, stop when closed, and make no further requests once stopped.
  • Any other failure (429, 5xx, a network error, a non-304 redirect) is logged and leaves readiness as it is, in both OKHttp and Jersey; polling carries on. Previously both clients marked the repository Failed on these.
  • OKHttp REST polling now builds its request through the protected buildRequest() hook (as the SSE client does), so subclass overrides such as extra auth headers apply.
  • The polling wrapper's 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

  • Example and POM updates. The examples' request/response payload logging is at debug (FINE) rather than INFO.
  • java11_changed.txt, release_modules.txt and java17_changed.txt regenerated with detect_module_changes.sh.

Known limitations

  • A Jersey or OKHttp connection replaced by a context change may still be applying an event when the change happens, and those old features can still be written to the repository. The new context's connection then sends its own full feature set, and the old features never release the new context's callers.

Follow-ups

Testing

New Spock tests cover:

  • a context change while connecting
  • a late reconnect after a context change
  • context changes not being blocked by event processing or readiness callbacks
  • Last-Event-ID preserved across reconnects and cleared on a context change
  • ignoring superseded connections
  • close() stopping the retryer and not blocking
  • reconnect after a server bye
  • OKHttp SSE timeouts
  • gzip responses against a real Jersey server
  • the OKHttp REST client making no further requests, and active polling stopping, after a rejection, and still being shut down when closed
  • the OKHttp SSE client backing off when the connection never opened (including a 429 or 408), and reconnecting immediately when an open one drops
  • callers waiting on a replacement connection not being released by the old one, and callers released when an event fails to apply
  • no new OKHttp client after close, and the Jersey JAX-RS client being closed
  • the Jersey REST clients stopping on 400/401/403/404 (returned or thrown) and on close
  • failed connects retrying, and no reconnects after close
  • duplicate Jersey connections, replaced Jersey connections stopping, and concurrent closes
  • Last-Event-ID not moving past an event that failed to apply
  • a replaced connection's features not releasing the new context's callers
  • no connecting after the retryer stops, and callers released when the server rejects us (401/403/4xx)
  • callers released when the connection is lost or can't be made, and waiting for a new connection's first features
  • polls and context builds not connecting while the retryer is backing off, and a server-evaluated context change still connecting straight away
  • no waiting after a reconnect once we have features
  • Jersey backing off and retrying on 429/408, and reconnecting straight away when a working stream times out
  • OKHttp REST polling using buildRequest()
  • backing off when a stream ends before sending anything, in OKHttp and Jersey
  • a server-evaluated context change during a backoff cancelling the retryer's reconnect, a cancelled reconnect never reconnecting and not holding later ones up, and the Jersey connect thread being a daemon
  • StreamingConnectionTracker: client-evaluated contexts never waiting, server-evaluated contexts waiting for their features, replaced connections being ignored, the 400/404 rule, Failed only once the retryer stops, failure events with no data, abandoning a connection, and Last-Event-ID only resumed once we have features
  • client-evaluated context changes not waiting, transient failures (429, 5xx, network errors) not marking us failed in SSE and REST, 401/403 stopping every client, and a library clash marking us failed without retrying
  • a lost connection's reconnect being queued before a racing context change can cancel it, quiet working streams resetting the backoff, a cancelled backoff not growing the multiplier, and the bye backoff (its 300ms default, its property, it delaying the reconnect, and cancelling it)
  • Jersey: an Error while connecting not leaving anyone waiting, and a new context's connection not waiting behind the old context's blocked read
  • quiet resumed streams that hit the read timeout or are dropped by the server reconnecting straight away, ack-only streams that drop quickly backing off, and no Last-Event-ID before we have features
  • non-feature events not marking the context as having features
  • Jersey: a failed attempt for an earlier context being ignored, close while connecting not reporting a failure, 5xx/timeouts backing off as connection failures, and close interrupting its connection without shutting the shared pool down
  • Jersey connections not waiting behind the retryer's backoff, connections for an earlier context (A -> B -> A) being discarded, and wrapped connection failures being recognised
  • BlockingStreamEdgeService: 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 closes
  • backing off after 3 quiet streams in a row, and a real event or a context change resetting the count

The Jersey modules gain objenesis as 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.sh builds the whole reactor.

🤖 Generated with Claude Code

…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
rvowles force-pushed the fix-sse-last-event-id branch from 4932807 to fa8837b Compare October 16, 2026 04:00
@rvowles rvowles changed the title Fix last event id for SSE SSE and REST client fixes: Last-Event-ID, reconnects, timeouts and compression Oct 16, 2026
rvowles and others added 13 commits October 16, 2026 17:15
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>
rvowles and others added 3 commits October 20, 2026 16:10
- 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant