refactor(transport): improve MCP transport layer design

- Rename DefaultMcpTransport to AbstractMcpTransport and make it abstract
- Add graceful shutdown support via closeGracefully() method
- Enhance McpTransport interface with default close() implementation
- Improve documentation across transport layer classes
This commit is contained in:
Christian Tzolov 2024-12-10 13:25:16 +01:00
commit 7abf118412
7 changed files with 339 additions and 249 deletions

View file

@ -24,7 +24,6 @@
<properties>
<java.version>21</java.version>
<maven.compiler.release>21</maven.compiler.release>
<!-- <spring-ai.version>1.0.0-M4</spring-ai.version> -->
</properties>
<dependencies>
<dependency>

View file

@ -1,18 +1,18 @@
/*
* Copyright 2024 - 2024 the original author or authors.
*
* Licensed 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
*
* https://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.
*/
* Copyright 2024 - 2024 the original author or authors.
*
* Licensed 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
*
* https://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.
*/
package spring.ai.experimental.mcp.client;
import java.time.Duration;
@ -21,41 +21,85 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import spring.ai.experimental.mcp.spec.McpTransport;
/**
* The MCP client is the main entry point for interacting with the Model Context Protocol
* (MCP) server.
* Factory class providing static methods for creating Model Context Protocol (MCP) clients.
* This class serves as the main entry point for establishing connections with MCP servers,
* offering both synchronous and asynchronous client implementations.
*
* <p>The class provides factory methods to create either:
* <ul>
* <li>{@link McpAsyncClient} for non-blocking operations
* <li>{@link McpSyncClient} for blocking operations
* </ul>
*
* <p>Each client type can be instantiated with default settings or with custom configuration
* including request timeout and JSON object mapping.
*
* <p>Future implementations will introduce a builder pattern for more flexible client configuration.
*
* @author Christian Tzolov
* @author Dariusz Jędrzejczyk
*/
public class McpClient {
private McpClient() {
}
/**
* Private constructor to prevent instantiation as this is a utility class
* containing only static factory methods.
*/
private McpClient() {
}
// TODO: introduce a builder like:
// McpClient.using(transport)
// .withRequestTimeout(Duration.ofSeconds(5))
// .withObjectMapper(objectMapper); <- or even a more sophisticated
// JSONtoPOJOCodec type
// .sync();
// TODO: introduce a builder like:
// McpClient.using(transport)
// .withRequestTimeout(Duration.ofSeconds(5))
// .withObjectMapper(objectMapper); <- or even a more sophisticated
// JSONtoPOJOCodec type
// .sync();
public static McpAsyncClient async(McpTransport transport) {
return new McpAsyncClient(transport);
}
/**
* Creates an asynchronous MCP client with default configuration.
*
* @param transport The transport layer implementation for MCP communication
* @return A new instance of {@link McpAsyncClient}
*/
public static McpAsyncClient async(McpTransport transport) {
return new McpAsyncClient(transport);
}
public static McpAsyncClient async(McpTransport transport, Duration requestTimeout,
ObjectMapper objectMapper) {
return new McpAsyncClient(transport, requestTimeout, objectMapper);
}
public static McpSyncClient sync(McpTransport transport) {
return new McpSyncClient(async(transport));
}
public static McpSyncClient sync(McpTransport transport, Duration requestTimeout,
ObjectMapper objectMapper) {
return new McpSyncClient(async(transport, requestTimeout, objectMapper));
}
/**
* Creates an asynchronous MCP client with custom configuration.
*
* @param transport The transport layer implementation for MCP communication
* @param requestTimeout The duration to wait before timing out requests
* @param objectMapper The Jackson object mapper for JSON serialization/deserialization
* @return A new instance of {@link McpAsyncClient}
*/
public static McpAsyncClient async(McpTransport transport, Duration requestTimeout,
ObjectMapper objectMapper) {
return new McpAsyncClient(transport, requestTimeout, objectMapper);
}
/**
* Creates a synchronous MCP client with default configuration.
* This method wraps an asynchronous client to provide synchronous operations.
*
* @param transport The transport layer implementation for MCP communication
* @return A new instance of {@link McpSyncClient}
*/
public static McpSyncClient sync(McpTransport transport) {
return new McpSyncClient(async(transport));
}
/**
* Creates a synchronous MCP client with custom configuration.
* This method wraps an asynchronous client to provide synchronous operations.
*
* @param transport The transport layer implementation for MCP communication
* @param requestTimeout The duration to wait before timing out requests
* @param objectMapper The Jackson object mapper for JSON serialization/deserialization
* @return A new instance of {@link McpSyncClient}
*/
public static McpSyncClient sync(McpTransport transport, Duration requestTimeout,
ObjectMapper objectMapper) {
return new McpSyncClient(async(transport, requestTimeout, objectMapper));
}
}

View file

@ -10,10 +10,11 @@ import java.util.concurrent.Executors;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;
import spring.ai.experimental.mcp.client.util.Assert;
import spring.ai.experimental.mcp.spec.DefaultMcpTransport;
import spring.ai.experimental.mcp.spec.AbstractMcpTransport;
import spring.ai.experimental.mcp.spec.McpSchema.JSONRPCMessage;
import spring.ai.experimental.mcp.spec.McpSchema.JSONRPCNotification;
import spring.ai.experimental.mcp.spec.McpSchema.JSONRPCRequest;
@ -25,7 +26,7 @@ import spring.ai.experimental.mcp.spec.McpSchema.JSONRPCResponse;
* @author Christian Tzolov
* @author Dariusz Jędrzejczyk
*/
public class StdioServerTransport extends DefaultMcpTransport {
public class StdioServerTransport extends AbstractMcpTransport {
private final static TypeReference<HashMap<String, Object>> MAP_TYPE_REF = new TypeReference<HashMap<String, Object>>() {
};
@ -194,47 +195,46 @@ public class StdioServerTransport extends DefaultMcpTransport {
.subscribe();
}
// TODO: provide a non-blocking variant with graceful option
public void stop() {
this.outboundScheduler.dispose();
this.inboundScheduler.dispose();
this.errorScheduler.dispose();
// Destroy process
if (this.process != null) {
this.process.destroyForcibly();
}
}
@Override
public void close() {
public Mono<Void> closeGracefully() {
this.stop();
this.isRunning = false;
super.close(); // Do we need this?
// Close resources
if (this.processReader != null) {
try {
this.processReader.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (this.processWriter != null) {
try {
this.processWriter.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (this.processErrorReader != null) {
try {
this.processErrorReader.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return Mono.whenDelayError(
Mono.fromRunnable(() -> {
try {
this.processErrorReader.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}).subscribeOn(errorScheduler),
Mono.fromRunnable(() -> {
try {
this.processReader.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}).subscribeOn(inboundScheduler),
Mono.fromRunnable(() -> {
try {
this.processWriter.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}).subscribeOn(outboundScheduler))
.then(Mono.whenDelayError(inboundScheduler.disposeGracefully(),
outboundScheduler.disposeGracefully(),
errorScheduler.disposeGracefully()))
.then(Mono.fromRunnable(() -> {
if (this.process != null) {
var process = this.process.destroyForcibly();
if (process.exitValue() != 0) {
throw new RuntimeException("Failed to destroy process");
}
}
}))
.then()
.subscribeOn(Schedulers.boundedElastic());
}
}

View file

@ -0,0 +1,119 @@
/*
* Copyright 2024 - 2024 the original author or authors.
*
* Licensed 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
*
* https://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.
*/
package spring.ai.experimental.mcp.spec;
import java.util.function.Consumer;
import com.fasterxml.jackson.databind.ObjectMapper;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks;
import spring.ai.experimental.mcp.client.util.Assert;
import spring.ai.experimental.mcp.spec.McpSchema.JSONRPCMessage;
/**
* @author Christian Tzolov
* @author Dariusz Jędrzejczyk
*/
public abstract class AbstractMcpTransport implements McpTransport {
protected final ObjectMapper objectMapper;
private final Sinks.Many<String> errorSink;
private final Sinks.Many<JSONRPCMessage> inboundSink;
private final Sinks.Many<JSONRPCMessage> outboundSink;
private Consumer<JSONRPCMessage> inboundMessageHandler = message -> System.out
.println("Received message: " + message);
private Consumer<String> errorHandler = error -> System.err.println("Received error: " + error);
public AbstractMcpTransport() {
this(new ObjectMapper());
}
public AbstractMcpTransport(ObjectMapper objectMapper) {
Assert.notNull(objectMapper, "ObjectMapper must not be null");
this.objectMapper = objectMapper;
// TODO: consider the effects of buffering here -> the inter-process pipes are
// independent and the notifications can flood the client/server.
// Potentially, the interest in reading could be communicated from one party
// to the other so the Blocking IO Threads can pause consuming the stream
// buffers when there is no expectation for reading.
this.errorSink = Sinks.many().unicast().onBackpressureBuffer();
this.inboundSink = Sinks.many().unicast().onBackpressureBuffer();
this.outboundSink = Sinks.many().unicast().onBackpressureBuffer();
this.handleIncomingMessages();
}
public ObjectMapper getObjectMapper() {
return this.objectMapper;
}
private void handleIncomingMessages() {
this.inboundSink.asFlux()
.subscribe(message -> this.inboundMessageHandler.accept(message));
}
private void handleIncomingErrors() {
this.errorSink.asFlux().subscribe(e -> {
this.errorHandler.accept(e);
// System.err.println(e); TODO: log the error
});
}
protected Sinks.Many<JSONRPCMessage> getInboundSink() {
return inboundSink;
}
protected Sinks.Many<JSONRPCMessage> getOutboundSink() {
return outboundSink;
}
protected Sinks.Many<String> getErrorSink() {
return errorSink;
}
public void setInboudMessageHandler(Consumer<JSONRPCMessage> inboundMessageHandler) {
this.inboundMessageHandler = inboundMessageHandler;
}
public void setInboundErrorHandler(Consumer<String> errorHandler) {
this.errorHandler = errorHandler;
}
// Start processing incoming messages
public void start() {
this.handleIncomingErrors();
this.handleIncomingMessages();
}
@Override
public Mono<Void> sendMessage(JSONRPCMessage message) {
if (this.outboundSink.tryEmitNext(message).isSuccess()) {
// TODO: essentially we could reschedule ourselves in some time and make
// another attempt with the already read data but pause reading until
// success
// In this approach we delegate the retry and the backpressure onto the
// caller. This might be enough for most cases.
return Mono.empty();
} else {
return Mono.error(new RuntimeException("Failed to enqueue message"));
}
}
}

View file

@ -111,7 +111,6 @@ public class McpSession {
McpSchema.JSONRPCNotification jsonrpcNotification = new McpSchema.JSONRPCNotification(McpSchema.JSONRPC_VERSION,
method, params);
try {
// TODO: make it non-blocking
this.transport.sendMessage(jsonrpcNotification);
} catch (Exception e) {
return Mono.error(new McpError(e));

View file

@ -12,67 +12,104 @@
* 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.
*/
*/
package spring.ai.experimental.mcp.spec;
import java.time.Duration;
import java.util.function.Consumer;
import reactor.core.publisher.Mono;
import spring.ai.experimental.mcp.spec.McpSchema.JSONRPCMessage;
/**
* Defines the asynchronous transport layer for the Model Context Protocol (MCP).
* Defines the asynchronous transport layer for the Model Context Protocol
* (MCP).
*
* <p>The McpTransport interface provides the foundation for implementing custom transport
* mechanisms in the Model Context Protocol. It handles the bidirectional communication between
* the client and server components, supporting asynchronous message exchange using JSON-RPC format.</p>
* <p>
* The McpTransport interface provides the foundation for implementing custom
* transport
* mechanisms in the Model Context Protocol. It handles the bidirectional
* communication between
* the client and server components, supporting asynchronous message exchange
* using JSON-RPC format.
* </p>
*
* <p>Implementations of this interface are responsible for:</p>
* <p>
* Implementations of this interface are responsible for:
* </p>
* <ul>
* <li>Managing the lifecycle of the transport connection</li>
* <li>Handling incoming messages and errors from the server</li>
* <li>Sending outbound messages to the server</li>
* <li>Managing the lifecycle of the transport connection</li>
* <li>Handling incoming messages and errors from the server</li>
* <li>Sending outbound messages to the server</li>
* </ul>
*
* <p>The transport layer is designed to be protocol-agnostic, allowing for various implementations
* such as WebSocket, HTTP, or custom protocols.</p>
* <p>
* The transport layer is designed to be protocol-agnostic, allowing for various
* implementations
* such as WebSocket, HTTP, or custom protocols.
* </p>
*
* @author Christian Tzolov
* @author Dariusz Jędrzejczyk
*/
public interface McpTransport extends AutoCloseable {
public interface McpTransport {
/**
* Initializes and starts the transport connection.
*
* <p>This method should be called before any message exchange can occur. It sets up
* the necessary resources and establishes the connection to the server.</p>
* <p>
* This method should be called before any message exchange can occur. It sets
* up
* the necessary resources and establishes the connection to the server.
* </p>
*/
void start();
/**
* Closes the transport connection and releases any associated resources.
*
* <p>This method ensures proper cleanup of resources when the transport is no longer needed.
* It should handle the graceful shutdown of any active connections.</p>
* <p>
* This method ensures proper cleanup of resources when the transport is no
* longer needed.
* It should handle the graceful shutdown of any active connections.
* </p>
*/
void close();
default void close() {
this.closeGracefully().subscribe();
}
/**
* Closes the transport connection and releases any associated resources
* asynchronously.
*
* @return a {@link Mono<Void>} that completes when the connection has been
* closed.
*/
Mono<Void> closeGracefully();
/**
* Sets the handler for processing incoming messages from the server.
*
* <p>The provided handler will be called whenever a new message is received from the server.
* Messages are expected to follow the JSON-RPC format as defined in the MCP specification.</p>
* <p>
* The provided handler will be called whenever a new message is received from
* the server.
* Messages are expected to follow the JSON-RPC format as defined in the MCP
* specification.
* </p>
*
* @param inboundMessageHandler a consumer that processes incoming {@link JSONRPCMessage}s
* @param inboundMessageHandler a consumer that processes incoming
* {@link JSONRPCMessage}s
*/
void setInboudMessageHandler(Consumer<JSONRPCMessage> inboundMessageHandler);
/**
* Sets the handler for processing transport-level errors.
*
* <p>The provided handler will be called when errors occur during transport operations,
* such as connection failures or protocol violations.</p>
* <p>
* The provided handler will be called when errors occur during transport
* operations,
* such as connection failures or protocol violations.
* </p>
*
* @param inboundErrorHandler a consumer that processes error messages
*/
@ -81,11 +118,14 @@ public interface McpTransport extends AutoCloseable {
/**
* Sends a message to the server asynchronously.
*
* <p>This method handles the transmission of messages to the server in an asynchronous manner.
* Messages are sent in JSON-RPC format as specified by the MCP protocol.</p>
* <p>
* This method handles the transmission of messages to the server in an
* asynchronous manner.
* Messages are sent in JSON-RPC format as specified by the MCP protocol.
* </p>
*
* @param message the {@link JSONRPCMessage} to be sent to the server
* @return a {@link Mono<Void>} that completes when the message has been sent
*/
Mono<Void> sendMessage(JSONRPCMessage message);
Mono<Void> sendMessage(JSONRPCMessage message);
}

View file

@ -15,148 +15,162 @@
*/
package spring.ai.experimental.mcp.spec;
import java.util.concurrent.atomic.AtomicReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import spring.ai.experimental.mcp.spec.DefaultMcpTransport;
import spring.ai.experimental.mcp.spec.McpSchema.JSONRPCMessage;
import spring.ai.experimental.mcp.spec.McpSchema.JSONRPCRequest;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class DefaultMcpTransportTests {
private DefaultMcpTransport transport;
private ObjectMapper objectMapper;
private AbstractMcpTransport transport;
private ObjectMapper objectMapper;
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper();
transport = new DefaultMcpTransport(objectMapper);
}
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper();
transport = new AbstractMcpTransport(objectMapper) {
@Override
public Mono<Void> closeGracefully() {
return null;
}
};
}
@Test
void constructorShouldValidateObjectMapper() {
assertThatThrownBy(() -> new DefaultMcpTransport(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("ObjectMapper must not be null");
}
@Test
void constructorShouldValidateObjectMapper() {
assertThatThrownBy(() -> new AbstractMcpTransport(null) {
@Override
public Mono<Void> closeGracefully() {
return null;
}
})
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("ObjectMapper must not be null");
}
@Test
void defaultConstructorShouldCreateValidInstance() {
DefaultMcpTransport defaultTransport = new DefaultMcpTransport();
assertThat(defaultTransport.getObjectMapper()).isNotNull();
}
@Test
void defaultConstructorShouldCreateValidInstance() {
AbstractMcpTransport defaultTransport = new AbstractMcpTransport() {
@Override
public Mono<Void> closeGracefully() {
return null;
}
};
assertThat(defaultTransport.getObjectMapper()).isNotNull();
}
@Test
void customObjectMapperConstructorShouldCreateValidInstance() {
assertThat(transport.getObjectMapper()).isSameAs(objectMapper);
}
@Test
void customObjectMapperConstructorShouldCreateValidInstance() {
assertThat(transport.getObjectMapper()).isSameAs(objectMapper);
}
@Test
void sendMessageShouldEmitToOutboundSink() {
JSONRPCRequest message = new JSONRPCRequest(
"2.0",
"test",
1,
null);
@Test
void sendMessageShouldEmitToOutboundSink() {
JSONRPCRequest message = new JSONRPCRequest(
"2.0",
"test",
1,
null);
AtomicReference<JSONRPCMessage> receivedMessage = new AtomicReference<>();
transport.getOutboundSink().asFlux().subscribe(receivedMessage::set);
AtomicReference<JSONRPCMessage> receivedMessage = new AtomicReference<>();
transport.getOutboundSink().asFlux().subscribe(receivedMessage::set);
Mono<Void> result = transport.sendMessage(message);
Mono<Void> result = transport.sendMessage(message);
StepVerifier.create(result)
.verifyComplete();
StepVerifier.create(result)
.verifyComplete();
assertThat(receivedMessage.get())
.isNotNull()
.satisfies(msg -> {
assertThat(((JSONRPCRequest) msg).jsonrpc()).isEqualTo("2.0");
assertThat(((JSONRPCRequest) msg).method()).isEqualTo("test");
});
}
assertThat(receivedMessage.get())
.isNotNull()
.satisfies(msg -> {
assertThat(((JSONRPCRequest) msg).jsonrpc()).isEqualTo("2.0");
assertThat(((JSONRPCRequest) msg).method()).isEqualTo("test");
});
}
@Test
void customInboundMessageHandlerShouldReceiveMessages() {
AtomicReference<JSONRPCMessage> receivedMessage = new AtomicReference<>();
transport.setInboudMessageHandler(receivedMessage::set);
transport.start();
@Test
void customInboundMessageHandlerShouldReceiveMessages() {
AtomicReference<JSONRPCMessage> receivedMessage = new AtomicReference<>();
transport.setInboudMessageHandler(receivedMessage::set);
transport.start();
JSONRPCRequest message = new JSONRPCRequest(
"2.0",
"test",
1,
null);
JSONRPCRequest message = new JSONRPCRequest(
"2.0",
"test",
1,
null);
transport.getInboundSink().tryEmitNext(message);
transport.getInboundSink().tryEmitNext(message);
assertThat(receivedMessage.get())
.isNotNull()
.satisfies(msg -> {
assertThat(((JSONRPCRequest) msg).jsonrpc()).isEqualTo("2.0");
assertThat(((JSONRPCRequest) msg).method()).isEqualTo("test");
});
}
assertThat(receivedMessage.get())
.isNotNull()
.satisfies(msg -> {
assertThat(((JSONRPCRequest) msg).jsonrpc()).isEqualTo("2.0");
assertThat(((JSONRPCRequest) msg).method()).isEqualTo("test");
});
}
@Test
void customErrorHandlerShouldReceiveErrors() {
AtomicReference<String> receivedError = new AtomicReference<>();
transport.setInboundErrorHandler(receivedError::set);
transport.start();
@Test
void customErrorHandlerShouldReceiveErrors() {
AtomicReference<String> receivedError = new AtomicReference<>();
transport.setInboundErrorHandler(receivedError::set);
transport.start();
String errorMessage = "Test error";
transport.getErrorSink().tryEmitNext(errorMessage);
String errorMessage = "Test error";
transport.getErrorSink().tryEmitNext(errorMessage);
assertThat(receivedError.get())
.isNotNull()
.isEqualTo(errorMessage);
}
assertThat(receivedError.get())
.isNotNull()
.isEqualTo(errorMessage);
}
@Test
void startShouldInitializeMessageAndErrorHandling() {
AtomicReference<JSONRPCMessage> receivedMessage = new AtomicReference<>();
AtomicReference<String> receivedError = new AtomicReference<>();
@Test
void startShouldInitializeMessageAndErrorHandling() {
AtomicReference<JSONRPCMessage> receivedMessage = new AtomicReference<>();
AtomicReference<String> receivedError = new AtomicReference<>();
transport.setInboudMessageHandler(receivedMessage::set);
transport.setInboundErrorHandler(receivedError::set);
transport.start();
transport.setInboudMessageHandler(receivedMessage::set);
transport.setInboundErrorHandler(receivedError::set);
transport.start();
JSONRPCRequest message = new JSONRPCRequest(
"2.0",
"test",
1,
null);
JSONRPCRequest message = new JSONRPCRequest(
"2.0",
"test",
1,
null);
String errorMessage = "Test error";
String errorMessage = "Test error";
transport.getInboundSink().tryEmitNext(message);
transport.getErrorSink().tryEmitNext(errorMessage);
transport.getInboundSink().tryEmitNext(message);
transport.getErrorSink().tryEmitNext(errorMessage);
assertThat(receivedMessage.get()).isNotNull();
assertThat(receivedError.get()).isEqualTo(errorMessage);
}
assertThat(receivedMessage.get()).isNotNull();
assertThat(receivedError.get()).isEqualTo(errorMessage);
}
@Test
void sendMessageShouldReturnErrorWhenSinkFails() {
JSONRPCRequest message = new JSONRPCRequest(
"2.0",
"test",
1,
null);
// Close the sink to simulate failure
transport.getOutboundSink().tryEmitComplete();
@Test
void sendMessageShouldReturnErrorWhenSinkFails() {
JSONRPCRequest message = new JSONRPCRequest(
"2.0",
"test",
1,
null);
// Close the sink to simulate failure
transport.getOutboundSink().tryEmitComplete();
Mono<Void> result = transport.sendMessage(message);
Mono<Void> result = transport.sendMessage(message);
StepVerifier.create(result)
.verifyErrorSatisfies(error -> assertThat(error)
.isInstanceOf(RuntimeException.class)
.hasMessage("Failed to enqueue message"));
}
StepVerifier.create(result)
.verifyErrorSatisfies(error -> assertThat(error)
.isInstanceOf(RuntimeException.class)
.hasMessage("Failed to enqueue message"));
}
}