Skip to content

Commit 7b62bfb

Browse files
authored
Refactor Rust SDK errors to use structs with a kind() method (#1400)
* Refactor Rust SDK errors to use structs with a `kind()` method The conventional `#[non_exhaustive] enum Error { ... }` pattern appears safe but creates problems as a library evolves. This PR changes all error types to the struct-with-`kind()` pattern, which also aligns with the Azure SDK for Rust error design. Why not a flat error enum: - `#[non_exhaustive]` on the enum prevents exhaustive matching, but individual variants are still fixed. Adding a field to any variant — even just to improve an error message with a line number or file path — is a breaking change. - Adding context data is harder than it looks. With a flat enum, new fields touch every affected variant and all match arms across the codebase. With a struct, new fields are added in one place and callers who don't use them are unaffected. - A single enum conflates all failure modes, making it impossible to document or guarantee which variants a given function can actually return. Callers must handle unrelated variants they will never see, or accept a wildcard arm that silently swallows future additions. The struct + kind pattern: | Concern | Flat enum | Struct + `kind()` | |---|---|---| | Categorization | Match directly on variant | Call `.kind()` → `&*Kind` | | Adding context | Breaking: add fields to variant | Non-breaking: add fields to struct | | `non_exhaustive` | On enum; variants are fixed | Not needed on struct with only private fields | | Simple display | Must match all variants | `format!("{err}")` — no match needed | Callers who only want to display or propagate an error with `?` do not need to call `.kind()` at all. Only callers who need to inspect the failure category call `.kind()`, and they get a stable, scoped `*Kind` enum to match against. * Remove unnecessary error structs Sticking with `*Kind` as a convention for error enums. * Add backtrace support to Error struct Enhanced the Error struct to include an optional backtrace, which is captured only when `RUST_BACKTRACE` is set. This change helps in debugging by providing context on error occurrences without inflating the Error size unnecessarily. * Resolve PR feedback
1 parent 6010405 commit 7b62bfb

24 files changed

Lines changed: 1082 additions & 503 deletions

.github/copilot-instructions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
- Java single test: `cd java && mvn test -Dtest=CopilotClientTest` | single method: `mvn test -Dtest=ToolsTest#testToolInvocation`
3232
- Java format check only: `mvn spotless:check` | Build without tests: `mvn clean package -DskipTests`
3333
- **Java testing note:** Always use `mvn verify` without `-q` and without piping through `grep`. Never add `InternalsVisibleTo` equivalent — tests must only access public APIs.
34+
- Use configured LSPs for supported operations like finding references instead of pattern matching, renaming symbols, etc.
3435

3536
## Testing & E2E tips ⚙️
3637

.github/lsp.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,24 @@
2121
".go": "go"
2222
},
2323
"rootUri": "go"
24+
},
25+
"rust-analyzer": {
26+
"command": "rust-analyzer",
27+
"fileExtensions": {
28+
".rs": "rust"
29+
},
30+
"initializationOptions": {
31+
"cargo": {
32+
"buildScripts": {
33+
"enable": true
34+
},
35+
"allFeatures": true
36+
},
37+
"checkOnSave": true,
38+
"check": {
39+
"command": "clippy"
40+
}
41+
}
2442
}
2543
}
2644
}

.github/skills/rust-coding-skill/SKILL.md

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,10 @@ Opinionated Rust rules for the Copilot Rust SDK (`rust/`). Priority order:
1313

1414
## Error handling
1515

16-
The SDK's public error type is `crate::Error` (`rust/src/error.rs`). Add new
17-
variants there rather than introducing parallel error enums per module — every
18-
public failure mode is part of the API contract and should be expressible in one
19-
type. Internal modules can use `thiserror` enums when a richer local taxonomy
20-
helps; convert at the boundary.
16+
The SDK's public error type is `crate::Error` (`rust/src/errors.rs`). Add new
17+
variants to `crate::ErrorKind` rather than introducing parallel error enums
18+
per module — every public failure mode is part of the API contract and should
19+
be expressible in one type.
2120

2221
`anyhow` is reserved for binaries and example code. Library code never returns
2322
`anyhow::Result` — callers can't pattern-match on `anyhow::Error`, so it would
@@ -42,7 +41,7 @@ it on shutdown. Fire-and-forget spawns silently swallow panics and outlive the
4241
session; don't.
4342

4443
Blocking calls (filesystem, subprocess wait) belong in
45-
`tokio::task::spawn_blocking`, *not* on the async runtime. The blocking pool is
44+
`tokio::task::spawn_blocking`, _not_ on the async runtime. The blocking pool is
4645
bounded, so for genuinely long-lived workers (think: file watchers that run for
4746
the lifetime of a session) prefer `std::thread::spawn` with a channel back into
4847
async land.
@@ -81,12 +80,12 @@ Trivial field re-shaping is best inlined. Closures should stay short (under ~10
8180

8281
**Channels, not callback closures, for event flow.** Closures fight `Send + Sync + 'static` and don't compose with `select!`. Channel choice by semantics:
8382

84-
| Use case | Primitive |
85-
|---|---|
86-
| One producer → one consumer with backpressure | `tokio::sync::mpsc` (cap 1) or `tokio::sync::oneshot` for single value |
87-
| Many producers → one consumer | `tokio::sync::mpsc` |
88-
| One producer → many consumers, every event delivered (pub/sub) | `tokio::sync::broadcast` |
89-
| One producer → many consumers, only the latest value matters | `tokio::sync::watch` |
83+
| Use case | Primitive |
84+
| -------------------------------------------------------------- | ---------------------------------------------------------------------- |
85+
| One producer → one consumer with backpressure | `tokio::sync::mpsc` (cap 1) or `tokio::sync::oneshot` for single value |
86+
| Many producers → one consumer | `tokio::sync::mpsc` |
87+
| One producer → many consumers, every event delivered (pub/sub) | `tokio::sync::broadcast` |
88+
| One producer → many consumers, only the latest value matters | `tokio::sync::watch` |
9089

9190
For the **public** API, prefer returning `impl Stream<Item = Event>` (wrap a `broadcast::Receiver` in `tokio_stream::wrappers::BroadcastStream`). `Stream` composes with `select!`, `take`, `map`, `filter`, `timeout`. See `EventSubscription` and `LifecycleSubscription`.
9291

@@ -115,7 +114,7 @@ JSON: `#[serde(rename_all = "camelCase")]` at the type level, per-field `#[serde
115114
Banned via `clippy.toml`. Use manual spans with `error_span!`:
116115

117116
- **Almost always use `error_span!`**, not `info_span!`. Span level controls
118-
the *minimum* filter at which the span appears. An `info_span` disappears when
117+
the _minimum_ filter at which the span appears. An `info_span` disappears when
119118
the filter is `warn` or `error` — taking all child events with it, even
120119
errors. `error_span!` ensures the span is always present.
121120
- **Spawned tasks lose parent context.** Attach a span with `.instrument()` or
@@ -239,9 +238,9 @@ Match those exact commands locally before pushing.
239238

240239
JSON-RPC and session-event types are generated from the Copilot CLI schema:
241240

242-
| Source | Output |
243-
|---|---|
244-
| `nodejs/node_modules/@github/copilot/schemas/api.schema.json` | `rust/src/generated/api_types.rs` |
241+
| Source | Output |
242+
| ------------------------------------------------------------------------ | -------------------------------------- |
243+
| `nodejs/node_modules/@github/copilot/schemas/api.schema.json` | `rust/src/generated/api_types.rs` |
245244
| `nodejs/node_modules/@github/copilot/schemas/session-events.schema.json` | `rust/src/generated/session_events.rs` |
246245

247246
Regenerate with:

.vscode/settings.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@
1414
"python.testing.pytestEnabled": true,
1515
"python.testing.unittestEnabled": false,
1616
"python.testing.pytestArgs": ["python"],
17+
"rust-analyzer.cargo.features": "all",
18+
"rust-analyzer.check.command": "clippy",
19+
"[rust]": {
20+
"editor.defaultFormatter": "rust-lang.rust-analyzer"
21+
},
1722
"[python]": {
1823
"editor.defaultFormatter": "charliermarsh.ruff"
1924
},

rust/Cargo.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rust/Cargo.toml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ async-trait = "0.1"
4343
schemars = { version = "1", optional = true }
4444
serde = { version = "1", features = ["derive"] }
4545
serde_json = "1"
46-
thiserror = "2"
4746
tokio = { version = "1", features = ["io-util", "sync", "rt", "process", "net", "time", "macros"] }
4847
tokio-stream = { version = "0.1", features = ["sync"] }
4948
tokio-util = { version = "0.7", default-features = false }
@@ -68,6 +67,18 @@ serial_test = "3"
6867
tempfile = "3"
6968
tokio = { version = "1", features = ["rt-multi-thread"] }
7069

70+
# Integration tests that call test-support-only Client methods (e.g.
71+
# `from_streams_with_connection_token`, `from_streams_with_trace_provider`)
72+
# require the `test-support` feature because `cfg(test)` is not set on the
73+
# library when Cargo compiles it for integration tests.
74+
[[test]]
75+
name = "session_test"
76+
required-features = ["test-support"]
77+
78+
[[test]]
79+
name = "protocol_version_test"
80+
required-features = ["test-support"]
81+
7182
[build-dependencies]
7283
dirs = "5"
7384
flate2 = "1"

rust/examples/manual_tool_resume.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ use github_copilot_sdk::generated::api_types::{
99
use github_copilot_sdk::generated::session_events::{
1010
AssistantMessageData, ExternalToolRequestedData, PermissionRequestedData, SessionEventType,
1111
};
12+
use github_copilot_sdk::subscription::RecvError;
1213
use github_copilot_sdk::{
13-
Client, ClientOptions, EventSubscription, RecvError, ResumeSessionConfig, SessionConfig,
14+
Client, ClientOptions, EventSubscription, ResumeSessionConfig, SessionConfig,
1415
};
1516
use serde_json::json;
1617

rust/examples/session_fs.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use std::sync::Arc;
1515
use async_trait::async_trait;
1616
use github_copilot_sdk::handler::ApproveAllHandler;
1717
use github_copilot_sdk::session_fs::{
18-
DirEntry, DirEntryKind, FileInfo, FsError, SessionFsConfig, SessionFsConventions,
18+
DirEntry, DirEntryKind, FileInfo, FsError, FsErrorKind, SessionFsConfig, SessionFsConventions,
1919
SessionFsProvider,
2020
};
2121
use github_copilot_sdk::types::{MessageOptions, SessionConfig};
@@ -46,7 +46,7 @@ impl SessionFsProvider for InMemoryProvider {
4646
.lock()
4747
.get(path)
4848
.cloned()
49-
.ok_or_else(|| FsError::NotFound(path.to_string()))
49+
.ok_or_else(|| FsError::from(FsErrorKind::NotFound(path.to_string())))
5050
}
5151

5252
async fn write_file(
@@ -69,7 +69,7 @@ impl SessionFsProvider for InMemoryProvider {
6969
let files = self.files.lock();
7070
let content = files
7171
.get(path)
72-
.ok_or_else(|| FsError::NotFound(path.to_string()))?;
72+
.ok_or_else(|| FsError::from(FsErrorKind::NotFound(path.to_string())))?;
7373
Ok(FileInfo::new(
7474
true,
7575
false,
@@ -101,7 +101,7 @@ impl SessionFsProvider for InMemoryProvider {
101101

102102
async fn rm(&self, path: &str, _recursive: bool, force: bool) -> Result<(), FsError> {
103103
if self.files.lock().remove(path).is_none() && !force {
104-
return Err(FsError::NotFound(path.to_string()));
104+
return Err(FsError::from(FsErrorKind::NotFound(path.to_string())));
105105
}
106106
Ok(())
107107
}

rust/src/canvas.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
use async_trait::async_trait;
44
use serde::{Deserialize, Serialize};
55
use serde_json::Value;
6-
use thiserror::Error;
76

87
use crate::generated::api_types::CanvasAction;
98

@@ -54,16 +53,23 @@ impl CanvasDeclaration {
5453
}
5554

5655
/// Structured error returned from canvas handlers.
57-
#[derive(Debug, Clone, Error, Serialize, Deserialize, PartialEq, Eq)]
56+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
5857
#[serde(rename_all = "camelCase")]
59-
#[error("{code}: {message}")]
6058
pub struct CanvasError {
6159
/// Machine-readable error code.
6260
pub code: String,
6361
/// Human-readable message.
6462
pub message: String,
6563
}
6664

65+
impl std::fmt::Display for CanvasError {
66+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67+
write!(f, "{}: {}", self.code, self.message)
68+
}
69+
}
70+
71+
impl std::error::Error for CanvasError {}
72+
6773
impl CanvasError {
6874
/// Construct a new error envelope with the given code and message.
6975
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {

0 commit comments

Comments
 (0)