From 837b42e7372cbfbe246de3bab6e57152c0007973 Mon Sep 17 00:00:00 2001 From: Connor Moss Date: Thu, 29 Oct 2026 17:26:22 -0400 Subject: [PATCH] fix: make error hints match the error class A local file error was classified as IO because the syscall.Errno inside a *fs.PathError satisfies net.Error. Failing to write --save-fileparts or read `server --card` exited 3 with a connectivity hint. It now exits 1. A card request that got 401/403 or 5xx said to check the --agent-card reference. It now asks for credentials or points at the agent. --- internal/clierr/clierr.go | 26 ++++++++++++++++++++++--- internal/clierr/clierr_test.go | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/internal/clierr/clierr.go b/internal/clierr/clierr.go index c5f3052..e8f7fa7 100644 --- a/internal/clierr/clierr.go +++ b/internal/clierr/clierr.go @@ -21,10 +21,13 @@ import ( "errors" "fmt" "io" + "io/fs" "net" + "net/http" "syscall" "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient/agentcard" ) // Code is a symbolic error identifier. @@ -111,11 +114,26 @@ func CardResolution(err error) *Error { Code: CodeCardInvalid, Message: msg, Exit: 4, - Hint: "check the --agent-card reference (host, full card URL, or local file path)", + Hint: cardInvalidHint(err), err: err, } } +// cardInvalidHint picks remediation for a card that was reached but not usable, +// based on the HTTP status of the card request when there is one. +func cardInvalidHint(err error) string { + var status *agentcard.ErrStatusNotOK + if errors.As(err, &status) { + switch { + case status.StatusCode == http.StatusUnauthorized || status.StatusCode == http.StatusForbidden: + return "pass credentials with --auth, --svc-param or --credential-helper" + case status.StatusCode >= http.StatusInternalServerError: + return "the agent failed to serve its card, check its logs or retry" + } + } + return "check the --agent-card reference (host, full card URL, or local file path)" +} + // MarshalJSON implements [json.Marshaler]. func (e *Error) MarshalJSON() ([]byte, error) { type body struct { @@ -186,10 +204,12 @@ func Classify(err error) *Error { // isIOError reports whether err is a transport-level failure — a network error, // a broken stream, or a reset/closed connection — identified by type and -// sentinel rather than by matching message text. +// sentinel rather than by matching message text. A local file error is not one, +// even though the syscall.Errno it wraps satisfies net.Error. func isIOError(err error) bool { var netErr net.Error - if errors.As(err, &netErr) { + var pathErr *fs.PathError + if errors.As(err, &netErr) && !errors.As(err, &pathErr) { return true } return errors.Is(err, io.ErrUnexpectedEOF) || diff --git a/internal/clierr/clierr_test.go b/internal/clierr/clierr_test.go index aee4cac..eb248ed 100644 --- a/internal/clierr/clierr_test.go +++ b/internal/clierr/clierr_test.go @@ -18,11 +18,13 @@ import ( "context" "errors" "fmt" + "io/fs" "net" "syscall" "testing" "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient/agentcard" ) func TestClassify(t *testing.T) { @@ -85,6 +87,18 @@ func TestClassify(t *testing.T) { wantCode: CodeIO, wantExit: 3, }, + { + name: "local file error is not io, exit 1", + err: fmt.Errorf("failed to save file parts: %w", &fs.PathError{Op: "open", Path: "out/x.txt", Err: syscall.EACCES}), + wantCode: CodeInternal, + wantExit: 1, + }, + { + name: "broken pipe on a file is still io", + err: fmt.Errorf("writing to plugin: %w", &fs.PathError{Op: "write", Path: "|1", Err: syscall.EPIPE}), + wantCode: CodeIO, + wantExit: 3, + }, { name: "unclassified error is internal, exit 1", err: errors.New("something odd happened"), @@ -158,6 +172,27 @@ func TestCardResolution(t *testing.T) { wantExit: 4, wantHint: "check the --agent-card reference (host, full card URL, or local file path)", }, + { + name: "unauthorized card request asks for credentials", + err: &agentcard.ErrStatusNotOK{StatusCode: 401, Status: "401 Unauthorized"}, + wantCode: CodeCardInvalid, + wantExit: 4, + wantHint: "pass credentials with --auth, --svc-param or --credential-helper", + }, + { + name: "forbidden card request asks for credentials", + err: &agentcard.ErrStatusNotOK{StatusCode: 403, Status: "403 Forbidden"}, + wantCode: CodeCardInvalid, + wantExit: 4, + wantHint: "pass credentials with --auth, --svc-param or --credential-helper", + }, + { + name: "server error on card request points at the agent", + err: &agentcard.ErrStatusNotOK{StatusCode: 500, Status: "500 Internal Server Error"}, + wantCode: CodeCardInvalid, + wantExit: 4, + wantHint: "the agent failed to serve its card, check its logs or retry", + }, { name: "deadline exceeded is a timeout", err: context.DeadlineExceeded,