Skip to content

feat: add GraphQL subscriptions over SSE (G3) - #1592

Open
jlouvel wants to merge 1 commit into
feat/graphql-g2-clientfrom
feat/graphql-g3-sse
Open

jlouvel wants to merge 1 commit into
feat/graphql-g2-clientfrom
feat/graphql-g3-sse

Conversation

@jlouvel

@jlouvel jlouvel commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #1585. Part of #1581.

Stacked on #1591 (G2) → #1590 (G1) → #1589 (G0). Merge those first.

Summary

Subscriptions with graphql-sse in distinct connections mode, on core SSE (EventStreamRepresentation, EventStreamReader). WebSocket comes in G4.

// Server
graphql.getSubscriptionTransports().add(GraphqlTransport.SSE);

// Client
Publisher<ExecutionResult> ticks = client.subscribe("subscription { ticks }", null, null);

Server

  • A request accepting text/event-stream (named explicitly; wildcards don't select it) gets an event stream: next events carrying results, then complete. Queries and mutations are one next; subscriptions are also accepted over GET when GET is enabled. Request errors are 400 with a JSON body, as in graphql-sse.
  • Back-pressure: one result requested at a time, the next one after the event is written, so the connector's flow control reaches the publisher.
  • Disconnects: a failed write cancels the subscription. Heartbeats (15 s by default, configurable or off) keep idle streams open through proxies and detect disconnected idle clients.
  • An empty comment is sent first so that the response head goes out at once. Without it, an idle subscription with heartbeats off kept the client waiting for headers (about 30 s in tests).
  • Limits: 64 active subscriptions per adapter (503 beyond), 1 MiB of queued bytes per subscription. The queue only grows when a publisher ignores demand or a result is huge; beyond the limit the stream ends with an error and the subscription is cancelled. The connector's server.maxEventStreams still applies.
  • stop() ends the adapter's open streams (error + complete), so that the server connector shuts down gracefully instead of waiting for idle subscriptions.
  • Publisher failures are logged; subscribers receive a generic message.

Client

  • subscribe(String|Document, operationName, variables) returns a cold Publisher<ExecutionResult>. Each subscriber sends its own POST on its first demand, from a virtual thread that emits all signals (no concurrent onNext).
  • Demand is honored; cancel() aborts the request (Request.abort()); request(n <= 0) signals IllegalArgumentException.
  • Request errors (server 400, or local validation with setSchema) are published as a single result. Non-GraphQL responses, events above maxResponseBytes and streams ending without complete are a GraphqlTransportException.
  • stop() fails open subscriptions with a GraphqlTransportException; the borrowed client keeps running.

Core findings

  • EventStreamReader has no line or event size limit (BufferedReader.readLine). The client wraps the stream with a per-event byte budget (one 8 KiB read-ahead margin); a bounded reader in core would be better.
  • EventStreamRepresentation does not commit the response head until the first event or heartbeat.

Validation

./mvnw -B verify -P default,test -pl org.restlet.ext.graphql: 47 tests, 0 failures; 93.8% instruction coverage (gate 80%); Spotless, SpotBugs, Javadoc, -Werror and dependency convergence pass.

New GraphqlSseTestCase (16 tests): wire format of next/complete; queries over SSE; JSON request errors; GET subscriptions and GET mutations rejected; SSE off unless enabled; client disconnect cancels a busy subscription; heartbeats detect a disconnected idle client; subscription cap; queued bytes limit; client demand (no result without demand), completion, cancellation aborting the server-side subscription, stop(), request errors as results (remote and local validation), transport errors (HTML 502, truncated stream, oversized event), invalid demand; configuration validation.

🤖 Generated with Claude Code

graphql-sse in distinct connections mode, on core SSE:

- Server: GraphqlTransport.SSE enables event streams for requests
  accepting text/event-stream: subscriptions (also over GET when it is
  enabled), and queries and mutations as one next event. Results are
  requested one at a time and written as next events, then complete.
  Request errors are 400 with a JSON body. An initial comment sends the
  response head at once; heartbeats (15 s by default) keep idle streams
  open and detect disconnected clients. A failed write cancels the
  subscription.
- Limits: 64 active subscriptions per adapter (503 beyond), 1 MiB of
  queued bytes per subscription (the stream ends with an error and the
  subscription is cancelled). Stopping the adapter ends its open
  streams so that the server connector can shut down gracefully.
- Client: subscribe() returns a cold Publisher<ExecutionResult>; each
  subscriber sends its own request on its first demand, from a virtual
  thread that emits all the signals. Demand is honored, cancel() aborts
  the request, events are bounded by maxResponseBytes, request errors
  are published as a single result, and a stream ending without a
  complete event is a GraphqlTransportException. stop() fails the open
  subscriptions.

Refs #1585

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com>
@sonarqubecloud

Copy link
Copy Markdown

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Early client disconnects can bypass cleanup and permanently exhaust subscription capacity, with additional unresolved subscription-lifecycle and SSE protocol defects.

8 open issues
What changed in this PR

Adds GraphQL subscriptions over Server-Sent Events (SSE), extending the existing HTTP binding with graphql-sse distinct-connections support built on Restlet’s core SSE APIs.

Changes:

  • Adds server streaming with heartbeats, subscription limits, back-pressure, and shutdown handling.
  • Adds cold client publishers with demand handling, cancellation, and transport-error reporting.
  • Adds SSE integration tests, idle-publisher fixtures, and a changelog entry.
File Description
org.restlet.ext.graphql/​src/​test/​java/​org/​restlet/​ext/​graphql/​TickPublisher.java Adds idle-publisher behavior for tests.
org.restlet.ext.graphql/​src/​test/​java/​org/​restlet/​ext/​graphql/​GraphqlTestSchemas.java Adds an idle subscription field.
org.restlet.ext.graphql/​src/​test/​java/​org/​restlet/​ext/​graphql/​GraphqlSseTestCase.java Tests SSE protocol, limits, demand, and lifecycle behavior.
org.restlet.ext.graphql/​src/​main/​java/​org/​restlet/​ext/​graphql/​internal/​GraphqlEventStream.java Writes subscription results as bounded SSE streams.
org.restlet.ext.graphql/​src/​main/​java/​org/​restlet/​ext/​graphql/​GraphqlTransport.java Defines the SSE subscription transport.
org.restlet.ext.graphql/​src/​main/​java/​org/​restlet/​ext/​graphql/​GraphqlSsePublisher.java Implements client-side SSE subscriptions.
org.restlet.ext.graphql/​src/​main/​java/​org/​restlet/​ext/​graphql/​GraphqlServerAdapter.java Integrates SSE negotiation, configuration, and stream lifecycle.
org.restlet.ext.graphql/​src/​main/​java/​org/​restlet/​ext/​graphql/​GraphqlClientAdapter.java Exposes subscription APIs and tracks open subscriptions.
changes.md Documents GraphQL SSE support and default limits.

Review effort: Balanced

Comment on lines +162 to +167
// An empty comment sends the response head at once, before the first result.
sink.send(OPEN);
QueueingSubscriber subscriber = new QueueingSubscriber(maxQueuedBytes);
this.subscriber = subscriber;
openStreams.add(this);
try {
Comment on lines +398 to +401
private static Publisher<ExecutionResult> single(ExecutionResult result) {
return subscriber ->
subscriber.onSubscribe(
new Subscription() {
Comment on lines +355 to +356
subscriptionTransports =
Collections.unmodifiableSet(EnumSet.copyOf(subscriptionTransports));
Comment on lines +455 to +457
if (subscription && activeSubscriptions.get() >= maxSubscriptions) {
writeEntity(response, responseType, failure(error("Too many active subscriptions")));
response.setStatus(Status.SERVER_ERROR_SERVICE_UNAVAILABLE);
* reported with {@code 400} in {@code application/graphql-response+json} only.
*/
private static void write(Response response, MediaType responseType, ExecutionResult result) {
write(response, responseType, result, false);
Comment on lines +58 to +59
subscriber.onSubscribe(subscription);
if (!adapter.register(subscription)) {
Comment on lines +91 to +92
if (n <= 0) {
invalidDemand = true;
Comment on lines +114 to +116
Request current = request;
if (current != null) {
current.abort();
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.

2 participants