From 0f2ed0cfc63856db245bfe425a82f7d0da52ae5a Mon Sep 17 00:00:00 2001 From: Hunter Sadler Date: Sat, 19 Sep 2026 10:54:18 -0600 Subject: [PATCH 1/2] Expose structured RPC error data in Go, .NET, and Java Preserve existing error identities and wrapping while exposing raw JSON data through supported public APIs. Add framed transport regressions and usage documentation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/README.md | 19 ++ dotnet/src/JsonRpc.cs | 19 +- dotnet/test/Unit/RpcErrorDataTests.cs | 202 ++++++++++++++++ go/README.md | 31 +++ go/errors.go | 15 ++ go/errors_test.go | 216 +++++++++++++++++ java/README.md | 53 +++++ .../com/github/copilot/JsonRpcClient.java | 3 +- .../com/github/copilot/JsonRpcException.java | 42 +++- .../consumer/JsonRpcErrorDataTest.java | 221 ++++++++++++++++++ 10 files changed, 817 insertions(+), 4 deletions(-) create mode 100644 dotnet/test/Unit/RpcErrorDataTests.cs create mode 100644 go/errors.go create mode 100644 go/errors_test.go create mode 100644 java/sdk/src/test/java/com/github/copilot/consumer/JsonRpcErrorDataTest.java 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..da01e63a0d --- /dev/null +++ b/dotnet/test/Unit/RpcErrorDataTests.cs @@ -0,0 +1,202 @@ +/*--------------------------------------------------------------------------------------------- + * 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() + { + _cts.Cancel(); + _listener.Stop(); + try + { + await _serverTask.WaitAsync(TimeSpan.FromSeconds(5)); + } + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or IOException or SocketException) + { + } + finally + { + _cts.Dispose(); + } + } + + 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..e50ac26cf1 --- /dev/null +++ b/java/sdk/src/test/java/com/github/copilot/consumer/JsonRpcErrorDataTest.java @@ -0,0 +1,221 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.consumer; + +import static org.junit.jupiter.api.Assertions.*; + +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()); + } + + 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:")) { + length = Integer.parseInt(line.substring("Content-Length:".length()).trim()); + } + } + 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); + } + } +} From fa0d51ea76bd0ebebd1efc4b20b6a6aa690353ce Mon Sep 17 00:00:00 2001 From: Hunter Sadler Date: Sat, 19 Sep 2026 12:31:53 -0600 Subject: [PATCH 2/2] Address RPC error test helper review feedback Surface malformed frame lengths as IO failures with regression coverage. Use scoped cancellation disposal and only suppress the expected server shutdown cancellation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet/test/Unit/RpcErrorDataTests.cs | 8 +++++--- .../copilot/consumer/JsonRpcErrorDataTest.java | 17 ++++++++++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/dotnet/test/Unit/RpcErrorDataTests.cs b/dotnet/test/Unit/RpcErrorDataTests.cs index da01e63a0d..cd4be91257 100644 --- a/dotnet/test/Unit/RpcErrorDataTests.cs +++ b/dotnet/test/Unit/RpcErrorDataTests.cs @@ -113,18 +113,20 @@ public FakeCopilotServer(string method, string? error) public async ValueTask DisposeAsync() { + using var cancellation = _cts; _cts.Cancel(); - _listener.Stop(); try { await _serverTask.WaitAsync(TimeSpan.FromSeconds(5)); } - catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or IOException or SocketException) + catch (OperationCanceledException ex) when (ex.CancellationToken == _cts.Token) { + // Canceling a pending accept/read/write is the expected shutdown path. + return; } finally { - _cts.Dispose(); + _listener.Stop(); } } 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 index e50ac26cf1..7cb8c685fa 100644 --- a/java/sdk/src/test/java/com/github/copilot/consumer/JsonRpcErrorDataTest.java +++ b/java/sdk/src/test/java/com/github/copilot/consumer/JsonRpcErrorDataTest.java @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.*; +import java.io.ByteArrayInputStream; import java.io.EOFException; import java.io.IOException; import java.io.InputStream; @@ -97,6 +98,15 @@ void preservesConstructorCompatibility() throws Exception { 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 } @@ -185,7 +195,12 @@ private static JsonNode readFrame(InputStream input) throws IOException { int length = -1; for (String line : header.toString().split("\r\n")) { if (line.startsWith("Content-Length:")) { - length = Integer.parseInt(line.substring("Content-Length:".length()).trim()); + 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) {