diff --git a/dotnet/README.md b/dotnet/README.md index 0e764b6c2d..2d2ec0b6da 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -1238,6 +1238,16 @@ try var session = await client.CreateSessionAsync(); await session.SendAsync(new MessageOptions { Prompt = "Hello" }); } +catch (IOException ex) when (ex.InnerException is RemoteRpcException) +{ + var remote = (RemoteRpcException)ex.InnerException!; + Console.Error.WriteLine($"RPC error {remote.ErrorCode}: {remote.Message}"); + if (remote.ErrorData is { } data) + { + // Interpret data according to the remote API's contract. + Console.Error.WriteLine($"Error data kind: {data.ValueKind}"); + } +} catch (IOException ex) { Console.Error.WriteLine($"Communication Error: {ex.Message}"); @@ -1248,6 +1258,15 @@ catch (Exception ex) } ``` +`RemoteRpcException` is in the `GitHub.Copilot` namespace. Remote JSON-RPC +errors remain wrapped in `IOException`; connection failures are not remote errors. +`ErrorData` is a `JsonElement?` that preserves objects, arrays, strings, numbers, +booleans, and empty values without converting them to application-specific types. +Omitted `data` has no nullable value; explicit JSON `null` has a value with +`ValueKind == JsonValueKind.Null`. The cloned element remains valid after the +response document or client is disposed. Exception messages and ordinary exception +formatting do not include the data payload. + ## Development Development requires [.NET SDK 10+](https://dotnet.microsoft.com/download) and a supported [Node.js version](../nodejs/README.md#prerequisites). From the repository root: diff --git a/dotnet/src/JsonRpc.cs b/dotnet/src/JsonRpc.cs index c5c444ea70..e9dcb88c4a 100644 --- a/dotnet/src/JsonRpc.cs +++ b/dotnet/src/JsonRpc.cs @@ -1000,13 +1000,30 @@ internal sealed class ConnectionLostException() : IOException("The JSON-RPC conn /// /// Thrown when the remote side returns a JSON-RPC error response. /// -internal sealed class RemoteRpcException(string message, int errorCode, JsonElement? errorData = null, Exception? innerException = null) : Exception(message, innerException) +/// +/// Client RPC calls wrap this exception in an . +/// Inspect its to access the remote error. +/// +/// The remote error message. +/// The numeric JSON-RPC error code. +/// The optional JSON error data, cloned to retain its lifetime. +/// The exception that caused this error, if any. +public sealed class RemoteRpcException(string message, int errorCode, JsonElement? errorData = null, Exception? innerException = null) : Exception(message, innerException) { /// JSON-RPC 2.0 reserved error code: requested method does not exist. public const int MethodNotFoundErrorCode = -32601; + /// Gets the numeric code from the JSON-RPC error response. public int ErrorCode { get; } = errorCode; + /// Gets the unmodified JSON data from the remote error, if provided. + /// + /// A missing data member produces a nullable value with no value. + /// An explicit JSON null produces a present element whose + /// is . + /// All other JSON value kinds are preserved. The element is cloned and remains + /// valid after the response document and client are disposed. + /// public JsonElement? ErrorData { get; } = errorData.HasValue ? errorData.Value.Clone() : null; } diff --git a/dotnet/test/Unit/RpcErrorDataTests.cs b/dotnet/test/Unit/RpcErrorDataTests.cs new file mode 100644 index 0000000000..cd4be91257 --- /dev/null +++ b/dotnet/test/Unit/RpcErrorDataTests.cs @@ -0,0 +1,204 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +#if NET8_0_OR_GREATER +using System.Globalization; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using Xunit; + +namespace GitHub.Copilot.Test.Unit; + +public sealed class RpcErrorDataTests +{ + private const string ErrorMessage = "Request failed"; + private const int ErrorCode = -32042; + + [Theory] + [InlineData("""{"privateDetail":"payload-only-marker","nested":{"items":[1,false,null]}}""", JsonValueKind.Object)] + [InlineData("""[{"value":"payload-only-marker"},[1,true],null]""", JsonValueKind.Array)] + [InlineData("{}", JsonValueKind.Object)] + [InlineData("[]", JsonValueKind.Array)] + [InlineData("\"payload-only-marker\"", JsonValueKind.String)] + [InlineData("\"\"", JsonValueKind.String)] + [InlineData("9007199254740993", JsonValueKind.Number)] + [InlineData("1.234567890123456789", JsonValueKind.Number)] + [InlineData("0", JsonValueKind.Number)] + [InlineData("true", JsonValueKind.True)] + [InlineData("false", JsonValueKind.False)] + [InlineData("null", JsonValueKind.Null)] + [InlineData(null, null)] + public async Task Session_Create_Preserves_Remote_Error_And_Data(string? data, JsonValueKind? kind) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var dataMember = data is null ? "" : ",\"data\":" + data; + await using var server = new FakeCopilotServer("session.create", + $$"""{"code":{{ErrorCode}},"message":"{{ErrorMessage}}"{{dataMember}}}"""); + IOException error; + await using (var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) })) + { + error = await Assert.ThrowsAsync(() => + client.CreateSessionAsync(new SessionConfig(), timeout.Token)); + + // Processing a later response ensures the error response document has been disposed. + var ping = await client.PingAsync(cancellationToken: timeout.Token); + Assert.Equal("pong", ping.Message); + } + + var remote = Assert.IsType(error.InnerException); + Assert.Same(remote, error.GetBaseException()); + Assert.Equal(ErrorCode, remote.ErrorCode); + Assert.Equal(ErrorMessage, remote.Message); + Assert.Equal($"Communication error with Copilot CLI: {ErrorMessage}", error.Message); + Assert.Equal($"GitHub.Copilot.RemoteRpcException: {ErrorMessage}", remote.ToString().Split(Environment.NewLine)[0]); + Assert.Equal($"System.IO.IOException: {error.Message}", error.ToString().Split(Environment.NewLine)[0]); + Assert.DoesNotContain("payload-only-marker", remote.ToString()); + Assert.DoesNotContain("payload-only-marker", error.ToString()); + Assert.Equal(kind.HasValue, remote.ErrorData.HasValue); + if (kind.HasValue) + { + var payload = remote.ErrorData!.Value; + Assert.Equal(kind.Value, payload.ValueKind); + Assert.Equal(data, payload.GetRawText()); + } + } + + [Fact] + public async Task Successful_Response_Is_Not_An_Error() + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await using var server = new FakeCopilotServer("session.create", null); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + + var response = await client.PingAsync(cancellationToken: timeout.Token); + + Assert.Equal("pong", response.Message); + } + + [Fact] + public async Task Connection_Loss_Is_Not_A_Remote_Error() + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await using var server = new FakeCopilotServer("ping", null); + await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) }); + + var error = await Assert.ThrowsAsync(() => client.PingAsync(cancellationToken: timeout.Token)); + + Assert.NotNull(error.InnerException); + Assert.IsNotType(error.InnerException); + Assert.Equal("Communication error with Copilot CLI: The JSON-RPC connection was lost.", error.Message); + } + + private sealed class FakeCopilotServer : IAsyncDisposable + { + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource _cts = new(TimeSpan.FromSeconds(15)); + private readonly Task _serverTask; + private readonly string _method; + private readonly string? _error; + + public FakeCopilotServer(string method, string? error) + { + _method = method; + _error = error; + _listener.Start(); + Url = $"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}"; + _serverTask = RunAsync(); + } + + public string Url { get; } + + public async ValueTask DisposeAsync() + { + using var cancellation = _cts; + _cts.Cancel(); + try + { + await _serverTask.WaitAsync(TimeSpan.FromSeconds(5)); + } + catch (OperationCanceledException ex) when (ex.CancellationToken == _cts.Token) + { + // Canceling a pending accept/read/write is the expected shutdown path. + return; + } + finally + { + _listener.Stop(); + } + } + + private async Task RunAsync() + { + using var connection = await _listener.AcceptTcpClientAsync(_cts.Token); + using var stream = connection.GetStream(); + while (!_cts.IsCancellationRequested) + { + using var request = await ReadMessageAsync(stream, _cts.Token); + if (request is null) + { + return; + } + if (!request.RootElement.TryGetProperty("id", out var id)) + { + continue; + } + var method = request.RootElement.GetProperty("method").GetString(); + string response; + if (method == _method) + { + if (_error is null) + { + return; + } + response = $$"""{"jsonrpc":"2.0","id":{{id.GetRawText()}},"error":{{_error}}}"""; + } + else + { + var result = method switch + { + "connect" => """{"ok":true,"protocolVersion":3,"version":"test"}""", + "ping" => """{"message":"pong"}""", + _ => throw new InvalidOperationException($"Unexpected method: {method}"), + }; + response = $$"""{"jsonrpc":"2.0","id":{{id.GetRawText()}},"result":{{result}}}"""; + } + var body = Encoding.UTF8.GetBytes(response); + var header = Encoding.ASCII.GetBytes($"Content-Length: {body.Length}\r\n\r\n"); + await stream.WriteAsync(header, _cts.Token); + await stream.WriteAsync(body, _cts.Token); + } + } + + private static async Task ReadMessageAsync(Stream stream, CancellationToken cancellationToken) + { + var header = new List(); + var buffer = new byte[1]; + while (true) + { + if (await stream.ReadAsync(buffer, cancellationToken) == 0) + { + return null; + } + header.Add(buffer[0]); + if (header.Count >= 4 && header[^4] == '\r' && header[^3] == '\n' && + header[^2] == '\r' && header[^1] == '\n') + { + break; + } + } + var length = Encoding.ASCII.GetString([.. header]) + .Split("\r\n", StringSplitOptions.RemoveEmptyEntries) + .Select(line => line.Split(':', 2)) + .Where(parts => parts[0].Equals("Content-Length", StringComparison.OrdinalIgnoreCase)) + .Select(parts => int.Parse(parts[1].Trim(), CultureInfo.InvariantCulture)) + .Single(); + var body = new byte[length]; + await stream.ReadExactlyAsync(body, cancellationToken); + return JsonDocument.Parse(body); + } + } +} +#endif diff --git a/go/README.md b/go/README.md index fd8bdd245a..9c80cd1380 100644 --- a/go/README.md +++ b/go/README.md @@ -97,6 +97,37 @@ tool name is `-`. For `AvailableTools` and `mcp:-`. For `CustomAgents[].Tools` and `DefaultAgent.ExcludedTools`, use `-` directly. +## JSON-RPC errors + +Use `errors.As` to inspect a runtime error without parsing its message, including +errors wrapped by SDK operations: + +```go +var rpcErr *copilot.RPCError +if errors.As(err, &rpcErr) { + fmt.Printf("RPC error %d: %s\n", rpcErr.Code, rpcErr.Message) + if rpcErr.Data != nil { + // Decode into an application-specific type when the payload schema is known. + var details map[string]json.RawMessage + if err := json.Unmarshal(rpcErr.Data, &details); err != nil { + // The payload may be an array or scalar rather than an object. + log.Printf("Error data is not an object: %v", err) + } + } +} +``` + +This example uses the standard `errors` and `encoding/json` packages. +`RPCError.Data` is a `json.RawMessage` containing the original JSON value: +objects, arrays, strings, numbers, and booleans are preserved. Omitted `data` +is `nil`; explicit JSON null is the non-nil JSON text `null`. Empty values, +zero, and false are not treated as absent. Non-RPC failures do not match +`*copilot.RPCError`. + +`RPCError` aliases the existing transport error, so error identity, wrapping, +and `Error()` messages are unchanged. The error string does not include the +payload; accessing or logging it is an explicit application choice. + ## Distributing your application with an embedded GitHub Copilot CLI The SDK supports bundling, using Go's `embed` package, the Copilot CLI binary within your application's distribution. diff --git a/go/errors.go b/go/errors.go new file mode 100644 index 0000000000..7babdcb1f8 --- /dev/null +++ b/go/errors.go @@ -0,0 +1,15 @@ +package copilot + +import "github.com/github/copilot-sdk/go/internal/jsonrpc2" + +// RPCError is an error response from the runtime's JSON-RPC API. +// Use errors.As to retrieve it from errors wrapped by SDK operations. +// +// Code and Message contain the JSON-RPC error code and message. Data contains +// the optional JSON value, which can be an object, array, or scalar. Omitted +// data is nil; explicit JSON null is the JSON text "null". Error() does not +// include Data. +// +// RPCError is an alias of the transport error, preserving its identity and +// existing wrapping behavior. +type RPCError = jsonrpc2.Error diff --git a/go/errors_test.go b/go/errors_test.go new file mode 100644 index 0000000000..3ceed1c338 --- /dev/null +++ b/go/errors_test.go @@ -0,0 +1,216 @@ +package copilot_test + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + "testing" + "time" + + copilot "github.com/github/copilot-sdk/go" +) + +func TestRPCErrorData(t *testing.T) { + payloads := []struct { + name string + data string + }{ + {"object", `{"reason":"private detail","nested":{"values":[1,null,false]}}`}, + {"array", `[1,"private detail",{"nested":true}]`}, + {"string", `"private detail"`}, + {"integer", `9007199254740993`}, + {"fraction", `1.25`}, + {"zero", `0`}, + {"true", `true`}, + {"false", `false`}, + {"empty object", `{}`}, + {"empty array", `[]`}, + {"empty string", `""`}, + {"omitted", ``}, + {"null", `null`}, + } + + for _, payload := range payloads { + t.Run(payload.name, func(t *testing.T) { + client := newRPCErrorClient(t, payload.data) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + + for _, method := range []string{"status.get", "session.create", "models.list"} { + var err error + switch method { + case "session.create": + _, err = client.CreateSession(ctx, &copilot.SessionConfig{}) + case "status.get": + _, err = client.GetStatus(ctx) + case "models.list": + _, err = client.RPC.Models.List(ctx, nil) + } + + var rpcErr *copilot.RPCError + if !errors.As(err, &rpcErr) { + t.Fatalf("%s: expected RPCError, got %T: %v", method, err, err) + } + if rpcErr.Code != -32000 || rpcErr.Message != "request rejected" { + t.Fatalf("unexpected RPC error: %+v", rpcErr) + } + if string(rpcErr.Data) != payload.data { + t.Fatalf("data = %s, want %s", rpcErr.Data, payload.data) + } + if (rpcErr.Data == nil) != (payload.data == "") { + t.Fatalf("data presence = %v, want %v", rpcErr.Data != nil, payload.data != "") + } + want := "JSON-RPC Error -32000: request rejected" + if rpcErr.Error() != want { + t.Fatalf("RPC error string = %q, want %q", rpcErr.Error(), want) + } + if method == "session.create" { + want = "failed to create session: " + want + if errors.Unwrap(err) != rpcErr { + t.Fatal("SDK wrapper did not preserve the RPC error identity") + } + } else if err != rpcErr { + t.Fatal("direct call changed the RPC error identity") + } + if err.Error() != want { + t.Fatalf("error string = %q, want %q", err.Error(), want) + } + outer := fmt.Errorf("application context: %w", err) + var recovered *copilot.RPCError + if !errors.As(outer, &recovered) || recovered != rpcErr { + t.Fatal("application wrapper did not preserve errors.As access") + } + } + + response, err := client.Ping(ctx, "still connected") + if err != nil || response.Message != "still connected" { + t.Fatalf("successful response after RPC errors: response=%+v, err=%v", response, err) + } + }) + } +} + +func TestRPCErrorNonRPCFailure(t *testing.T) { + client := copilot.NewClient(&copilot.ClientOptions{}) + _, err := client.GetStatus(t.Context()) + var rpcErr *copilot.RPCError + if err == nil || errors.As(err, &rpcErr) { + t.Fatalf("expected a non-RPC error, got %T: %v", err, err) + } +} + +// Use a raw framed peer so these tests exercise the public API without importing +// the SDK's internal transport or re-encoding the error through RPCError. +func newRPCErrorClient(t *testing.T, data string) *copilot.Client { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { + done <- serveRPCErrorPeer(listener, data) + }() + t.Cleanup(func() { + listener.Close() + select { + case err := <-done: + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) { + t.Errorf("RPC peer: %v", err) + } + case <-time.After(10 * time.Second): + t.Error("RPC peer did not stop") + } + }) + + client := copilot.NewClient(&copilot.ClientOptions{ + Connection: copilot.URIConnection{URL: listener.Addr().String()}, + }) + t.Cleanup(client.ForceStop) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + if err := client.Start(ctx); err != nil { + t.Fatal(err) + } + return client +} + +func serveRPCErrorPeer(listener net.Listener, data string) error { + conn, err := listener.Accept() + if err != nil { + return err + } + defer conn.Close() + if err := conn.SetDeadline(time.Now().Add(10 * time.Second)); err != nil { + return err + } + reader := bufio.NewReader(conn) + for { + body, err := readRPCErrorFrame(reader) + if err != nil { + return err + } + var request struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + if err := json.Unmarshal(body, &request); err != nil { + return err + } + if len(request.ID) == 0 { + continue + } + var response string + switch request.Method { + case "connect": + response = `"result":{"ok":true,"protocolVersion":3,"version":"test"}` + case "status.get", "session.create", "models.list": + response = `"error":{"code":-32000,"message":"request rejected"` + if data != "" { + response += `,"data":` + data + } + response += `}` + case "ping": + response = `"result":{"message":"still connected","timestamp":"2026-01-01T00:00:00Z"}` + default: + return fmt.Errorf("unexpected method %q", request.Method) + } + frame := fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,%s}`, request.ID, response) + if _, err := fmt.Fprintf(conn, "Content-Length: %d\r\n\r\n%s", len(frame), frame); err != nil { + return err + } + } +} + +func readRPCErrorFrame(reader *bufio.Reader) ([]byte, error) { + length := 0 + for { + line, err := reader.ReadString('\n') + if err != nil { + return nil, err + } + line = strings.TrimSpace(line) + if line == "" { + break + } + name, value, ok := strings.Cut(line, ":") + if ok && name == "Content-Length" { + length, err = strconv.Atoi(strings.TrimSpace(value)) + if err != nil { + return nil, err + } + } + } + if length <= 0 || length > 1024*1024 { + return nil, fmt.Errorf("invalid Content-Length %d", length) + } + body := make([]byte, length) + _, err := io.ReadFull(reader, body) + return body, err +} diff --git a/java/README.md b/java/README.md index f3381e3255..014a01252a 100644 --- a/java/README.md +++ b/java/README.md @@ -488,6 +488,59 @@ var resumed = client.resumeSession(sessionId, new ResumeSessionConfig() When `memory` is left unset, no memory configuration is sent and the runtime default applies. In the default `CopilotClientMode.COPILOT_CLI` the SDK leaves `memory` unset so the runtime applies its own default, while `CopilotClientMode.EMPTY` defaults `memory` to disabled unless you set it explicitly. +## JSON-RPC error handling + +Server error responses surface as `com.github.copilot.JsonRpcException`, a +`RuntimeException` with `getCode()`, `getMessage()`, and `getData()`. The data is a +Jackson `JsonNode`: objects, arrays, strings, numbers, and booleans retain their +JSON types, including empty values, zero, and false. Numeric fidelity follows +Jackson's existing parser. Omitted data returns Java `null`; explicit JSON `null` +returns a `NullNode` (`data.isNull()` is true). + +Future wrapping is unchanged. With `get()`, inspect the cause of +`ExecutionException`: + +```java +import com.github.copilot.JsonRpcException; +import java.util.concurrent.ExecutionException; + +try { + client.ping("hello").get(); +} catch (ExecutionException ex) { + if (ex.getCause() instanceof JsonRpcException rpcError) { + System.err.println("RPC " + rpcError.getCode() + ": " + rpcError.getMessage()); + var data = rpcError.getData(); + if (data != null && !data.isNull()) { + // Inspect data according to the server's error contract. + } + } else { + throw ex; // Transport and local failures are not JSON-RPC error responses. + } +} +``` + +The enclosing method must also handle or declare `InterruptedException`. +With `join()`, the wrapper is `CompletionException` instead: + +```java +import java.util.concurrent.CompletionException; + +try { + client.ping("hello").join(); +} catch (CompletionException ex) { + if (ex.getCause() instanceof JsonRpcException rpcError) { + System.err.println("RPC " + rpcError.getCode() + ": " + rpcError.getMessage()); + var data = rpcError.getData(); + // Java null means omitted; data.isNull() means an explicit JSON null. + } else { + throw ex; + } +} +``` + +Error data is not appended to `getMessage()` or `toString()`. Avoid logging it +indiscriminately: server-provided data may contain sensitive information. + ## Using experimental APIs Some SDK APIs are marked as experimental with `@CopilotExperimental`. These APIs may change or be removed in future versions without notice. diff --git a/java/sdk/src/main/java/com/github/copilot/JsonRpcClient.java b/java/sdk/src/main/java/com/github/copilot/JsonRpcClient.java index f78ce00425..8f50f4f8e6 100644 --- a/java/sdk/src/main/java/com/github/copilot/JsonRpcClient.java +++ b/java/sdk/src/main/java/com/github/copilot/JsonRpcClient.java @@ -375,7 +375,8 @@ private void handleMessage(String content) { ? errorNode.get("message").asText() : "Unknown error"; int errorCode = errorNode.has("code") ? errorNode.get("code").asInt() : -1; - future.completeExceptionally(new JsonRpcException(errorCode, errorMessage)); + future.completeExceptionally( + new JsonRpcException(errorCode, errorMessage, errorNode.get("data"))); } else { future.complete(node.get("result")); } diff --git a/java/sdk/src/main/java/com/github/copilot/JsonRpcException.java b/java/sdk/src/main/java/com/github/copilot/JsonRpcException.java index 1786466bdd..7e8b00234f 100644 --- a/java/sdk/src/main/java/com/github/copilot/JsonRpcException.java +++ b/java/sdk/src/main/java/com/github/copilot/JsonRpcException.java @@ -4,18 +4,25 @@ package com.github.copilot; +import com.fasterxml.jackson.databind.JsonNode; + /** * Exception thrown when a JSON-RPC error occurs during communication with the * Copilot CLI server. *

* This exception wraps error responses from the JSON-RPC protocol, including - * the error code and message returned by the server. + * the error code, message, and optional raw JSON data returned by the server. + * Calls to {@link java.util.concurrent.CompletableFuture#get()} wrap this + * exception in an {@link java.util.concurrent.ExecutionException}; calls to + * {@link java.util.concurrent.CompletableFuture#join()} wrap it in a + * {@link java.util.concurrent.CompletionException}. * * @since 1.0.0 */ -final class JsonRpcException extends RuntimeException { +public final class JsonRpcException extends RuntimeException { private final int code; + private final JsonNode data; /** * Creates a new JSON-RPC exception. @@ -26,8 +33,25 @@ final class JsonRpcException extends RuntimeException { * the error message from the server */ public JsonRpcException(int code, String message) { + this(code, message, null); + } + + /** + * Creates a new JSON-RPC exception with optional raw JSON error data. + * + * @param code + * the JSON-RPC error code + * @param message + * the error message from the server + * @param data + * the error data, or {@code null} if omitted; a JSON null is + * represented by a + * {@link com.fasterxml.jackson.databind.node.NullNode} + */ + public JsonRpcException(int code, String message, JsonNode data) { super(message); this.code = code; + this.data = data; } /** @@ -47,4 +71,18 @@ public JsonRpcException(int code, String message) { public int getCode() { return code; } + + /** + * Returns the raw JSON error data without converting its value or shape. + *

+ * An omitted data member is represented by Java {@code null}. An explicit JSON + * null is represented by a + * {@link com.fasterxml.jackson.databind.node.NullNode}. This value is not + * included in the exception's message or string representation. + * + * @return the error data, or {@code null} if the server omitted it + */ + public JsonNode getData() { + return data; + } } diff --git a/java/sdk/src/test/java/com/github/copilot/consumer/JsonRpcErrorDataTest.java b/java/sdk/src/test/java/com/github/copilot/consumer/JsonRpcErrorDataTest.java new file mode 100644 index 0000000000..7cb8c685fa --- /dev/null +++ b/java/sdk/src/test/java/com/github/copilot/consumer/JsonRpcErrorDataTest.java @@ -0,0 +1,236 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.consumer; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.ByteArrayInputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.CopilotClient; +import com.github.copilot.JsonRpcException; +import com.github.copilot.rpc.CopilotClientOptions; + +@Timeout(15) +class JsonRpcErrorDataTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final int ERROR_CODE = -32001; + private static final String ERROR_MESSAGE = "Request failed"; + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"{\"reason\":\"details-not-in-message\",\"nested\":{\"items\":[1,false,null]}}", + "[{\"nested\":[\"value\"]},2,false,null]", "\"detail — unicode\"", "42", "9007199254740993", "1.25", "0", + "true", "false", "{}", "[]", "\"\"", "null"}) + void preservesRawDataThroughPublicClient(String dataJson) throws Exception { + try (var server = new FramedServer(Reply.ERROR, dataJson); + var client = new CopilotClient(new CopilotClientOptions().setCliUrl(server.url()))) { + client.start().get(5, TimeUnit.SECONDS); + var future = client.ping("error"); + + var getFailure = assertThrows(ExecutionException.class, () -> future.get(5, TimeUnit.SECONDS)); + var error = assertInstanceOf(JsonRpcException.class, getFailure.getCause()); + assertEquals(JsonRpcException.class, error.getClass()); + assertInstanceOf(RuntimeException.class, error); + assertEquals(ERROR_CODE, error.getCode()); + assertEquals(ERROR_MESSAGE, error.getMessage()); + assertEquals("com.github.copilot.JsonRpcException: Request failed", error.toString()); + if (dataJson == null) { + assertNull(error.getData()); + } else { + assertEquals(MAPPER.readTree(dataJson), error.getData()); + } + + var joinFailure = assertThrows(CompletionException.class, future::join); + assertSame(error, joinFailure.getCause()); + } + } + + @Test + void preservesSuccessfulResponses() throws Exception { + try (var server = new FramedServer(Reply.SUCCESS, null); + var client = new CopilotClient(new CopilotClientOptions().setCliUrl(server.url()))) { + client.start().get(5, TimeUnit.SECONDS); + assertEquals("hello", client.ping("hello").get(5, TimeUnit.SECONDS).message()); + } + } + + @Test + void localFailuresRemainDistinctFromRemoteErrors() { + try (var client = new CopilotClient(new CopilotClientOptions().setAutoStart(false))) { + RuntimeException failure = assertThrows(IllegalStateException.class, () -> client.ping("not connected")); + assertEquals("Client not connected. Call start() first.", failure.getMessage()); + assertFalse(failure instanceof JsonRpcException); + } + } + + @Test + void preservesConstructorCompatibility() throws Exception { + var withoutData = new JsonRpcException(ERROR_CODE, ERROR_MESSAGE); + assertNull(withoutData.getData()); + assertEquals(ERROR_CODE, withoutData.getCode()); + assertEquals(ERROR_MESSAGE, withoutData.getMessage()); + var data = MAPPER.readTree("{\"detail\":false}"); + var withData = new JsonRpcException(ERROR_CODE, ERROR_MESSAGE, data); + assertSame(data, withData.getData()); + assertEquals(withoutData.toString(), withData.toString()); + } + + @ParameterizedTest + @ValueSource(strings = {"not-a-number", "2147483648"}) + void malformedFrameLengthIsAnIoFailure(String length) { + var frame = ("Content-Length: " + length + "\r\n\r\n").getBytes(StandardCharsets.US_ASCII); + var error = assertThrows(IOException.class, () -> FramedServer.readFrame(new ByteArrayInputStream(frame))); + assertEquals("Invalid Content-Length: " + length, error.getMessage()); + assertInstanceOf(NumberFormatException.class, error.getCause()); + } + + private enum Reply { + ERROR, SUCCESS + } + + /** + * A framed loopback peer that uses no SDK internals or CLI runtime. + */ + private static final class FramedServer implements AutoCloseable { + + private final ServerSocket listener; + private final Thread worker; + private final CompletableFuture finished = new CompletableFuture<>(); + private volatile Socket socket; + private volatile boolean closing; + + FramedServer(Reply reply, String dataJson) throws IOException { + listener = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")); + listener.setSoTimeout(5000); + worker = new Thread(() -> serve(reply, dataJson), "json-rpc-error-data-peer"); + worker.setDaemon(true); + worker.start(); + } + + String url() { + return "127.0.0.1:" + listener.getLocalPort(); + } + + private void serve(Reply reply, String dataJson) { + try (var accepted = listener.accept()) { + socket = accepted; + accepted.setSoTimeout(5000); + while (!closing) { + JsonNode request = readFrame(accepted.getInputStream()); + if (request == null) { + break; + } + if (!request.has("id")) { + continue; + } + var response = MAPPER.createObjectNode().put("jsonrpc", "2.0"); + response.set("id", request.get("id")); + switch (request.path("method").asText()) { + case "connect" -> response.putObject("result").put("protocolVersion", 2); + case "ping" -> { + if (reply == Reply.SUCCESS) { + response.putObject("result") + .put("message", request.path("params").path("message").asText()) + .put("protocolVersion", 2); + } else { + var error = response.putObject("error").put("code", ERROR_CODE).put("message", + ERROR_MESSAGE); + if (dataJson != null) { + error.set("data", MAPPER.readTree(dataJson)); + } + } + } + case "runtime.shutdown" -> response.putObject("result"); + default -> throw new IOException("Unexpected method: " + request.path("method")); + } + writeFrame(accepted.getOutputStream(), response); + } + } catch (IOException ex) { + if (!closing) { + finished.completeExceptionally(ex); + } + } finally { + finished.complete(null); + } + } + + private static JsonNode readFrame(InputStream input) throws IOException { + var header = new StringBuilder(); + while (!header.toString().endsWith("\r\n\r\n")) { + int value = input.read(); + if (value < 0) { + if (header.isEmpty()) { + return null; + } + throw new EOFException("Incomplete frame header"); + } + header.append((char) value); + if (header.length() > 1024) { + throw new IOException("Frame header too large"); + } + } + int length = -1; + for (String line : header.toString().split("\r\n")) { + if (line.startsWith("Content-Length:")) { + var value = line.substring("Content-Length:".length()).trim(); + try { + length = Integer.parseInt(value); + } catch (NumberFormatException ex) { + throw new IOException("Invalid Content-Length: " + value, ex); + } + } + } + if (length < 0 || length > 65536) { + throw new IOException("Invalid frame length: " + length); + } + byte[] body = input.readNBytes(length); + if (body.length != length) { + throw new EOFException("Incomplete frame body"); + } + return MAPPER.readTree(body); + } + + private static void writeFrame(OutputStream output, JsonNode response) throws IOException { + byte[] body = MAPPER.writeValueAsBytes(response); + output.write(("Content-Length: " + body.length + "\r\n\r\n").getBytes(StandardCharsets.US_ASCII)); + output.write(body); + output.flush(); + } + + @Override + public void close() throws Exception { + closing = true; + listener.close(); + Socket accepted = socket; + if (accepted != null) { + accepted.close(); + } + worker.join(5000); + assertFalse(worker.isAlive(), "Framed peer must terminate"); + finished.get(5, TimeUnit.SECONDS); + } + } +}