Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
Expand All @@ -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:
Expand Down
19 changes: 18 additions & 1 deletion dotnet/src/JsonRpc.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1000,13 +1000,30 @@ internal sealed class ConnectionLostException() : IOException("The JSON-RPC conn
/// <summary>
/// Thrown when the remote side returns a JSON-RPC error response.
/// </summary>
internal sealed class RemoteRpcException(string message, int errorCode, JsonElement? errorData = null, Exception? innerException = null) : Exception(message, innerException)
/// <remarks>
/// Client RPC calls wrap this exception in an <see cref="IOException"/>.
/// Inspect its <see cref="Exception.InnerException"/> to access the remote error.
/// </remarks>
/// <param name="message">The remote error message.</param>
/// <param name="errorCode">The numeric JSON-RPC error code.</param>
/// <param name="errorData">The optional JSON error data, cloned to retain its lifetime.</param>
/// <param name="innerException">The exception that caused this error, if any.</param>
public sealed class RemoteRpcException(string message, int errorCode, JsonElement? errorData = null, Exception? innerException = null) : Exception(message, innerException)
{
/// <summary>JSON-RPC 2.0 reserved error code: requested method does not exist.</summary>
public const int MethodNotFoundErrorCode = -32601;

/// <summary>Gets the numeric code from the JSON-RPC error response.</summary>
public int ErrorCode { get; } = errorCode;

/// <summary>Gets the unmodified JSON data from the remote error, if provided.</summary>
/// <remarks>
/// A missing <c>data</c> member produces a nullable value with no value.
/// An explicit JSON <c>null</c> produces a present element whose
/// <see cref="JsonElement.ValueKind"/> is <see cref="JsonValueKind.Null"/>.
/// All other JSON value kinds are preserved. The element is cloned and remains
/// valid after the response document and client are disposed.
/// </remarks>
public JsonElement? ErrorData { get; } = errorData.HasValue ? errorData.Value.Clone() : null;
}

Expand Down
204 changes: 204 additions & 0 deletions dotnet/test/Unit/RpcErrorDataTests.cs
Original file line number Diff line number Diff line change
@@ -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<IOException>(() =>
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<RemoteRpcException>(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<IOException>(() => client.PingAsync(cancellationToken: timeout.Token));

Assert.NotNull(error.InnerException);
Assert.IsNotType<RemoteRpcException>(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));
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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<JsonDocument?> ReadMessageAsync(Stream stream, CancellationToken cancellationToken)
{
var header = new List<byte>();
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
31 changes: 31 additions & 0 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,37 @@ tool name is `<server-key>-<tool-name>`. For `AvailableTools` and
`mcp:<server-key>-<tool-name>`. For `CustomAgents[].Tools` and
`DefaultAgent.ExcludedTools`, use `<server-key>-<tool-name>` 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.
Expand Down
15 changes: 15 additions & 0 deletions go/errors.go
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading