From de6963c2cce164c836c12010d21903301b538e1a Mon Sep 17 00:00:00 2001 From: Eden Reich Date: Tue, 6 Oct 2026 17:19:01 +0200 Subject: [PATCH] refactor(a2a): match ADK errors by type instead of parsing their text ADK v0.34.0 returns *types.HTTPStatusError for an unexpected status and a *types.JSONRPCError that unwraps to a sentinel per spec code. The 401/403 regex, the guardrail envelope regex and the (code: -32601) substring match in auth.go become errors.As and errors.Is. A guardrail refusal is a 403 with a JSON-RPC body, not a JSON-RPC error object, so its message is still read from the status error's body. The two submit_task tests that faked the ADK error as text now return the typed error the client returns. --- go.mod | 2 +- go.sum | 4 +-- internal/protocols/a2a/infrastructure/auth.go | 36 ++++++------------- .../a2a/submit_task_error_handling_test.go | 5 +-- 4 files changed, 16 insertions(+), 31 deletions(-) diff --git a/go.mod b/go.mod index 90708e021..5402471e7 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/go-vgo/robotgo v1.1.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 - github.com/inference-gateway/adk v0.33.0 + github.com/inference-gateway/adk v0.34.0 github.com/inference-gateway/sdk v1.41.0 github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 github.com/lib/pq v1.12.3 diff --git a/go.sum b/go.sum index da01900e6..d7102b6bb 100644 --- a/go.sum +++ b/go.sum @@ -142,8 +142,8 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/inference-gateway/adk v0.33.0 h1:uRJArPCI2jYEVsrOwx/y1ohZwpIAtCRHfLNpoRmQw9s= -github.com/inference-gateway/adk v0.33.0/go.mod h1:XhX6mteE8YTMwsNIF78BMIYwRZSv6AjFCTDVPbcWzGI= +github.com/inference-gateway/adk v0.34.0 h1:me6aGRtkQ8m45K+ayzV1OyL94SkYhOrIQ8SS28+FaUI= +github.com/inference-gateway/adk v0.34.0/go.mod h1:XhX6mteE8YTMwsNIF78BMIYwRZSv6AjFCTDVPbcWzGI= github.com/inference-gateway/sdk v1.41.0 h1:8ZYQti0fFIN2MXspPNiMbrP8n2QBeFJPc/56rTtGiJk= github.com/inference-gateway/sdk v1.41.0/go.mod h1:niH2yIl/reMfdMwH/jtE4P2G3FJh63nC4Ji/jLZ0qaA= github.com/inference-gateway/tokenless v0.9.0 h1:10KQ12GGtaBOCFjiYgGeCdl892vxP+d+PRD5F+YFOhM= diff --git a/internal/protocols/a2a/infrastructure/auth.go b/internal/protocols/a2a/infrastructure/auth.go index 96635faa1..c84afd74b 100644 --- a/internal/protocols/a2a/infrastructure/auth.go +++ b/internal/protocols/a2a/infrastructure/auth.go @@ -9,7 +9,6 @@ import ( "net/http" "net/url" "os" - "regexp" "slices" "strings" "sync" @@ -35,11 +34,6 @@ func (e *AuthError) Error() string { return fmt.Sprintf("Authentication failed for A2A agent %q: %s", e.Agent, e.Reason) } -// rejectedStatus matches the ADK client's error for a 401 or 403 response. -// ponytail: ADK has no typed HTTP error, so the status is read from the -// message. Switch to errors.As once ADK exposes the status code. -var rejectedStatus = regexp.MustCompile(`unexpected status code[^:]*: (401|403)\b`) - // AuthFailure describes err for the model when it is an authentication // failure: credentials that could not be obtained, or a 401 or 403 from the // agent. The message names the agent and leaves the response body out. @@ -51,13 +45,13 @@ func AuthFailure(agentURL string, err error) (string, bool) { if errors.As(err, &authErr) { return authErr.Error(), true } - match := rejectedStatus.FindStringSubmatch(err.Error()) - if match == nil { + var status *adk.HTTPStatusError + if !errors.As(err, &status) || (status.StatusCode != http.StatusUnauthorized && status.StatusCode != http.StatusForbidden) { return "", false } rejected := &AuthError{ Agent: agentDisplayName(agentURL), - Reason: fmt.Sprintf("the agent rejected the request with status %s, check %s", match[1], credentialSetting(agentURL)), + Reason: fmt.Sprintf("the agent rejected the request with status %d, check %s", status.StatusCode, credentialSetting(agentURL)), } return rejected.Error(), true } @@ -70,18 +64,12 @@ func credentialSetting(agentURL string) string { return "its auth settings in agents.yaml" } -// refusedByPolicy matches the ADK client's error for a 403 response and captures its body. -var refusedByPolicy = regexp.MustCompile(`unexpected status code[^:]*: 403, body: (\{.*\})`) - // PolicyRefusal describes err for the model when the gateway's guardrails // refused the request: a 403 whose body is a JSON-RPC error. The message // carries the policy's own words. func PolicyRefusal(err error) (string, bool) { - if err == nil { - return "", false - } - match := refusedByPolicy.FindStringSubmatch(err.Error()) - if match == nil { + var status *adk.HTTPStatusError + if !errors.As(err, &status) || status.StatusCode != http.StatusForbidden { return "", false } var envelope struct { @@ -89,20 +77,16 @@ func PolicyRefusal(err error) (string, bool) { Message string `json:"message"` } `json:"error"` } - if json.Unmarshal([]byte(match[1]), &envelope) != nil || envelope.Error.Message == "" { + if json.Unmarshal([]byte(status.Body), &envelope) != nil || envelope.Error.Message == "" { return "", false } return "The request was refused by a guardrail policy: " + envelope.Error.Message, true } -// methodNotFound is how the ADK client ends the error for a JSON-RPC -// "method not found" answer. -const methodNotFound = "(code: -32601)" - -// MethodUnsupported describes err for the model when the agent does not +// methodUnsupported describes err for the model when the agent does not // implement the A2A method that was called, which no retry can change. -func MethodUnsupported(agentURL string, err error) (string, bool) { - if err == nil || !strings.Contains(err.Error(), methodNotFound) { +func methodUnsupported(agentURL string, err error) (string, bool) { + if !errors.Is(err, adk.ErrMethodNotFound) { return "", false } return fmt.Sprintf("A2A agent %q does not implement the A2A method that was called, so it likely speaks another A2A protocol version than this CLI (v1.0). Retrying will not help.", agentDisplayName(agentURL)), true @@ -115,7 +99,7 @@ func Rejection(agentURL string, err error) (string, bool) { if message, ok := PolicyRefusal(err); ok { return message, true } - if message, ok := MethodUnsupported(agentURL, err); ok { + if message, ok := methodUnsupported(agentURL, err); ok { return message, true } return AuthFailure(agentURL, err) diff --git a/internal/protocols/a2a/submit_task_error_handling_test.go b/internal/protocols/a2a/submit_task_error_handling_test.go index 41cca5959..db1fe984b 100644 --- a/internal/protocols/a2a/submit_task_error_handling_test.go +++ b/internal/protocols/a2a/submit_task_error_handling_test.go @@ -3,6 +3,7 @@ package a2a import ( "context" "errors" + "net/http" "testing" assert "github.com/stretchr/testify/assert" @@ -421,7 +422,7 @@ func TestSubmitTaskTool_RejectedSubmissionIsAnAuthFailure(t *testing.T) { t.Chdir(t.TempDir()) t.Setenv("HOME", t.TempDir()) client := &adkmocks.FakeA2AClient{} - client.SendTaskReturns(nil, errors.New(`unexpected status code: 401, body: {"error":"invalid token"}`)) + client.SendTaskReturns(nil, &adk.HTTPStatusError{StatusCode: http.StatusUnauthorized, Body: `{"error":"invalid token"}`}) tool := NewSubmitTaskToolWithClient(authTestConfig(), nil, nil, nil, client) result, err := tool.Execute(t.Context(), map[string]any{ @@ -441,7 +442,7 @@ func TestSubmitTaskTool_PollingStopsOnAuthFailure(t *testing.T) { t.Chdir(t.TempDir()) t.Setenv("HOME", t.TempDir()) client := &adkmocks.FakeA2AClient{} - client.GetTaskReturns(nil, errors.New("unexpected status code: 403, body: forbidden")) + client.GetTaskReturns(nil, &adk.HTTPStatusError{StatusCode: http.StatusForbidden, Body: "forbidden"}) tool := NewSubmitTaskToolWithClient(authTestConfig(), nil, nil, nil, client) state := &a2adomain.TaskPollingState{TaskID: "t1", ContextID: "ctx1", AgentURL: "https://research.example.com"}