Skip to content

feat: add the GraphQL WebSocket client (G4, graphql-transport-ws) - #1594

Open
jlouvel wants to merge 7 commits into
feat/graphql-g4-websocket-serverfrom
feat/graphql-g4-websocket-client
Open

jlouvel wants to merge 7 commits into
feat/graphql-g4-websocket-serverfrom
feat/graphql-g4-websocket-client

Conversation

@jlouvel

@jlouvel jlouvel commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator

Closes #1586. Part of #1581.

Draft: depends on #1566 and #1568 (#1567, snag S1). This branch is the G4 server branch (#1593) merged with fix/websocket-client-shared-event-loop, so the diff against #1593 also shows the commits of #1566 and #1568 until they are merged. Once they are, I'll rebase and mark this ready.

Summary

GraphqlClientAdapter graphql = new GraphqlClientAdapter(getContext());
graphql.setClient(restletClient);                         // started Netty HTTP client with a context
graphql.setEndpoint("https://api.example.com/graphql");   // wss://api.example.com/graphql
graphql.setSubscriptionTransport(GraphqlTransport.WEBSOCKET);
graphql.setConnectionInitPayload(Map.of("token", token));
graphql.start();
Publisher<ExecutionResult> events = graphql.subscribe("subscription { issueUpdated { id } }", null, null);
  • subscribe() multiplexes operations over one graphql-transport-ws connection, opened on the first demand (with connection_init and a 10 s acknowledgement timeout) and reopened if it was closed.
  • Shared engine: the WebSocketClient borrows the event loop of the adapter's HTTP client through WebSocketClient.setClient (fix: let WebSocketClient share a borrowed client's event loop #1568), so the extension creates no Netty event loop group. stop() stops the WebSocket client but never the borrowed HTTP client.
  • Each operation is delivered by its own virtual thread, honoring demand. graphql-transport-ws has no flow control: results received without demand are buffered up to maxResponseBytes per operation; beyond it the operation fails with a GraphqlTransportException and the server is sent complete, which cancels the server-side subscription.
  • Errors before execution (error message) are a single result with errors, as with HTTP and SSE. A closed connection fails its operations with the close code and reason. cancel() sends complete.
  • The request customizer adds handshake headers (for example an API key); the connection_init payload carries tokens for servers that authenticate there.
  • The WebSocket code is only loaded when the transport is selected; org.restlet.ext.websocket stays optional.

Finding on #1568

WebSocketClient.setClient requires the borrowed client to have a Context (it reads the helper from the context attributes). new Client(Protocol.HTTP) has none, so it can't be borrowed: new Client(new Context(), Protocol.HTTP) is needed. Worth a clearer error message or a fallback there.

Validation

./mvnw -B verify -P default,test -pl org.restlet.ext.graphql (with core and the WebSocket extension from this branch): 63 tests, 0 failures; 91.6% instruction coverage (gate 80%); Spotless, SpotBugs, Javadoc, -Werror and dependency convergence pass.

New GraphqlWebSocketClientTestCase (7 tests) against GraphqlServerAdapter: a subscription and a query share one upgrade (counted by a filter) with demand honored, the init payload and a handshake header reaching data fetchers; cancelling one of two subscriptions cancels it on the server; adapter stop fails the other one and keeps the borrowed client; errors before execution as results; buffered results bounded, cancelling the server-side subscription; server shutdown (1001) fails subscriptions; endpoint without WebSocket; a borrowed client that isn't a started Netty client is rejected at start().

🤖 Generated with Claude Code

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

Unresolved cancellation and shutdown races can leak server operations and WebSocket connections, while decoding failures can leave subscribers waiting indefinitely.

7 open issues
What changed in this PR

Adds the client side of GraphQL G4: graphql-transport-ws subscriptions integrated with GraphqlClientAdapter. The PR also includes the prerequisite WebSocket lifecycle and shared-event-loop changes.

Changes:

  • Adds multiplexed WebSocket subscriptions, connection initialization, demand-aware delivery, buffering limits, and cancellation.
  • Lets WebSocket clients borrow the HTTP client’s Netty event loop without owning its lifecycle.
  • Adds integration tests and release notes while keeping the WebSocket dependency optional.
File Description
org.restlet/​src/​main/​java/​org/​restlet/​engine/​netty/​NettyClientHelper.java Exposes the connector’s event loop for extensions.
org.restlet/​src/​main/​java/​org/​restlet/​Client.java Publishes the helper when the context is assigned.
org.restlet.ext.websocket/​src/​test/​java/​org/​restlet/​ext/​websocket/​WebSocketTestCase.java Migrates setup to setter-based configuration.
org.restlet.ext.websocket/​src/​test/​java/​org/​restlet/​ext/​websocket/​WebSocketSharedEventLoopTestCase.java Tests borrowed event-loop ownership and validation.
org.restlet.ext.websocket/​src/​test/​java/​org/​restlet/​ext/​websocket/​WebSocketSessionTestCase.java Updates configuration tests and checks lifecycle restrictions.
org.restlet.ext.websocket/​src/​test/​java/​org/​restlet/​ext/​websocket/​WebSocketCompressionTestCase.java Migrates compression tests to the new client API.
org.restlet.ext.websocket/​src/​main/​java/​org/​restlet/​ext/​websocket/​WebSocketClient.java Adds setters, lifecycle methods, and HTTP-client borrowing.
org.restlet.ext.websocket/​src/​main/​java/​org/​restlet/​ext/​websocket/​internal/​NettyWebSocketConnector.java Supports shared event loops and tracks connections for shutdown.
org.restlet.ext.graphql/​src/​test/​java/​org/​restlet/​ext/​graphql/​GraphqlWebSocketClientTestCase.java Tests multiplexing, demand, cancellation, limits, and failures.
org.restlet.ext.graphql/​src/​main/​java/​org/​restlet/​ext/​graphql/​GraphqlWebSocketTransport.java Implements the GraphQL WebSocket client protocol.
org.restlet.ext.graphql/​src/​main/​java/​org/​restlet/​ext/​graphql/​GraphqlSubscriptionTransport.java Isolates the optional subscription transport.
org.restlet.ext.graphql/​src/​main/​java/​org/​restlet/​ext/​graphql/​GraphqlClientAdapter.java Adds transport selection and connection-init configuration.
changes.md Documents GraphQL client support and connector changes.

Review effort: Balanced


/** The open connections, closed with the connector. */
private final ChannelGroup channels =
new DefaultChannelGroup("restlet-websocket-client", GlobalEventExecutor.INSTANCE);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Not fixed in this PR: NettyWebSocketConnector comes from #1568 (fix/websocket-client-shared-event-loop), which this branch includes and where the same comment was posted (r4424962845). The fix belongs there and will come in when this branch is restacked on it.

@jlouvel
jlouvel force-pushed the feat/graphql-g4-websocket-server branch from ab5539d to 63ed523 Compare October 31, 2026 06:18
@jlouvel
jlouvel force-pushed the feat/graphql-g4-websocket-client branch from 950f70d to 0f851b5 Compare October 31, 2026 06:26
jlouvel and others added 7 commits October 31, 2026 03:18
Follow the Restlet API conventions used by the RPC extensions:
constructors, getter and void setter pairs, and a start/stop lifecycle
instead of a fluent builder.

- Remove WebSocketClient.Builder, builder() and create(). Settings are
  now configured with setters on a new WebSocketClient().
- Add start(), stop() and isStarted(). close() stops the client, which
  can be started again. connect() starts a stopped client, like
  Restlet.handle().
- Reject setting changes while the client is started.

The extension is unreleased, so the builder is removed rather than
deprecated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com>
Also describe the setter-based configuration and restartable lifecycle
of WebSocketClient in the changes log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com>
WebSocketClient created its own Netty event loop group in every
instance, next to the one of the core HTTP client connector, with a
thread count that could not be configured.

- Add NettyClientHelper.getEventLoopGroup(), an engine extension point
  exposing the client connector's event loop group.
- Add WebSocketClient.setClient(Client): a borrowed, started Netty HTTP
  client whose event loop is shared. Without it, the client still
  creates its own threads.
- Track the WebSocket connections in a channel group, so stopping the
  client closes its sessions without shutting down a shared event loop.
- Store the helper in the "org.restlet.engine.helper" context attribute
  in Client.setContext(), as the constructors do. Clients added with
  Component.getClients().add(Protocol) get their context this way, and
  did not expose their helper.

Fixes #1567

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com>
A connect() racing with stop() could register its channel after the
channel group was closed. With a borrowed event loop, which isn't shut
down, that connection stayed open. Keep the group closed so that late
channels are closed when added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com>
- GraphqlClientAdapter.setSubscriptionTransport(WEBSOCKET): subscribe()
  multiplexes operations over one graphql-transport-ws connection,
  opened on the first demand at the endpoint with the ws or wss scheme.
- The WebSocketClient borrows the event loop of the adapter's HTTP
  client (WebSocketClient.setClient, #1567), so no extra Netty threads
  are created. The request customizer adds handshake headers and
  setConnectionInitPayload sets the connection_init payload.
- Each operation is delivered by its own virtual thread, honoring
  demand. The protocol has no flow control: results received without
  demand are buffered up to maxResponseBytes per operation, beyond which
  the operation fails and the server is sent complete.
- Errors before execution are a single result; a closed connection
  fails its operations with the close code; cancel() sends complete;
  stop() fails open operations, closes the connection and stops the
  WebSocket client, but not the borrowed HTTP client.
- The WebSocket code is only loaded when the transport is selected.

Refs #1586

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com>
The client signals the overflow at once, possibly before delivering
the first buffered result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com>
Track subscribers from onSubscribe() so that stop() fails them all,
signal non-positive demand without waiting for a result and complete the
server operation, close the connection when its reader fails, send
subscribe before any complete of a cancellation, signal setup failures,
and count buffered results until they are delivered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jlouvel
jlouvel force-pushed the feat/graphql-g4-websocket-client branch from 0f851b5 to 50d00cb Compare October 31, 2026 07:32
@sonarqubecloud

Copy link
Copy Markdown

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