From d34184eb3d449e93dbf3616656d0c9628a1fbe69 Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Tue, 28 Jul 2026 09:12:19 -0700 Subject: [PATCH 01/14] refactor(go): add core/status and reimplement core's error surface over it v1 split error handling across core: GenkitError (internal, stack-capturing) and UserFacingError (safe to return over HTTP) shared no interface, so handling code checked for both with separate errors.As calls. Neither wrapped a cause, so callers could only distinguish failure modes by matching on message text. Add core/status, which collapses the two into a single status.Error with a Public field gating whether its message is safe to return to clients, and adds Sentinel classification so callers branch with errors.Is. A base sentinel exists per status; packages derive domain sentinels with Subtype, which inherits the parent's status and still matches it. Everything in core becomes an alias or a thin deprecated wrapper over it. StatusName aliases status.Name and GenkitError aliases status.Error, so the two are the same type: an errors.As for either finds errors raised by any part of Genkit, and []core.StatusName config (retry, fallback) is interchangeable with []status.Name. UserFacingError keeps its exact shape, fields, and Error() text, and gains an Unwrap returning its base sentinel so status.Of reports its status instead of defaulting to INTERNAL. status.Error keeps a single-cause Unwrap() error rather than the Unwrap() []error form, matching sentinels through an Is method instead. The stdlib errors.Unwrap returns nil for a multi-cause unwrapper, which would silently truncate hand-rolled chain walks in logging and telemetry middleware. The deprecated NewError shim preserves v1 behaviour exactly: it still wraps the last error argument implicitly and still records Details["stack"], so anything built through it behaves as before while internals move to the lean path. RuntimeError codegen moves from core.genkitErrorWire to status.errorWire, and AgentOutput.Error and SessionSnapshot.Error are regenerated as *status.Error. Both are the same type as *core.GenkitError under the alias, so the exp surface is unchanged. Also fixes five call sites that passed a non-constant string as a format argument to NewError, including three that passed err.Error() directly, where a % in the message produced a mangled error. The one v1 symbol that cannot survive the alias is the GenkitError method ToReflectionError; the package-level ToReflectionError function still works. --- go/ai/exp/gen.go | 6 +- go/ai/generate.go | 4 +- go/core/error.go | 246 +++++++++++---------------- go/core/error_test.go | 6 +- go/core/schemas.config | 27 ++- go/core/status/doc.go | 86 ++++++++++ go/core/status/error.go | 328 ++++++++++++++++++++++++++++++++++++ go/core/{ => status}/gen.go | 17 +- go/core/status/sentinel.go | 145 ++++++++++++++++ go/core/status/status.go | 125 ++++++++++++++ go/core/status_types.go | 126 +++++--------- go/genkit/reflection.go | 6 +- 12 files changed, 858 insertions(+), 264 deletions(-) create mode 100644 go/core/status/doc.go create mode 100644 go/core/status/error.go rename go/core/{ => status}/gen.go (68%) create mode 100644 go/core/status/sentinel.go create mode 100644 go/core/status/status.go diff --git a/go/ai/exp/gen.go b/go/ai/exp/gen.go index 85a7c27d40..99cffaa19b 100644 --- a/go/ai/exp/gen.go +++ b/go/ai/exp/gen.go @@ -20,7 +20,7 @@ package exp import ( "github.com/firebase/genkit/go/ai" - "github.com/firebase/genkit/go/core" + "github.com/firebase/genkit/go/core/status" "time" ) @@ -171,7 +171,7 @@ type AgentOutput[State any] struct { // failure (FinishReason is [AgentFinishReasonFailed]). Its Status preserves // the original error category (e.g. INVALID_ARGUMENT, FAILED_PRECONDITION, // INTERNAL) so callers can still branch on it. Nil otherwise. - Error *core.GenkitError `json:"error,omitempty"` + Error *status.Error `json:"error,omitempty"` // FinishReason is why the invocation finished. It is // [AgentFinishReasonDetached] when the client detached and the work continues // in the background, or [AgentFinishReasonFailed] when the invocation ended @@ -324,7 +324,7 @@ type SessionSnapshot[State any] struct { CreatedAt time.Time `json:"createdAt"` // Error is the structured failure information for a snapshot in // [SnapshotStatusFailed]. Nil otherwise. - Error *core.GenkitError `json:"error,omitempty"` + Error *status.Error `json:"error,omitempty"` // FinishReason is the semantic reason the turn or invocation captured here // ended (e.g. [AgentFinishReasonStop], [AgentFinishReasonInterrupted], // [AgentFinishReasonFailed], [AgentFinishReasonAborted]). It complements diff --git a/go/ai/generate.go b/go/ai/generate.go index 8db90fbfea..170058f0cc 100644 --- a/go/ai/generate.go +++ b/go/ai/generate.go @@ -1610,7 +1610,7 @@ func handleResumedToolRequest(ctx context.Context, r api.Registry, genOpts *Gene if p.ToolRequest.Ref != "" { refStr = "#" + p.ToolRequest.Ref } - return nil, core.NewError(core.INVALID_ARGUMENT, fmt.Sprintf("unresolved tool request %q was not handled by the Resume argument; you must supply Respond or Restart directives, or ensure there is pending output from a previous tool call", refStr)) + return nil, core.NewError(core.INVALID_ARGUMENT, "unresolved tool request %q was not handled by the Resume argument; you must supply Respond or Restart directives, or ensure there is pending output from a previous tool call", refStr) } // handleResumeOption amends message history to handle `resume` arguments. @@ -1707,7 +1707,7 @@ func handleResumeOption(ctx context.Context, r api.Registry, genOpts *GenerateAc } if len(toolResps) != toolReqCount { - return nil, core.NewError(core.FAILED_PRECONDITION, fmt.Sprintf("handleResumeOption: Expected %d tool responses but resolved to %d.", toolReqCount, len(toolResps))) + return nil, core.NewError(core.FAILED_PRECONDITION, "handleResumeOption: Expected %d tool responses but resolved to %d.", toolReqCount, len(toolResps)) } toolMessage := &Message{ diff --git a/go/core/error.go b/go/core/error.go index e38ac09992..3f7ed1826f 100644 --- a/go/core/error.go +++ b/go/core/error.go @@ -15,25 +15,36 @@ // SPDX-License-Identifier: Apache-2.0 // Package core provides base error types and utilities for Genkit. +// +// The error surface in this file is deprecated in favour of +// [github.com/firebase/genkit/go/core/status], which unifies the two error +// types below into one and adds sentinel classification so callers can branch +// with errors.Is instead of matching on message text. Everything here is an +// alias or a thin wrapper over that package: [GenkitError] and [status.Error] +// are the same type, so an errors.As for either finds errors raised by any part +// of Genkit, old or new. package core import ( - "encoding/json" - "errors" "fmt" - "maps" "runtime/debug" - "github.com/firebase/genkit/go/internal/base" - "github.com/invopop/jsonschema" + "github.com/firebase/genkit/go/core/status" ) +// ReflectionErrorDetails is the details field of a [ReflectionError]. +// +// Deprecated: the reflection API's error envelope is internal to that +// boundary and will stop being part of this package's surface. type ReflectionErrorDetails struct { Stack *string `json:"stack,omitempty"` // Use pointer for optional TraceID *string `json:"traceId,omitempty"` } // ReflectionError is the wire format for HTTP errors for Reflection API responses. +// +// Deprecated: the reflection API's error envelope is internal to that +// boundary and will stop being part of this package's surface. type ReflectionError struct { Details *ReflectionErrorDetails `json:"details,omitempty"` Message string `json:"message"` @@ -42,87 +53,30 @@ type ReflectionError struct { // GenkitError is the base error type for Genkit errors. // -// On the wire, GenkitError marshals to and from the canonical Genkit -// error shape {status, message, details}, which mirrors the -// `RuntimeError` definition in the JSON schema. Fields that exist for -// in-process use (HTTPCode, Source, the wrapped error) are not -// serialized. -type GenkitError struct { - Message string // Wire field "message". - Status StatusName // Wire field "status". - HTTPCode int // Derived from Status; not serialized. - Details map[string]any // Wire field "details" (omitted when empty). - Source *string // In-process annotation; not serialized. - originalError error // The wrapped error, if any. -} - -// MarshalJSON encodes a GenkitError in the canonical Genkit error wire -// format: {status, message, details}. The wire shape ([genkitErrorWire]) -// is generated from the shared JSON schema's RuntimeError definition. -// -// The stack trace [NewError] records under Details["stack"] is in-process -// diagnostics like HTTPCode and Source, not wire data: marshaling omits it -// so errors embedded in values (e.g. a failed agent invocation's output) -// do not leak process internals to clients. Consumers that want the stack -// (the reflection API's error envelope) read the error value directly. -func (e *GenkitError) MarshalJSON() ([]byte, error) { - details := e.Details - if _, ok := details["stack"]; ok { - details = maps.Clone(details) - delete(details, "stack") - if len(details) == 0 { - details = nil - } - } - return json.Marshal(genkitErrorWire{ - Status: e.Status, - Message: e.Message, - Details: details, - }) -} - -// JSONSchema describes the error's wire format for schema inference. -// Without it, inference would reflect over the struct fields, requiring -// capitalized in-process fields (Message, HTTPCode, Source) that -// MarshalJSON never emits, so values embedding a GenkitError would fail -// validation against their own inferred schema. -func (GenkitError) JSONSchema() *jsonschema.Schema { - return base.InferJSONSchema(genkitErrorWire{}) -} - -// UnmarshalJSON decodes a GenkitError from the canonical wire format -// and re-derives HTTPCode from Status. -func (e *GenkitError) UnmarshalJSON(data []byte) error { - var w genkitErrorWire - if err := json.Unmarshal(data, &w); err != nil { - return err - } - e.Status = w.Status - e.Message = w.Message - e.Details = w.Details - e.HTTPCode = HTTPStatusCode(w.Status) - return nil -} +// Deprecated: use [status.Error]. This is an alias for it, so the two are the +// same type: an errors.As for a *GenkitError still matches every error Genkit +// raises, and a *status.Error can be used anywhere a *GenkitError is expected. +// Note that [status.Error] classifies failures with a sentinel, so prefer +// errors.Is against the sentinels in core/status (and the domain sentinels in +// ai, exp, and friends) over comparing the Status field. +type GenkitError = status.Error // AsGenkitError returns err as a *GenkitError, wrapping it in a fresh // one with status INTERNAL if it isn't one already. Returns nil for a // nil input. -func AsGenkitError(err error) *GenkitError { - if err == nil { - return nil - } - var ge *GenkitError - if errors.As(err, &ge) { - return ge - } - return &GenkitError{ - Status: INTERNAL, - Message: err.Error(), - HTTPCode: HTTPStatusCode(INTERNAL), - } -} +// +// Deprecated: use [status.Convert], or [status.Of] when you only need the +// status. Note that Convert derives the status from the error (mapping a +// cancelled context to CANCELLED, for instance) rather than always using +// INTERNAL. +func AsGenkitError(err error) *GenkitError { return status.Convert(err) } // UserFacingError is the base error type for user facing errors. +// +// Deprecated: use [status.PublicErrorf], which produces a [status.Error] with +// Public set. Unlike this type, the result carries a sentinel and its status +// reaches HTTP transports, so a public INVALID_ARGUMENT returns 400 rather than +// falling through to 500. type UserFacingError struct { Message string `json:"message"` // Exclude from default JSON if embedded elsewhere Status StatusName `json:"status"` @@ -133,6 +87,8 @@ type UserFacingError struct { // is safe to return the message in a request. Other kinds of errors will // result in a generic 500 message to avoid the possibility of internal // exceptions being leaked to attackers. +// +// Deprecated: use [status.PublicErrorf]. func NewPublicError(status StatusName, message string, details map[string]any) *UserFacingError { return &UserFacingError{ Status: status, @@ -146,102 +102,100 @@ func (e *UserFacingError) Error() string { return fmt.Sprintf("%s: %s", e.Status, e.Message) } +// Unwrap returns the base sentinel for the error's status, so a UserFacingError +// classifies the same way a [status.Error] does: [status.Of] reports its Status +// rather than defaulting to INTERNAL, and errors.Is matches the corresponding +// base sentinel. +func (e *UserFacingError) Unwrap() error { return status.Base(e.Status) } + +// PublicMessage reports the error's message as safe to return to clients. +// Transports call this to decide what reaches a client; implementing it keeps +// a UserFacingError public now that publicness is a property of the error +// rather than of its type. +func (e *UserFacingError) PublicMessage() (string, bool) { return e.Message, true } + // NewError creates a new GenkitError with a stack trace. +// +// Deprecated: use [status.Errorf] with a sentinel, which classifies the failure +// so callers can match it with errors.Is: +// +// status.Errorf(status.ErrNotFound, "model %q not found", name) +// +// Record a cause with %w rather than relying on the implicit wrapping of the +// last error argument that this function performs. func NewError(status StatusName, message string, args ...any) *GenkitError { - msg := message - - ge := &GenkitError{ - Status: status, - Message: fmt.Sprintf(msg, args...), - } + ge := newErrorSkip(status, message, args...) + ge.Details = map[string]any{"stack": string(debug.Stack())} + return ge +} - // scan args for the last error to wrap it (Iterate backwards) +// newErrorSkip builds the error NewError returns, minus the stack detail, so +// the stack NewError records starts at its caller. +func newErrorSkip(name StatusName, message string, args ...any) *GenkitError { + ge := status.Errorf(status.Base(name), message, args...) + // v1 scanned args for the last error and wrapped it implicitly, with no %w + // in the format. Preserve that so errors.Is and errors.As still reach it. for i := len(args) - 1; i >= 0; i-- { if err, ok := args[i].(error); ok { - ge.originalError = err + ge.WithCause(err) break } } - - errStack := string(debug.Stack()) - if errStack != "" { - ge.Details = make(map[string]any) - ge.Details["stack"] = errStack - } return ge } -// Error implements the standard error interface. -func (e *GenkitError) Error() string { - return e.Message -} - -// Unwrap implements the standard error unwrapping interface. -// This allows errors.Is and errors.As to work with GenkitError. -func (e *GenkitError) Unwrap() error { - return e.originalError -} - // SchemaValidationError is an error returned when action input fails parsing // or schema validation, e.g. when a model produces malformed tool arguments. +// +// Deprecated: match [status.ErrInvalidInput] with errors.Is instead. type SchemaValidationError struct { *GenkitError } // Unwrap returns the underlying GenkitError so that errors.Is and errors.As // continue to match *GenkitError anywhere a SchemaValidationError is returned. -func (e *SchemaValidationError) Unwrap() error { - return e.GenkitError -} +func (e *SchemaValidationError) Unwrap() error { return e.GenkitError } // NewSchemaValidationError creates a SchemaValidationError for the given action key and validation error. +// +// Deprecated: use status.Errorf with [status.ErrInvalidInput]. func NewSchemaValidationError(actionKey string, err error) *SchemaValidationError { return &SchemaValidationError{ - GenkitError: NewError(INVALID_ARGUMENT, "invalid input to action %q: %v", actionKey, err), + GenkitError: status.Errorf(status.ErrInvalidInput, "invalid input to action %q: %w", actionKey, err), } } // ToReflectionError returns a JSON-serializable representation for reflection API responses. -func (e *GenkitError) ToReflectionError() ReflectionError { - var errDetails *ReflectionErrorDetails - if e.Details != nil { - stackVal, stackOk := e.Details["stack"].(string) - traceVal, traceOk := e.Details["traceId"].(string) - - if stackOk || traceOk { - errDetails = &ReflectionErrorDetails{} - if stackOk { - errDetails.Stack = &stackVal - } - if traceOk { - errDetails.TraceID = &traceVal - } - } - } - return ReflectionError{ - Details: errDetails, - Code: HTTPStatusCode(e.Status), - Message: e.Message, - } -} - -// ToReflectionError gets the JSON representation for reflection API Error responses. +// +// Deprecated: the reflection API's error envelope is internal to that boundary +// and will stop being part of this package's surface. func ToReflectionError(err error) ReflectionError { - if ge, ok := err.(*GenkitError); ok { - return ge.ToReflectionError() + e := status.Convert(err) + if e == nil { + return ReflectionError{Code: status.Internal.HTTPCode(), Details: &ReflectionErrorDetails{}} } - - // Error could be a markedError, which is a wrapper on GenkitError. - // Casting markedError directly fails because it is indeed a different type. - // errors.As() unwraps markedError and finds the GenkitError underneath. - var ge *GenkitError - if errors.As(err, &ge) { - return ge.ToReflectionError() + // v1 recorded the stack under Details["stack"]; status.Errorf keeps it off + // the details map and formats it on demand. Read both so errors from either + // constructor still carry a stack to the Dev UI. + stack, stackOK := e.Details["stack"].(string) + if !stackOK { + stack = e.Stack() + stackOK = stack != "" + } + traceID, traceOK := e.Details["traceId"].(string) + var details *ReflectionErrorDetails + if stackOK || traceOK { + details = &ReflectionErrorDetails{} + if stackOK { + details.Stack = &stack + } + if traceOK { + details.TraceID = &traceID + } } - return ReflectionError{ - Message: err.Error(), - Code: HTTPStatusCode(INTERNAL), - Details: &ReflectionErrorDetails{}, + Details: details, + Code: e.Status.HTTPCode(), + Message: e.Message, } } diff --git a/go/core/error_test.go b/go/core/error_test.go index c2aaac13cd..d365ca2ef3 100644 --- a/go/core/error_test.go +++ b/go/core/error_test.go @@ -116,7 +116,7 @@ func TestGenkitErrorError(t *testing.T) { func TestGenkitErrorToReflectionError(t *testing.T) { t.Run("converts error with stack", func(t *testing.T) { ge := NewError(NOT_FOUND, "resource not found") - re := ge.ToReflectionError() + re := ToReflectionError(ge) if re.Message != "resource not found" { t.Errorf("Message = %q, want %q", re.Message, "resource not found") @@ -137,7 +137,7 @@ func TestGenkitErrorToReflectionError(t *testing.T) { "traceId": "trace-123", }, } - re := ge.ToReflectionError() + re := ToReflectionError(ge) if re.Details == nil || re.Details.TraceID == nil { t.Fatal("expected traceId in details") @@ -153,7 +153,7 @@ func TestGenkitErrorToReflectionError(t *testing.T) { Message: "success", Details: nil, } - re := ge.ToReflectionError() + re := ToReflectionError(ge) if re.Message != "success" { t.Errorf("Message = %q, want %q", re.Message, "success") diff --git a/go/core/schemas.config b/go/core/schemas.config index 6b1e0de9b6..f1aa41e44d 100644 --- a/go/core/schemas.config +++ b/go/core/schemas.config @@ -1171,7 +1171,7 @@ GenkitErrorDataGenkitErrorDetails omit ai/exp name exp exp import time exp import github.com/firebase/genkit/go/ai -exp import github.com/firebase/genkit/go/core +exp import github.com/firebase/genkit/go/core/status # ---------------------------------------------------------------------------- # Artifact @@ -1376,7 +1376,7 @@ in failure (see [AgentOutput.Error]); otherwise it is the last turn's reason (or the value a custom agent set on its [AgentResult]). . -AgentOutput.error type *core.GenkitError +AgentOutput.error type *status.Error AgentOutput.error doc Error is the structured failure information when the invocation ended in failure (FinishReason is [AgentFinishReasonFailed]). Its Status preserves @@ -1389,24 +1389,23 @@ INTERNAL) so callers can still branch on it. Nil otherwise. # ---------------------------------------------------------------------------- # RuntimeError is the canonical Genkit error wire shape. It is generated -# into core as the unexported struct backing GenkitError's MarshalJSON, +# into core/status as the unexported struct backing Error's MarshalJSON, # UnmarshalJSON, and JSONSchema, so the wire format and the advertised # schema are single-sourced from the Zod schema. The fields that carry # it (AgentOutput.error, SessionSnapshot.error) are overridden to -# *core.GenkitError, which delegates to this type. -RuntimeError pkg core -RuntimeError name genkitErrorWire +# *status.Error, which delegates to this type. +RuntimeError pkg core/status +RuntimeError name errorWire RuntimeError doc -genkitErrorWire is the on-the-wire shape of a [GenkitError]: the -canonical Genkit error format ({status, message, details}) shared -across runtimes (RuntimeError in the JSON schema). GenkitError's -MarshalJSON, UnmarshalJSON, and JSONSchema delegate to it; fields that -exist for in-process use (HTTPCode, Source, the wrapped error) are not -part of it. +errorWire is the on-the-wire shape of an [Error]: the canonical Genkit +error format ({status, message, details}) shared across runtimes +(RuntimeError in the JSON schema). Error's MarshalJSON, UnmarshalJSON, +and JSONSchema delegate to it; fields that exist only in-process +(Public, the sentinel, the cause, the stack) are not part of it. . -RuntimeError.status type StatusName +RuntimeError.status type Name RuntimeError.status doc Status is the canonical status name (e.g. INTERNAL, FAILED_PRECONDITION). . @@ -1650,7 +1649,7 @@ background task can report how it ended without re-deriving it from the messages. . -SessionSnapshot.error type *core.GenkitError +SessionSnapshot.error type *status.Error SessionSnapshot.error doc Error is the structured failure information for a snapshot in [SnapshotStatusFailed]. Nil otherwise. diff --git a/go/core/status/doc.go b/go/core/status/doc.go new file mode 100644 index 0000000000..5bf97acc50 --- /dev/null +++ b/go/core/status/doc.go @@ -0,0 +1,86 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +/* +Package status defines Genkit's canonical status codes and the error type that +carries them. + +# Classifying an error + +[Error] is the only error type Genkit defines. It pairs a message with a +canonical status [Name] and the [Sentinel] that classified it. Build one with +[Errorf], whose first argument is the sentinel: + + return status.Errorf(status.ErrNotFound, "model %q not found", name) + +Callers branch with errors.Is rather than by matching message text: + + if errors.Is(err, ai.ErrMaxTurnsExceeded) { ... } // specific + if errors.Is(err, status.ErrAborted) { ... } // broad + +A base sentinel exists for every status ([ErrInvalidArgument], [ErrNotFound], +[ErrAborted], ...). Packages declare domain sentinels from them with +[Sentinel.Subtype], which inherits the status and still matches the parent: + + var ErrMaxTurnsExceeded = status.ErrAborted.Subtype("max turns exceeded") + +# Adding context versus reclassifying + +Classify at the point where the failure mode is actually known, which is +usually deep in the call stack: the code that looked up the model is the only +code that knows a missing model is NotFound, not the HTTP handler ten frames +up. Everything above it should add context without touching the classification: + + return fmt.Errorf("agent %q: %w", name, err) // status and sentinel survive + +Reclassify only at a boundary where the meaning genuinely changes, and do it +deliberately with [Errorf]. A tool's own NotFound, for instance, is not a +NotFound for the request that invoked the tool; it is an Internal failure of +that tool: + + return status.Errorf(status.ErrInternal, "tool %q failed: %w", name, err) + +When several [Error] values are in one chain, errors.As finds the outermost, so +the last deliberate reclassification is the one transports report. [Of] follows +the same rule. + +The pattern to avoid is restating the status on every frame as an error bubbles +up. Wrapping with %v is the usual culprit: it flattens the cause into a string, +so the sentinel, the status, and everything else in the chain are lost. + +# Messages + +Keep messages short and specific, and name the thing that failed: the action +key, model name, tool name, session or snapshot ID. Prefer + + status.Errorf(status.ErrNotFound, "tool %q not found", name) + +over a generic "tool not found", and do not prefix messages with the name of +the unexported function that produced them. Genkit composes the surrounding +context by wrapping, so a message only needs to describe its own layer. + +# Reaching clients + +[Errorf] produces an error whose message stays server-side. [PublicErrorf] +marks a message as safe to return over the wire: + + return status.PublicErrorf(status.ErrInvalidArgument, "invalid %q parameter", param) + +Transports call [PublicMessage], which returns the message only when the +outermost [Error] is public and a generic string derived from the status +otherwise. The status code itself is always reported. +*/ +package status diff --git a/go/core/status/error.go b/go/core/status/error.go new file mode 100644 index 0000000000..54105f9d97 --- /dev/null +++ b/go/core/status/error.go @@ -0,0 +1,328 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package status + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "runtime" + "strings" + + "github.com/firebase/genkit/go/internal/base" + "github.com/invopop/jsonschema" +) + +// Error is Genkit's error type. It carries a canonical [Name] status, the +// [Sentinel] that classified it, and any wrapped cause. +// +// On the wire an Error marshals to the canonical Genkit error shape +// ({status, message, details}), which mirrors the RuntimeError definition in +// the shared JSON schema. Fields that exist only in-process (Public, the +// sentinel, the cause, the stack) are not serialized. +// +// Construct one with [Errorf] or [PublicErrorf]. To add context to an existing +// error without reclassifying it, use fmt.Errorf with %w instead. +type Error struct { + // Status is the canonical status name for this failure. Wire field "status". + Status Name + // Message describes the failure. Wire field "message". + Message string + // Public reports whether Message is safe to return to a client. Transports + // replace the message of a non-public error with a generic one so internal + // details do not leak. Not serialized. + Public bool + // Details is optional structured information about the failure. + // Wire field "details" (omitted when empty). + Details map[string]any + + // HTTPCode is the HTTP status for Status, recorded at construction. + // + // Deprecated: use Status.HTTPCode(), which is correct for every Error + // including ones built as a struct literal. This field exists so + // core.GenkitError can alias Error, and will be removed with it. + HTTPCode int + + // Source names the component that raised the error. + // + // Deprecated: never populated. It exists so core.GenkitError can alias + // Error, and will be removed with it. + Source *string + + sentinel *Sentinel + // cause is the error recorded via %w, if any. Keeping it a single error + // (rather than an Unwrap() []error holding the sentinel too) means the + // stdlib errors.Unwrap and hand-rolled chain walks still see through an + // Error; sentinel matching goes through [Error.Is] instead. + cause error + stack []uintptr +} + +// Errorf returns an [Error] classified by sentinel, with a message built as by +// fmt.Errorf. Use %w in format to record a cause: the cause stays reachable +// through [errors.Is] and [errors.As] alongside the sentinel. +// +// return status.Errorf(status.ErrNotFound, "model %q not found", name) +// return status.Errorf(ai.ErrToolFailed, "tool %q: %w", tool, err) +// +// A nil sentinel is treated as [ErrInternal]. +func Errorf(sentinel *Sentinel, format string, args ...any) *Error { + return newError(sentinel, false, format, args...) +} + +// PublicErrorf is [Errorf] for a message that is safe to return to clients. +// Transports may surface the message verbatim, so it must not contain internal +// details. Everything else is a generic message and the status code alone. +func PublicErrorf(sentinel *Sentinel, format string, args ...any) *Error { + return newError(sentinel, true, format, args...) +} + +func newError(sentinel *Sentinel, public bool, format string, args ...any) *Error { + if sentinel == nil { + sentinel = ErrInternal + } + formatted := fmt.Errorf(format, args...) + msg := formatted.Error() + if msg == "" { + msg = sentinel.label + } + return &Error{ + Status: sentinel.status, + Message: msg, + Public: public, + HTTPCode: sentinel.status.HTTPCode(), + sentinel: sentinel, + cause: causeOf(formatted), + stack: callers(4), + } +} + +// WithDetails attaches structured details and returns e, for chaining onto a +// constructor. Details are serialized and reach clients, so keep them free of +// internal information unless the error is public. +func (e *Error) WithDetails(details map[string]any) *Error { + e.Details = details + return e +} + +// WithCause records err as e's cause without folding it into the message, and +// returns e. Use it when the cause is worth keeping reachable through +// [errors.Is] and [errors.As] but not worth repeating in the text: +// +// return status.Errorf(ai.ErrToolFailed, "tool %q failed", name).WithCause(err) +// +// Prefer %w in the format string when the cause belongs in the message. A nil +// err, or a second call, is a no-op. +func (e *Error) WithCause(err error) *Error { + if err != nil && e.cause == nil { + e.cause = err + } + return e +} + +// Error implements error. It returns Message alone: the sentinel is a +// classification label, not a message prefix, so callers control the wording. +func (e *Error) Error() string { return e.Message } + +// Unwrap returns the cause recorded via %w or [Error.WithCause], or nil. The +// classifying sentinel is deliberately not part of the unwrap chain, so +// errors.Unwrap and hand-rolled chain walks behave the way they do for any +// fmt.Errorf result; [Error.Is] handles sentinel matching. +func (e *Error) Unwrap() error { return e.cause } + +// Is reports whether e was classified by target or by a sentinel derived from +// it. errors.Is consults this before walking [Error.Unwrap], so both +// granularities match: +// +// errors.Is(err, ai.ErrMaxTurnsExceeded) // the specific sentinel +// errors.Is(err, status.ErrAborted) // the base it derives from +func (e *Error) Is(target error) bool { + return e.sentinel != nil && errors.Is(e.sentinel, target) +} + +// Sentinel returns the sentinel that classified e, or nil if it was decoded +// from the wire rather than constructed in this process. +func (e *Error) Sentinel() *Sentinel { return e.sentinel } + +// Stack returns the call stack captured when e was constructed, formatted like +// a panic trace, or "" for an error decoded from the wire. It is formatted on +// demand: construction only records program counters. +func (e *Error) Stack() string { return formatStack(e.stack) } + +// Of returns the status of err. +// +// It reports the status of the outermost [Error] in the chain, so a boundary +// that deliberately reclassifies with [Errorf] wins over anything beneath it. A +// bare [Sentinel] reports its own status. Context cancellation and deadline +// errors map to Cancelled and DeadlineExceeded. Anything else is Internal: an +// unclassified failure is a failure of ours, not of the caller's request. +// +// Of(nil) is OK. +func Of(err error) Name { + if err == nil { + return OK + } + var e *Error + if errors.As(err, &e) { + return e.Status + } + var s *Sentinel + if errors.As(err, &s) { + return s.status + } + switch { + case errors.Is(err, context.Canceled): + return Cancelled + case errors.Is(err, context.DeadlineExceeded): + return DeadlineExceeded + } + return Internal +} + +// Convert returns err as an [Error], converting it if it is not one already. +// The converted error takes its status from [Of] and is never public. Returns +// nil for a nil err. +// +// Prefer errors.As when you need to know whether err really is an [Error]; this +// is for boundaries that must produce one either way. +func Convert(err error) *Error { + if err == nil { + return nil + } + var e *Error + if errors.As(err, &e) { + return e + } + n := Of(err) + return &Error{Status: n, Message: err.Error(), HTTPCode: n.HTTPCode(), cause: err} +} + +// PublicMessage returns a message for err that is safe to show a client, and +// whether it came from the error itself. When the outermost [Error] is public +// its Message is returned verbatim; otherwise the result is a generic string +// derived from the status, so internal details never reach the client. +// +// Transports should use this instead of err.Error(). Note that the fallback is +// deliberately uninformative: log err separately for diagnosis. +func PublicMessage(err error) (msg string, public bool) { + if err == nil { + return "", false + } + var e *Error + if errors.As(err, &e) && e.Public { + return e.Message, true + } + return genericMessage(Of(err)), false +} + +func genericMessage(n Name) string { + if s, ok := baseSentinels[n]; ok { + return s.label + } + return "internal" +} + +// causeOf returns the error fmt.Errorf recorded via %w, or nil when the format +// had none. A format with several %w verbs yields a wrapper holding all of +// them; that wrapper is returned whole so errors.Is and errors.As still reach +// every branch through it. +func causeOf(err error) error { + switch x := err.(type) { + case interface{ Unwrap() error }: + return x.Unwrap() + case interface{ Unwrap() []error }: + return err + } + return nil +} + +// maxStackDepth bounds the frames recorded per error. Deep enough to reach a +// user's own code from anywhere in the framework. +const maxStackDepth = 64 + +// callers records the stack starting at the frame skip levels above +// runtime.Callers itself (4 == the caller of Errorf/PublicErrorf). +func callers(skip int) []uintptr { + pcs := make([]uintptr, maxStackDepth) + return pcs[:runtime.Callers(skip, pcs)] +} + +func formatStack(pcs []uintptr) string { + if len(pcs) == 0 { + return "" + } + var b strings.Builder + frames := runtime.CallersFrames(pcs) + for { + f, more := frames.Next() + fmt.Fprintf(&b, "%s\n\t%s:%d\n", f.Function, f.File, f.Line) + if !more { + break + } + } + return b.String() +} + +// MarshalJSON encodes e in the canonical Genkit error wire format +// ({status, message, details}). The wire shape ([errorWire]) is generated from +// the shared JSON schema's RuntimeError definition. +// +// A captured stack is in-process diagnostics, not wire data, so errors +// embedded in values (a failed agent invocation's output, say) do not leak +// process internals to clients. Consumers that want the stack read +// [Error.Stack] directly. [Error.Stack] keeps it off Details to begin with; +// a "stack" entry put there by hand (as the deprecated core.NewError does for +// compatibility) is dropped here too. +func (e *Error) MarshalJSON() ([]byte, error) { + details := e.Details + if _, ok := details["stack"]; ok { + details = maps.Clone(details) + delete(details, "stack") + if len(details) == 0 { + details = nil + } + } + return json.Marshal(errorWire{ + Status: e.Status, + Message: e.Message, + Details: details, + }) +} + +// UnmarshalJSON decodes an Error from the canonical wire format. The result +// carries no sentinel, cause, or stack: those do not cross the wire. +func (e *Error) UnmarshalJSON(data []byte) error { + var w errorWire + if err := json.Unmarshal(data, &w); err != nil { + return err + } + e.Status = w.Status + e.Message = w.Message + e.Details = w.Details + e.HTTPCode = w.Status.HTTPCode() + return nil +} + +// JSONSchema describes the error's wire format for schema inference. Without +// it, inference would reflect over the struct fields, requiring in-process +// fields that MarshalJSON never emits, so values embedding an Error would fail +// validation against their own inferred schema. +func (Error) JSONSchema() *jsonschema.Schema { + return base.InferJSONSchema(errorWire{}) +} diff --git a/go/core/gen.go b/go/core/status/gen.go similarity index 68% rename from go/core/gen.go rename to go/core/status/gen.go index d246431e3f..de3447ff78 100644 --- a/go/core/gen.go +++ b/go/core/status/gen.go @@ -16,19 +16,18 @@ // This file was generated by jsonschemagen. DO NOT EDIT. -package core +package status -// genkitErrorWire is the on-the-wire shape of a [GenkitError]: the -// canonical Genkit error format ({status, message, details}) shared -// across runtimes (RuntimeError in the JSON schema). GenkitError's -// MarshalJSON, UnmarshalJSON, and JSONSchema delegate to it; fields that -// exist for in-process use (HTTPCode, Source, the wrapped error) are not -// part of it. -type genkitErrorWire struct { +// errorWire is the on-the-wire shape of an [Error]: the canonical Genkit +// error format ({status, message, details}) shared across runtimes +// (RuntimeError in the JSON schema). Error's MarshalJSON, UnmarshalJSON, +// and JSONSchema delegate to it; fields that exist only in-process +// (Public, the sentinel, the cause, the stack) are not part of it. +type errorWire struct { // Details is optional structured information describing the failure. Details map[string]any `json:"details,omitempty"` // Message is the human-readable error message. Message string `json:"message"` // Status is the canonical status name (e.g. INTERNAL, FAILED_PRECONDITION). - Status StatusName `json:"status,omitempty"` + Status Name `json:"status,omitempty"` } diff --git a/go/core/status/sentinel.go b/go/core/status/sentinel.go new file mode 100644 index 0000000000..4800239102 --- /dev/null +++ b/go/core/status/sentinel.go @@ -0,0 +1,145 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package status + +// Sentinel classifies a failure. It pairs a status name with a short, +// stable label and is the first argument to [Errorf] and [PublicErrorf]. +// +// Sentinels are comparable with [errors.Is], which is how callers branch on a +// failure mode instead of matching on message text: +// +// if errors.Is(err, ai.ErrMaxTurnsExceeded) { ... } +// +// A sentinel created with [Sentinel.Subtype] inherits its parent's status and also +// matches the parent under [errors.Is], so callers can match at whichever +// granularity they need. +type Sentinel struct { + status Name + label string + parent *Sentinel +} + +// NewSentinel returns a base sentinel carrying status. Prefer deriving from an +// existing sentinel with [Sentinel.Subtype]; use NewSentinel only when introducing +// a classification that no existing sentinel covers. +func NewSentinel(status Name, label string) *Sentinel { + return &Sentinel{status: status, label: label} +} + +// Subtype returns a more specific sentinel that inherits s's status and matches s +// under [errors.Is]. It is how packages declare domain failure modes: +// +// var ErrMaxTurnsExceeded = status.ErrAborted.Subtype("max turns exceeded") +// +// errors.Is(err, ErrMaxTurnsExceeded) // specific +// errors.Is(err, status.ErrAborted) // broad +func (s *Sentinel) Subtype(label string) *Sentinel { + return &Sentinel{status: s.status, label: label, parent: s} +} + +// Status returns the status name s carries. +func (s *Sentinel) Status() Name { return s.status } + +// Error implements error so a sentinel can be returned or wrapped directly. +func (s *Sentinel) Error() string { return s.label } + +// Unwrap returns the sentinel s was derived from, or nil for a base sentinel. +func (s *Sentinel) Unwrap() error { + if s.parent == nil { + return nil + } + return s.parent +} + +// Base sentinels, one per status name. Reach for these when no more specific +// sentinel fits; otherwise prefer (or declare) a domain sentinel via +// [Sentinel.Subtype] so callers can branch on the actual failure mode. +var ( + ErrCancelled = NewSentinel(Cancelled, "cancelled") + ErrUnknown = NewSentinel(Unknown, "unknown") + ErrInvalidArgument = NewSentinel(InvalidArgument, "invalid argument") + ErrDeadlineExceeded = NewSentinel(DeadlineExceeded, "deadline exceeded") + ErrNotFound = NewSentinel(NotFound, "not found") + ErrAlreadyExists = NewSentinel(AlreadyExists, "already exists") + ErrPermissionDenied = NewSentinel(PermissionDenied, "permission denied") + ErrUnauthenticated = NewSentinel(Unauthenticated, "unauthenticated") + ErrResourceExhausted = NewSentinel(ResourceExhausted, "resource exhausted") + ErrFailedPrecondition = NewSentinel(FailedPrecondition, "failed precondition") + ErrAborted = NewSentinel(Aborted, "aborted") + ErrOutOfRange = NewSentinel(OutOfRange, "out of range") + ErrUnimplemented = NewSentinel(Unimplemented, "unimplemented") + ErrInternal = NewSentinel(Internal, "internal") + ErrUnavailable = NewSentinel(Unavailable, "unavailable") + ErrDataLoss = NewSentinel(DataLoss, "data loss") +) + +// Framework-level sentinels for failures the action machinery raises. Domain +// sentinels live with the package that raises them (see ai.ErrModelNotFound, +// streaming.ErrStreamNotFound, and friends). +var ( + // ErrInvalidSchema means an action's declared input or output schema could + // not be resolved or compiled. The schema itself is wrong, not the value. + ErrInvalidSchema = ErrInvalidArgument.Subtype("invalid schema") + + // ErrInvalidInput means a value failed validation against an action's input + // schema, e.g. a model produced malformed tool arguments. + ErrInvalidInput = ErrInvalidArgument.Subtype("invalid input") + + // ErrInvalidOutput means an action produced a value that does not match its + // declared output schema. This is a bug in the action, hence Internal. + ErrInvalidOutput = ErrInternal.Subtype("invalid output") + + // ErrActionNotFound means no action is registered under the requested key. + ErrActionNotFound = ErrNotFound.Subtype("action not found") + + // ErrPanic means a user-supplied function panicked and the framework + // recovered at an action boundary. + ErrPanic = ErrInternal.Subtype("panic") +) + +// baseSentinels indexes the base sentinels by status name for [Base]. +var baseSentinels = map[Name]*Sentinel{ + Cancelled: ErrCancelled, + Unknown: ErrUnknown, + InvalidArgument: ErrInvalidArgument, + DeadlineExceeded: ErrDeadlineExceeded, + NotFound: ErrNotFound, + AlreadyExists: ErrAlreadyExists, + PermissionDenied: ErrPermissionDenied, + Unauthenticated: ErrUnauthenticated, + ResourceExhausted: ErrResourceExhausted, + FailedPrecondition: ErrFailedPrecondition, + Aborted: ErrAborted, + OutOfRange: ErrOutOfRange, + Unimplemented: ErrUnimplemented, + Internal: ErrInternal, + Unavailable: ErrUnavailable, + DataLoss: ErrDataLoss, +} + +// Base returns the base sentinel for a status name, or [ErrUnknown] if the name +// is not canonical. Use it when the status is only known at runtime, such as a +// plugin translating a provider's error code: +// +// return status.Errorf(status.Base(status.FromHTTPCode(resp.StatusCode)), +// "%s: %s", provider, body) +func Base(n Name) *Sentinel { + if s, ok := baseSentinels[n]; ok { + return s + } + return ErrUnknown +} diff --git a/go/core/status/status.go b/go/core/status/status.go new file mode 100644 index 0000000000..eec0846a3c --- /dev/null +++ b/go/core/status/status.go @@ -0,0 +1,125 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package status + +import "net/http" + +// Name is a canonical status name, drawn from the gRPC status codes. It is the +// value Genkit puts on the wire, shared by the Go, JS, and Python runtimes. +type Name string + +// The canonical status names. +const ( + OK Name = "OK" + Cancelled Name = "CANCELLED" + Unknown Name = "UNKNOWN" + InvalidArgument Name = "INVALID_ARGUMENT" + DeadlineExceeded Name = "DEADLINE_EXCEEDED" + NotFound Name = "NOT_FOUND" + AlreadyExists Name = "ALREADY_EXISTS" + PermissionDenied Name = "PERMISSION_DENIED" + Unauthenticated Name = "UNAUTHENTICATED" + ResourceExhausted Name = "RESOURCE_EXHAUSTED" + FailedPrecondition Name = "FAILED_PRECONDITION" + Aborted Name = "ABORTED" + OutOfRange Name = "OUT_OF_RANGE" + Unimplemented Name = "UNIMPLEMENTED" + Internal Name = "INTERNAL" + Unavailable Name = "UNAVAILABLE" + DataLoss Name = "DATA_LOSS" +) + +// statuses is the canonical table: for each status name, the gRPC integer code +// and the HTTP status it maps to. Membership defines [Name.IsValid]. +// +// Codes and HTTP mappings both follow https://cloud.google.com/apis/design/errors. +var statuses = map[Name]struct{ code, httpCode int }{ + OK: {0, http.StatusOK}, // 200 + Cancelled: {1, 499}, // Client Closed Request (non-standard but common) + Unknown: {2, http.StatusInternalServerError}, // 500 + InvalidArgument: {3, http.StatusBadRequest}, // 400 + DeadlineExceeded: {4, http.StatusGatewayTimeout}, // 504 + NotFound: {5, http.StatusNotFound}, // 404 + AlreadyExists: {6, http.StatusConflict}, // 409 + PermissionDenied: {7, http.StatusForbidden}, // 403 + ResourceExhausted: {8, http.StatusTooManyRequests}, // 429 + FailedPrecondition: {9, http.StatusBadRequest}, // 400 + Aborted: {10, http.StatusConflict}, // 409 + OutOfRange: {11, http.StatusBadRequest}, // 400 + Unimplemented: {12, http.StatusNotImplemented}, // 501 + Internal: {13, http.StatusInternalServerError}, // 500 + Unavailable: {14, http.StatusServiceUnavailable}, // 503 + DataLoss: {15, http.StatusInternalServerError}, // 500 + Unauthenticated: {16, http.StatusUnauthorized}, // 401 +} + +// httpCodeToName is the canonical reverse of the HTTP column above. Several +// names share an HTTP code (400 maps from InvalidArgument, FailedPrecondition, +// and OutOfRange); this table picks the canonical gRPC choice in each case. +var httpCodeToName = map[int]Name{ + http.StatusOK: OK, + 499: Cancelled, + http.StatusBadRequest: InvalidArgument, + http.StatusGatewayTimeout: DeadlineExceeded, + http.StatusNotFound: NotFound, + http.StatusConflict: Aborted, + http.StatusForbidden: PermissionDenied, + http.StatusUnauthorized: Unauthenticated, + http.StatusTooManyRequests: ResourceExhausted, + http.StatusNotImplemented: Unimplemented, + http.StatusInternalServerError: Internal, + http.StatusServiceUnavailable: Unavailable, +} + +// IsValid reports whether n is one of the canonical status names. +func (n Name) IsValid() bool { + _, ok := statuses[n] + return ok +} + +// Code returns the gRPC integer code for n, or 2 (Unknown) if n is not +// canonical. +func (n Name) Code() int { + if s, ok := statuses[n]; ok { + return s.code + } + return statuses[Unknown].code +} + +// HTTPCode returns the HTTP status code for n, or 500 if n is not canonical. +func (n Name) HTTPCode() int { + if s, ok := statuses[n]; ok { + return s.httpCode + } + return http.StatusInternalServerError +} + +// FromHTTPCode returns the canonical status name for an HTTP status code, +// following the gRPC / Google API reverse mapping. Any 5xx code with no +// explicit entry falls through to Internal; unmapped 4xx codes return Unknown. +// +// This is intended for plugins wrapping HTTP-based SDK errors so that +// status-aware middleware (retry, fallback, ...) can reason about them. +func FromHTTPCode(code int) Name { + if n, ok := httpCodeToName[code]; ok { + return n + } + if code >= 500 { + return Internal + } + return Unknown +} diff --git a/go/core/status_types.go b/go/core/status_types.go index 2e8aa5c803..ee2090aec4 100644 --- a/go/core/status_types.go +++ b/go/core/status_types.go @@ -14,37 +14,44 @@ // // SPDX-License-Identifier: Apache-2.0 -// Package status defines canonical status codes, names, and related types -// inspired by gRPC status codes. package core -import "net/http" // Import standard http package for status codes +import "github.com/firebase/genkit/go/core/status" // StatusName defines the set of canonical status names. -type StatusName string +// +// Deprecated: use [status.Name]. This is an alias for it, so the two are the +// same type and values are interchangeable. +type StatusName = status.Name // Constants for canonical status names. +// +// Deprecated: use the Go-cased constants in [github.com/firebase/genkit/go/core/status] +// ([status.InvalidArgument], [status.NotFound], ...). These alias them, so the +// values are identical and the wire format is unchanged. const ( - OK StatusName = "OK" - CANCELLED StatusName = "CANCELLED" - UNKNOWN StatusName = "UNKNOWN" - INVALID_ARGUMENT StatusName = "INVALID_ARGUMENT" - DEADLINE_EXCEEDED StatusName = "DEADLINE_EXCEEDED" - NOT_FOUND StatusName = "NOT_FOUND" - ALREADY_EXISTS StatusName = "ALREADY_EXISTS" - PERMISSION_DENIED StatusName = "PERMISSION_DENIED" - UNAUTHENTICATED StatusName = "UNAUTHENTICATED" - RESOURCE_EXHAUSTED StatusName = "RESOURCE_EXHAUSTED" - FAILED_PRECONDITION StatusName = "FAILED_PRECONDITION" - ABORTED StatusName = "ABORTED" - OUT_OF_RANGE StatusName = "OUT_OF_RANGE" - UNIMPLEMENTED StatusName = "UNIMPLEMENTED" - INTERNAL StatusName = "INTERNAL" - UNAVAILABLE StatusName = "UNAVAILABLE" - DATA_LOSS StatusName = "DATA_LOSS" + OK = status.OK + CANCELLED = status.Cancelled + UNKNOWN = status.Unknown + INVALID_ARGUMENT = status.InvalidArgument + DEADLINE_EXCEEDED = status.DeadlineExceeded + NOT_FOUND = status.NotFound + ALREADY_EXISTS = status.AlreadyExists + PERMISSION_DENIED = status.PermissionDenied + UNAUTHENTICATED = status.Unauthenticated + RESOURCE_EXHAUSTED = status.ResourceExhausted + FAILED_PRECONDITION = status.FailedPrecondition + ABORTED = status.Aborted + OUT_OF_RANGE = status.OutOfRange + UNIMPLEMENTED = status.Unimplemented + INTERNAL = status.Internal + UNAVAILABLE = status.Unavailable + DATA_LOSS = status.DataLoss ) // Constants for canonical status codes (integer values). +// +// Deprecated: use [status.Name.Code]. const ( // CodeOK means not an error; returned on success. CodeOK = 0 @@ -83,7 +90,9 @@ const ( ) // StatusNameToCode maps status names to their integer code values. -// Exported for potential use elsewhere if needed. +// +// Deprecated: use [status.Name.Code], which is correct for every name rather +// than only the ones present in this map. var StatusNameToCode = map[StatusName]int{ OK: CodeOK, CANCELLED: CodeCancelled, @@ -104,80 +113,29 @@ var StatusNameToCode = map[StatusName]int{ DATA_LOSS: CodeDataLoss, } -// statusNameToHTTPCode maps status names to HTTP status codes. -// Kept unexported as it's primarily used by the HTTPStatusCode function. -var statusNameToHTTPCode = map[StatusName]int{ - OK: http.StatusOK, // 200 - CANCELLED: 499, // Client Closed Request (non-standard but common) - UNKNOWN: http.StatusInternalServerError, // 500 - INVALID_ARGUMENT: http.StatusBadRequest, // 400 - DEADLINE_EXCEEDED: http.StatusGatewayTimeout, // 504 - NOT_FOUND: http.StatusNotFound, // 404 - ALREADY_EXISTS: http.StatusConflict, // 409 - PERMISSION_DENIED: http.StatusForbidden, // 403 - UNAUTHENTICATED: http.StatusUnauthorized, // 401 - RESOURCE_EXHAUSTED: http.StatusTooManyRequests, // 429 - FAILED_PRECONDITION: http.StatusBadRequest, // 400 - ABORTED: http.StatusConflict, // 409 - OUT_OF_RANGE: http.StatusBadRequest, // 400 - UNIMPLEMENTED: http.StatusNotImplemented, // 501 - INTERNAL: http.StatusInternalServerError, // 500 - UNAVAILABLE: http.StatusServiceUnavailable, // 503 - DATA_LOSS: http.StatusInternalServerError, // 500 -} - -// httpCodeToStatusName is the canonical reverse of [statusNameToHTTPCode]. -// Several StatusNames share an HTTP code (e.g. 400 maps from INVALID_ARGUMENT, -// FAILED_PRECONDITION, and OUT_OF_RANGE); this table picks the canonical -// gRPC choice in each case, matching -// https://cloud.google.com/apis/design/errors. -var httpCodeToStatusName = map[int]StatusName{ - http.StatusOK: OK, - 499: CANCELLED, - http.StatusBadRequest: INVALID_ARGUMENT, - http.StatusGatewayTimeout: DEADLINE_EXCEEDED, - http.StatusNotFound: NOT_FOUND, - http.StatusConflict: ABORTED, - http.StatusForbidden: PERMISSION_DENIED, - http.StatusUnauthorized: UNAUTHENTICATED, - http.StatusTooManyRequests: RESOURCE_EXHAUSTED, - http.StatusNotImplemented: UNIMPLEMENTED, - http.StatusInternalServerError: INTERNAL, - http.StatusServiceUnavailable: UNAVAILABLE, -} - // HTTPStatusCode gets the corresponding HTTP status code for a given Genkit status name. -func HTTPStatusCode(name StatusName) int { - if code, ok := statusNameToHTTPCode[name]; ok { - return code - } - - return http.StatusInternalServerError -} +// +// Deprecated: use [status.Name.HTTPCode]. +func HTTPStatusCode(name StatusName) int { return name.HTTPCode() } // StatusFromHTTPCode returns the canonical [StatusName] for an HTTP status -// code, following the gRPC / Google API reverse mapping. Any 5xx code with no -// explicit entry falls through to INTERNAL; unmapped 4xx codes return UNKNOWN. +// code, following the gRPC / Google API reverse mapping. // -// This is intended for plugins wrapping HTTP-based SDK errors so that -// status-aware middleware (retry, fallback, ...) can reason about them. -func StatusFromHTTPCode(code int) StatusName { - if s, ok := httpCodeToStatusName[code]; ok { - return s - } - if code >= 500 { - return INTERNAL - } - return UNKNOWN -} +// Deprecated: use [status.FromHTTPCode]. +func StatusFromHTTPCode(code int) StatusName { return status.FromHTTPCode(code) } // Status represents a status condition, typically used in responses or errors. +// +// Deprecated: use [status.Error], which carries a status alongside the message +// and participates in errors.Is and errors.As. type Status struct { Name StatusName `json:"name"` Message string `json:"message,omitempty"` } // NewStatus creates a new Status object. +// +// Deprecated: use [status.Errorf]. func NewStatus(name StatusName, message string) *Status { return &Status{ Name: name, diff --git a/go/genkit/reflection.go b/go/genkit/reflection.go index 53aca964bc..cd23488b3a 100644 --- a/go/genkit/reflection.go +++ b/go/genkit/reflection.go @@ -356,7 +356,7 @@ func handleRunAction(g *Genkit, activeActions *activeActionsMap) func(w http.Res } defer r.Body.Close() if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - return core.NewError(core.INVALID_ARGUMENT, err.Error()) + return core.NewError(core.INVALID_ARGUMENT, "%s", err) } stream, err := parseBoolQueryParam(r, "stream") @@ -543,7 +543,7 @@ func handleCancelAction(activeActions *activeActionsMap) func(w http.ResponseWri defer r.Body.Close() if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - return core.NewError(core.INVALID_ARGUMENT, err.Error()) + return core.NewError(core.INVALID_ARGUMENT, "%s", err) } if body.TraceID == "" { @@ -596,7 +596,7 @@ func handleNotify() func(w http.ResponseWriter, r *http.Request) error { defer r.Body.Close() if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - return core.NewError(core.INVALID_ARGUMENT, err.Error()) + return core.NewError(core.INVALID_ARGUMENT, "%s", err) } configureTelemetry(body.TelemetryServerURL) From a36162e8ec61b6914f696738e60e55c46cdcf520 Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Tue, 28 Jul 2026 09:16:12 -0700 Subject: [PATCH 02/14] feat(go): add domain sentinels for ai, exp, core, and streaming Declare the failure modes each package raises as status sentinels derived from the base ones, so callers branch with errors.Is at either granularity: errors.Is(err, ai.ErrMaxTurnsExceeded) // specific errors.Is(err, status.ErrAborted) // broad core.ErrConnectionClosed and core.ErrActionCompleted become FailedPrecondition subtypes rather than bare errors.New values, so they now carry a status; they still match errors.Is exactly as before. Migrate core/x/streaming off core.NewPublicError to status.PublicErrorf with the new stream sentinels. That package documents itself as changing in any minor release, so errors.As for *core.UserFacingError no longer matching there is in scope. genkit's subscribeToStream now matches ErrStreamNotFound directly instead of testing for a public error that happens to be NOT_FOUND. --- go/ai/errors.go | 60 +++++++++++++++++++++++++++ go/ai/exp/errors.go | 35 ++++++++++++++++ go/core/bidi.go | 5 ++- go/core/x/streaming/streaming.go | 35 ++++++++++++---- go/core/x/streaming/streaming_test.go | 58 +++++++------------------- go/genkit/servers.go | 3 +- 6 files changed, 141 insertions(+), 55 deletions(-) create mode 100644 go/ai/errors.go create mode 100644 go/ai/exp/errors.go diff --git a/go/ai/errors.go b/go/ai/errors.go new file mode 100644 index 0000000000..b4e7a96eda --- /dev/null +++ b/go/ai/errors.go @@ -0,0 +1,60 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package ai + +import "github.com/firebase/genkit/go/core/status" + +// Failure modes generation reports. Match them with errors.Is rather than by +// inspecting message text: +// +// if errors.Is(err, ai.ErrMaxTurnsExceeded) { ... } +// +// Each also matches the base sentinel it derives from, so +// errors.Is(err, status.ErrNotFound) still catches a missing model or tool. +var ( + // ErrModelNotFound means the named model is not registered. Usually the + // providing plugin is missing from genkit.Init. + ErrModelNotFound = status.ErrNotFound.Subtype("model not found") + + // ErrToolNotFound means the named tool is not registered, either on the + // request or in the registry the model's tool call resolved against. + ErrToolNotFound = status.ErrNotFound.Subtype("tool not found") + + // ErrMaxTurnsExceeded means the tool-calling loop hit its turn limit before + // the model produced a final response. Raise the limit with WithMaxTurns, or + // look for a tool the model keeps retrying. + ErrMaxTurnsExceeded = status.ErrAborted.Subtype("max turns exceeded") + + // ErrToolFailed means a tool returned an error or produced output that does + // not match its declared schema. The tool's own error is wrapped, so + // errors.Is and errors.As still reach it; the status is INTERNAL because a + // tool's failure is not a failure of the caller's request. + ErrToolFailed = status.ErrInternal.Subtype("tool failed") + + // ErrUnsupportedByModel means the request used a capability the model does + // not advertise (media, tools, tool choice, a system role, ...). + ErrUnsupportedByModel = status.ErrInvalidArgument.Subtype("unsupported by model") + + // ErrInvalidPart means a Part is malformed for the operation at hand: the + // wrong kind, missing a required field, or carrying a field its kind does + // not allow. + ErrInvalidPart = status.ErrInvalidArgument.Subtype("invalid part") + + // ErrUnresolvedToolRequest means a resumed generation left an interrupted + // tool request without a Respond or Restart directive. + ErrUnresolvedToolRequest = status.ErrInvalidArgument.Subtype("unresolved tool request") +) diff --git a/go/ai/exp/errors.go b/go/ai/exp/errors.go new file mode 100644 index 0000000000..d7572b08cf --- /dev/null +++ b/go/ai/exp/errors.go @@ -0,0 +1,35 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package exp + +import "github.com/firebase/genkit/go/core/status" + +// Failure modes agents and session stores report. Match them with errors.Is. +var ( + // ErrSnapshotNotFound means no snapshot exists under the given ID, or the + // session has none yet. + ErrSnapshotNotFound = status.ErrNotFound.Subtype("snapshot not found") + + // ErrNoSessionStore means the operation needs server-managed state but the + // agent was defined without WithSessionStore. + ErrNoSessionStore = status.ErrFailedPrecondition.Subtype("no session store") + + // ErrSessionIDRequired means a snapshot reached a store without a session ID. + // Every store implementation rejects this: a snapshot with no session cannot + // be resolved back to a conversation. + ErrSessionIDRequired = status.ErrInvalidArgument.Subtype("session ID is required") +) diff --git a/go/core/bidi.go b/go/core/bidi.go index 648d22a449..fb7e753cf7 100644 --- a/go/core/bidi.go +++ b/go/core/bidi.go @@ -26,6 +26,7 @@ import ( "time" "github.com/firebase/genkit/go/core/api" + "github.com/firebase/genkit/go/core/status" "github.com/firebase/genkit/go/core/tracing" "github.com/firebase/genkit/go/internal/base" ) @@ -522,12 +523,12 @@ func (c *BidiConnection[In, Out, Stream]) run(name string, fn func(context.Conte // ErrConnectionClosed indicates a Send on a connection whose input side // was closed with [BidiConnection.Close]. Test with [errors.Is]. -var ErrConnectionClosed = errors.New("connection is closed") +var ErrConnectionClosed = status.ErrFailedPrecondition.Subtype("connection is closed") // ErrActionCompleted indicates a Send on a connection whose action has // already returned. Test with [errors.Is]; the action's result is // available via [BidiConnection.Output]. -var ErrActionCompleted = errors.New("action has completed") +var ErrActionCompleted = status.ErrFailedPrecondition.Subtype("action has completed") // Send sends an input message to the bidi action. It blocks until the action // reads the message (backpressure), the connection is cancelled, or the diff --git a/go/core/x/streaming/streaming.go b/go/core/x/streaming/streaming.go index 3fb51a3341..b65f881c0b 100644 --- a/go/core/x/streaming/streaming.go +++ b/go/core/x/streaming/streaming.go @@ -29,7 +29,7 @@ import ( "sync" "time" - "github.com/firebase/genkit/go/core" + "github.com/firebase/genkit/go/core/status" ) // StreamEventType indicates the type of stream event. @@ -86,6 +86,23 @@ const ( streamStatusError ) +// Failure modes stream operations report. Match them with errors.Is; every +// implementation (in-memory, Firestore, ...) returns errors that satisfy these. +var ( + // ErrStreamNotFound means no stream is open under the given ID. Callers + // resuming a stream treat this as "nothing to resume" rather than a failure. + ErrStreamNotFound = status.ErrNotFound.Subtype("stream not found") + // ErrStreamExists means a stream is already open under the given ID. + ErrStreamExists = status.ErrAlreadyExists.Subtype("stream already exists") + // ErrStreamClosed means the writer was closed by a prior Done or Error. + ErrStreamClosed = status.ErrFailedPrecondition.Subtype("stream writer is closed") + // ErrStreamCompleted means the stream already reached a terminal state, so + // no further chunk, result, or error can be written to it. + ErrStreamCompleted = status.ErrFailedPrecondition.Subtype("stream has already completed") + // ErrStreamTimeout means a subscriber gave up waiting for the next event. + ErrStreamTimeout = status.ErrDeadlineExceeded.Subtype("stream timed out") +) + // streamState holds the internal state of a single stream. type streamState struct { status streamStatus @@ -196,7 +213,7 @@ func (m *InMemoryStreamManager) Open(ctx context.Context, streamID string) (Stre defer m.mu.Unlock() if _, exists := m.streams[streamID]; exists { - return nil, core.NewPublicError(core.ALREADY_EXISTS, "stream already exists", nil) + return nil, status.PublicErrorf(ErrStreamExists, "stream %q already exists", streamID) } state := &streamState{ @@ -221,7 +238,7 @@ func (m *InMemoryStreamManager) Subscribe(ctx context.Context, streamID string) m.mu.RUnlock() if !exists { - return nil, nil, core.NewPublicError(core.NOT_FOUND, "stream not found", nil) + return nil, nil, status.PublicErrorf(ErrStreamNotFound, "stream %q not found", streamID) } ch := make(chan StreamEvent, inMemoryStreamBufferSize) @@ -283,14 +300,14 @@ func (s *inMemoryStreamInput) Write(_ context.Context, chunk json.RawMessage) er defer s.mu.Unlock() if s.closed { - return core.NewPublicError(core.FAILED_PRECONDITION, "stream writer is closed", nil) + return status.PublicErrorf(ErrStreamClosed, "stream %q: writer is closed", s.streamID) } s.state.mu.Lock() defer s.state.mu.Unlock() if s.state.status != streamStatusOpen { - return core.NewPublicError(core.FAILED_PRECONDITION, "stream has already completed", nil) + return status.PublicErrorf(ErrStreamCompleted, "stream %q has already completed", s.streamID) } s.state.chunks = append(s.state.chunks, chunk) @@ -313,7 +330,7 @@ func (s *inMemoryStreamInput) Done(_ context.Context, output json.RawMessage) er defer s.mu.Unlock() if s.closed { - return core.NewPublicError(core.FAILED_PRECONDITION, "stream writer is closed", nil) + return status.PublicErrorf(ErrStreamClosed, "stream %q: writer is closed", s.streamID) } s.closed = true @@ -321,7 +338,7 @@ func (s *inMemoryStreamInput) Done(_ context.Context, output json.RawMessage) er defer s.state.mu.Unlock() if s.state.status != streamStatusOpen { - return core.NewPublicError(core.FAILED_PRECONDITION, "stream has already completed", nil) + return status.PublicErrorf(ErrStreamCompleted, "stream %q has already completed", s.streamID) } s.state.status = streamStatusDone @@ -346,7 +363,7 @@ func (s *inMemoryStreamInput) Error(_ context.Context, err error) error { defer s.mu.Unlock() if s.closed { - return core.NewPublicError(core.FAILED_PRECONDITION, "stream writer is closed", nil) + return status.PublicErrorf(ErrStreamClosed, "stream %q: writer is closed", s.streamID) } s.closed = true @@ -354,7 +371,7 @@ func (s *inMemoryStreamInput) Error(_ context.Context, err error) error { defer s.state.mu.Unlock() if s.state.status != streamStatusOpen { - return core.NewPublicError(core.FAILED_PRECONDITION, "stream has already completed", nil) + return status.PublicErrorf(ErrStreamCompleted, "stream %q has already completed", s.streamID) } s.state.status = streamStatusError diff --git a/go/core/x/streaming/streaming_test.go b/go/core/x/streaming/streaming_test.go index e86ce6f6e0..da0e389870 100644 --- a/go/core/x/streaming/streaming_test.go +++ b/go/core/x/streaming/streaming_test.go @@ -74,12 +74,8 @@ func TestInMemoryStreamManager_OpenDuplicateFails(t *testing.T) { t.Fatal("Expected error when opening duplicate stream") } - var ufErr *core.UserFacingError - if !errors.As(err, &ufErr) { - t.Fatalf("Expected UserFacingError, got %T", err) - } - if ufErr.Status != core.ALREADY_EXISTS { - t.Errorf("Expected ALREADY_EXISTS status, got %v", ufErr.Status) + if !errors.Is(err, ErrStreamExists) { + t.Errorf("error = %v, want one matching ErrStreamExists", err) } } @@ -94,12 +90,8 @@ func TestInMemoryStreamManager_SubscribeNonExistent(t *testing.T) { t.Fatal("Expected error when subscribing to non-existent stream") } - var ufErr *core.UserFacingError - if !errors.As(err, &ufErr) { - t.Fatalf("Expected UserFacingError, got %T", err) - } - if ufErr.Status != core.NOT_FOUND { - t.Errorf("Expected NOT_FOUND status, got %v", ufErr.Status) + if !errors.Is(err, ErrStreamNotFound) { + t.Errorf("error = %v, want one matching ErrStreamNotFound", err) } } @@ -260,12 +252,10 @@ func TestInMemoryStreamManager_WriteAfterDone(t *testing.T) { t.Fatal("Expected error when writing after done") } - var ufErr *core.UserFacingError - if !errors.As(err, &ufErr) { - t.Fatalf("Expected UserFacingError, got %T", err) - } - if ufErr.Status != core.FAILED_PRECONDITION { - t.Errorf("Expected FAILED_PRECONDITION status, got %v", ufErr.Status) + // Done closes the writer, so a later write reports the closed writer rather + // than the completed stream. Both are FAILED_PRECONDITION. + if !errors.Is(err, ErrStreamClosed) { + t.Errorf("error = %v, want one matching ErrStreamClosed", err) } } @@ -291,12 +281,8 @@ func TestInMemoryStreamManager_WriteAfterClose(t *testing.T) { t.Fatal("Expected error when writing after close") } - var ufErr *core.UserFacingError - if !errors.As(err, &ufErr) { - t.Fatalf("Expected UserFacingError, got %T", err) - } - if ufErr.Status != core.FAILED_PRECONDITION { - t.Errorf("Expected FAILED_PRECONDITION status, got %v", ufErr.Status) + if !errors.Is(err, ErrStreamClosed) { + t.Errorf("error = %v, want one matching ErrStreamClosed", err) } } @@ -692,12 +678,8 @@ func TestInMemoryStreamManager_CleanupExpiredStreams(t *testing.T) { t.Fatal("Expected error subscribing to expired stream") } - var ufErr *core.UserFacingError - if !errors.As(err, &ufErr) { - t.Fatalf("Expected UserFacingError, got %T", err) - } - if ufErr.Status != core.NOT_FOUND { - t.Errorf("Expected NOT_FOUND status, got %v", ufErr.Status) + if !errors.Is(err, ErrStreamNotFound) { + t.Errorf("error = %v, want one matching ErrStreamNotFound", err) } } @@ -748,12 +730,8 @@ func TestInMemoryStreamManager_ErrorAfterClose(t *testing.T) { t.Fatal("Expected error when calling Error after Close") } - var ufErr *core.UserFacingError - if !errors.As(err, &ufErr) { - t.Fatalf("Expected UserFacingError, got %T", err) - } - if ufErr.Status != core.FAILED_PRECONDITION { - t.Errorf("Expected FAILED_PRECONDITION status, got %v", ufErr.Status) + if !errors.Is(err, ErrStreamClosed) { + t.Errorf("error = %v, want one matching ErrStreamClosed", err) } } @@ -779,11 +757,7 @@ func TestInMemoryStreamManager_DoneAfterClose(t *testing.T) { t.Fatal("Expected error when calling Done after Close") } - var ufErr *core.UserFacingError - if !errors.As(err, &ufErr) { - t.Fatalf("Expected UserFacingError, got %T", err) - } - if ufErr.Status != core.FAILED_PRECONDITION { - t.Errorf("Expected FAILED_PRECONDITION status, got %v", ufErr.Status) + if !errors.Is(err, ErrStreamClosed) { + t.Errorf("error = %v, want one matching ErrStreamClosed", err) } } diff --git a/go/genkit/servers.go b/go/genkit/servers.go index 1eb34f4a4c..cb6b7acc05 100644 --- a/go/genkit/servers.go +++ b/go/genkit/servers.go @@ -357,8 +357,7 @@ func runWithDurableStreaming(ctx context.Context, w http.ResponseWriter, run run func subscribeToStream(ctx context.Context, w http.ResponseWriter, sm streaming.StreamManager, streamID string) error { events, unsubscribe, err := sm.Subscribe(ctx, streamID) if err != nil { - var ufErr *core.UserFacingError - if errors.As(err, &ufErr) && ufErr.Status == core.NOT_FOUND { + if errors.Is(err, streaming.ErrStreamNotFound) { w.WriteHeader(http.StatusNoContent) return nil } From e70787a58baff42a55e11a43045fda25b2751706 Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Tue, 28 Jul 2026 09:27:36 -0700 Subject: [PATCH 03/14] refactor(go): classify internal failures with status sentinels Move every internal error construction off core.NewError and core.NewPublicError onto status.Errorf and status.PublicErrorf, classified by sentinel. Failures that callers plausibly branch on get a domain sentinel (ai.ErrModelNotFound, ai.ErrToolFailed, ai.ErrMaxTurnsExceeded, ai.ErrUnsupportedByModel, exp.ErrSnapshotNotFound, exp.ErrNoSessionStore, exp.ErrSessionIDRequired, status.ErrInvalidSchema, status.ErrInvalidOutput, status.ErrActionNotFound, status.ErrPanic); the rest take the base sentinel for the status they already carried, so no status changes. core.NewError implicitly wrapped its last error argument, which status.Errorf does not, so 35 sites that formatted a cause with %v now use %w and keep their chain reachable through errors.Is and errors.As. bidi's send-after-close paths formatted a sentinel with %v, which never wrapped it either; they are now classified by that sentinel directly. retry and fallback both take an error's status from status.Of instead of requiring a *core.GenkitError. Retry is unaffected in practice, since an unclassified error reports INTERNAL and INTERNAL was already in its default set. Fallback previously ignored unclassified errors entirely, so a network failure was retried but never fell back; the two now agree. A cancelled context reports CANCELLED and is excluded from both, where before it was retried. googlegenai classifies provider failures through status.Name.IsValid and status.FromHTTPCode, and wraps the SDK error with %w so errors.As still reaches the underlying genai.APIError. --- go/ai/background_model.go | 5 +- go/ai/embedder.go | 3 +- go/ai/evaluator.go | 8 +- go/ai/exp/agent.go | 83 ++++++++++--------- go/ai/exp/localstore/file.go | 7 +- go/ai/exp/localstore/inmemory.go | 7 +- go/ai/exp/session.go | 25 +++--- go/ai/format.go | 8 +- go/ai/generate.go | 64 +++++++------- go/ai/middleware.go | 19 +++-- go/ai/model_middleware.go | 24 +++--- go/ai/prompt.go | 18 ++-- go/ai/retriever.go | 3 +- go/ai/tools.go | 21 ++--- go/core/action.go | 7 +- go/core/background_action.go | 9 +- go/core/bidi.go | 28 +++---- go/core/doc.go | 4 +- go/genkit/reflection.go | 17 ++-- go/genkit/servers.go | 8 +- go/plugins/firebase/auth.go | 9 +- .../firebase/exp/firestore_session_store.go | 11 +-- go/plugins/firebase/exp/stream_manager.go | 43 +++++----- go/plugins/googlegenai/errors.go | 18 ++-- go/plugins/googlegenai/gemini.go | 10 ++- go/plugins/googlegenai/imagen.go | 9 +- go/plugins/googlegenai/veo.go | 8 +- go/plugins/middleware/fallback.go | 41 +++++---- go/plugins/middleware/fallback_test.go | 39 ++++++++- go/plugins/middleware/filesystem.go | 8 +- go/plugins/middleware/retry.go | 45 +++++----- 31 files changed, 331 insertions(+), 278 deletions(-) diff --git a/go/ai/background_model.go b/go/ai/background_model.go index b563ff6743..3d08ff59d6 100644 --- a/go/ai/background_model.go +++ b/go/ai/background_model.go @@ -22,6 +22,7 @@ import ( "github.com/firebase/genkit/go/core" "github.com/firebase/genkit/go/core/api" + "github.com/firebase/genkit/go/core/status" "github.com/firebase/genkit/go/internal/registry" ) @@ -203,7 +204,7 @@ func backgroundModelToModelFn(startFn StartModelOpFunc) ModelFunc { // modelOpFromResponse extracts a [ModelOperation] from a [ModelResponse]. func modelOpFromResponse(resp *ModelResponse) (*ModelOperation, error) { if resp.Operation == nil { - return nil, core.NewError(core.FAILED_PRECONDITION, "background model did not return an operation") + return nil, status.Errorf(status.ErrFailedPrecondition, "background model did not return an operation") } op := &ModelOperation{ @@ -221,7 +222,7 @@ func modelOpFromResponse(resp *ModelResponse) (*ModelOperation, error) { if modelResp, ok := resp.Operation.Output.(*ModelResponse); ok { op.Output = modelResp } else { - return nil, core.NewError(core.INTERNAL, "operation output is not a model response") + return nil, status.Errorf(status.ErrInternal, "operation output is not a model response") } } diff --git a/go/ai/embedder.go b/go/ai/embedder.go index 5801da9592..f6836f62ae 100644 --- a/go/ai/embedder.go +++ b/go/ai/embedder.go @@ -22,6 +22,7 @@ import ( "github.com/firebase/genkit/go/core" "github.com/firebase/genkit/go/core/api" + "github.com/firebase/genkit/go/core/status" ) // EmbedderFunc is the function type for embedding documents. @@ -155,7 +156,7 @@ func LookupEmbedder(r api.Registry, name string) Embedder { // Embed runs the given [Embedder]. func (e *embedder) Embed(ctx context.Context, req *EmbedRequest) (*EmbedResponse, error) { if e == nil { - return nil, core.NewError(core.INVALID_ARGUMENT, "Embedder.Embed: embedder called on a nil embedder; check that all embedders are defined") + return nil, status.Errorf(status.ErrInvalidArgument, "Embedder.Embed: embedder called on a nil embedder; check that all embedders are defined") } return e.Run(ctx, req, nil) diff --git a/go/ai/evaluator.go b/go/ai/evaluator.go index 1ab7335932..811c180597 100644 --- a/go/ai/evaluator.go +++ b/go/ai/evaluator.go @@ -20,12 +20,14 @@ import ( "context" "fmt" + "github.com/google/uuid" + "go.opentelemetry.io/otel/trace" + "github.com/firebase/genkit/go/core" "github.com/firebase/genkit/go/core/api" "github.com/firebase/genkit/go/core/logger" + "github.com/firebase/genkit/go/core/status" "github.com/firebase/genkit/go/core/tracing" - "github.com/google/uuid" - "go.opentelemetry.io/otel/trace" ) // EvaluatorFunc is the function type for evaluator implementations. @@ -303,7 +305,7 @@ func LookupEvaluator(r api.Registry, name string) Evaluator { // Evaluate runs the given [Evaluator]. func (e *evaluator) Evaluate(ctx context.Context, req *EvaluatorRequest) (*EvaluatorResponse, error) { if e == nil { - return nil, core.NewError(core.INVALID_ARGUMENT, "Evaluator.Evaluate: evaluator called on a nil evaluator; check that all evaluators are defined") + return nil, status.Errorf(status.ErrInvalidArgument, "Evaluator.Evaluate: evaluator called on a nil evaluator; check that all evaluators are defined") } return e.Run(ctx, req, nil) diff --git a/go/ai/exp/agent.go b/go/ai/exp/agent.go index b8f89bb68c..aab076490c 100644 --- a/go/ai/exp/agent.go +++ b/go/ai/exp/agent.go @@ -33,16 +33,18 @@ import ( "sync/atomic" "time" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/core" "github.com/firebase/genkit/go/core/api" "github.com/firebase/genkit/go/core/logger" + "github.com/firebase/genkit/go/core/status" "github.com/firebase/genkit/go/core/tracing" "github.com/firebase/genkit/go/internal/base" "github.com/firebase/genkit/go/internal/genkitbridge" - "github.com/google/uuid" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/trace" ) // --- Heartbeat --- @@ -590,10 +592,10 @@ func (a *Agent[State]) Store() SessionStore[State] { // INVALID_ARGUMENT when snapshotID is empty; a missing snapshot is NOT_FOUND. func (a *Agent[State]) GetSnapshot(ctx context.Context, snapshotID string) (*SessionSnapshot[State], error) { if a.store == nil { - return nil, core.NewError(core.FAILED_PRECONDITION, "agent %q: GetSnapshot requires a session store", a.Name()) + return nil, status.Errorf(ErrNoSessionStore, "agent %q: GetSnapshot requires a session store", a.Name()) } if snapshotID == "" { - return nil, core.NewError(core.INVALID_ARGUMENT, "agent %q: GetSnapshot: snapshotID is required", a.Name()) + return nil, status.Errorf(status.ErrInvalidArgument, "agent %q: GetSnapshot: snapshotID is required", a.Name()) } return readSnapshot(ctx, a.store, a.transform, snapshotID, "") } @@ -607,10 +609,10 @@ func (a *Agent[State]) GetSnapshot(ctx context.Context, snapshotID string) (*Ses // when sessionID is empty; an unknown session is NOT_FOUND. func (a *Agent[State]) GetLatestSnapshot(ctx context.Context, sessionID string) (*SessionSnapshot[State], error) { if a.store == nil { - return nil, core.NewError(core.FAILED_PRECONDITION, "agent %q: GetLatestSnapshot requires a session store", a.Name()) + return nil, status.Errorf(ErrNoSessionStore, "agent %q: GetLatestSnapshot requires a session store", a.Name()) } if sessionID == "" { - return nil, core.NewError(core.INVALID_ARGUMENT, "agent %q: GetLatestSnapshot: sessionID is required", a.Name()) + return nil, status.Errorf(status.ErrInvalidArgument, "agent %q: GetLatestSnapshot: sessionID is required", a.Name()) } return readSnapshot(ctx, a.store, a.transform, "", sessionID) } @@ -626,10 +628,10 @@ func (a *Agent[State]) GetLatestSnapshot(ctx context.Context, sessionID string) // when snapshotID is empty. func (a *Agent[State]) Abort(ctx context.Context, snapshotID string) (SnapshotStatus, error) { if a.store == nil { - return "", core.NewError(core.FAILED_PRECONDITION, "agent %q: Abort requires a session store", a.Name()) + return "", status.Errorf(ErrNoSessionStore, "agent %q: Abort requires a session store", a.Name()) } if snapshotID == "" { - return "", core.NewError(core.INVALID_ARGUMENT, "agent %q: Abort: snapshotID is required", a.Name()) + return "", status.Errorf(status.ErrInvalidArgument, "agent %q: Abort: snapshotID is required", a.Name()) } return abortPendingSnapshot(ctx, a.store, snapshotID) } @@ -1014,7 +1016,7 @@ func (rt *agentRuntime[State]) takeFatal() error { // it crash the process. func panicError(ctx context.Context, what string, rec any) error { logger.FromContext(ctx).Error(what+" panicked", "panic", rec, "stack", string(debug.Stack())) - return core.NewError(core.INTERNAL, "%s panicked: %v", what, rec) + return status.Errorf(status.ErrPanic, "%s panicked: %v", what, rec) } // fnDoneResult carries the user fn's return values across the goroutine @@ -1301,11 +1303,11 @@ func (rt *agentRuntime[State]) handleTransformFailure( // the abort flip and promptly cancel the background work without polling). func (rt *agentRuntime[State]) checkDetachCapabilities() error { if rt.cfg.store == nil { - return core.NewError(core.FAILED_PRECONDITION, + return status.Errorf(status.ErrFailedPrecondition, "agent %q: detach requires a session store", rt.name) } if _, ok := rt.cfg.store.(SnapshotSubscriber); !ok { - return core.NewError(core.FAILED_PRECONDITION, + return status.Errorf(status.ErrFailedPrecondition, "agent %q: detach requires a session store implementing SnapshotSubscriber", rt.name) } return nil @@ -1453,7 +1455,7 @@ func (rt *agentRuntime[State]) failedOutput(ctx context.Context, cause error) *A out := &AgentOutput[State]{ SessionID: rt.session.SessionID(), FinishReason: AgentFinishReasonFailed, - Error: core.AsGenkitError(cause), + Error: status.Convert(cause), } if rt.cfg.store == nil { // This is already the failure path, so a transform that also fails @@ -1532,8 +1534,7 @@ func (rt *agentRuntime[State]) handleDetach( }) if err != nil { rt.drainAndWait(cancelWork) - return rt.failedOutput(clientCtx, core.NewError(core.INTERNAL, - "agent %q: detach: save pending snapshot: %v", rt.name, err)), nil + return rt.failedOutput(clientCtx, status.Errorf(status.ErrInternal, "agent %q: detach: save pending snapshot: %w", rt.name, err)), nil } // The router can no longer write to outCh once we return; the bidi // framework closes it shortly after. Post-detach chunks never entered @@ -1702,23 +1703,23 @@ func (rt *agentRuntime[State]) finalizePendingSnapshot( return &annotated, nil } - status := SnapshotStatusCompleted + snapStatus := SnapshotStatusCompleted // The persisted finish reason records how the background work // actually ended, distinct from the detached reason the client // already saw on AgentOutput. finishReason := completedReason - var snapErr *core.GenkitError + var snapErr *status.Error switch { case abortedByUser: - status = SnapshotStatusAborted + snapStatus = SnapshotStatusAborted finishReason = AgentFinishReasonAborted if fnErr != nil { - snapErr = core.AsGenkitError(fnErr) // aborted wins, preserve text + snapErr = status.Convert(fnErr) // aborted wins, preserve text } case fnErr != nil: - status = SnapshotStatusFailed + snapStatus = SnapshotStatusFailed finishReason = AgentFinishReasonFailed - snapErr = core.AsGenkitError(fnErr) + snapErr = status.Convert(fnErr) } // Preserve the pending row's CreatedAt (so the finalize does not @@ -1727,7 +1728,7 @@ func (rt *agentRuntime[State]) finalizePendingSnapshot( return &SessionSnapshot[State]{ SessionID: pending.SessionID, ParentID: pending.ParentID, - Status: status, + Status: snapStatus, FinishReason: finishReason, Error: snapErr, State: &finalState, @@ -1762,14 +1763,14 @@ func loadSession[State any]( } if init.State != nil && (init.SessionID != "" || init.SnapshotID != "") { - return nil, nil, core.NewError(core.INVALID_ARGUMENT, + return nil, nil, status.Errorf(status.ErrInvalidArgument, "state is mutually exclusive with session ID and snapshot ID; a client-managed conversation's identity rides inside the state (SessionState.SessionID)") } switch { case init.State != nil: if store != nil { - return nil, nil, core.NewError(core.FAILED_PRECONDITION, + return nil, nil, status.Errorf(status.ErrFailedPrecondition, "state provided but agent has a session store configured (server-managed state); use snapshot ID instead") } // Deep-copy at the entry boundary: an in-process caller retains @@ -1782,33 +1783,33 @@ func loadSession[State any]( case init.SnapshotID != "": if store == nil { - return nil, nil, core.NewError(core.FAILED_PRECONDITION, + return nil, nil, status.Errorf(status.ErrFailedPrecondition, "snapshot ID %q provided but agent has no session store configured (client-managed state); use state instead", init.SnapshotID) } snap, err := store.GetSnapshot(ctx, init.SnapshotID) if err != nil { - return nil, nil, core.NewError(core.INTERNAL, "failed to load snapshot %q: %v", init.SnapshotID, err) + return nil, nil, status.Errorf(status.ErrInternal, "failed to load snapshot %q: %w", init.SnapshotID, err) } if snap == nil { - return nil, nil, core.NewError(core.NOT_FOUND, "snapshot %q not found", init.SnapshotID) + return nil, nil, status.Errorf(ErrSnapshotNotFound, "snapshot %q not found", init.SnapshotID) } // A session ID sent alongside the snapshot ID asserts which // conversation the snapshot belongs to; a mismatch means the // caller would silently continue the wrong conversation. if init.SessionID != "" && snap.SessionID != init.SessionID { - return nil, nil, core.NewError(core.INVALID_ARGUMENT, + return nil, nil, status.Errorf(status.ErrInvalidArgument, "snapshot %q does not belong to session %q (snapshot's session: %q)", init.SnapshotID, init.SessionID, snap.SessionID) } return resumeSessionFrom(s, snap) case init.SessionID != "": if store == nil { - return nil, nil, core.NewError(core.FAILED_PRECONDITION, + return nil, nil, status.Errorf(status.ErrFailedPrecondition, "session ID %q provided but agent has no session store configured (client-managed state); the conversation's identity rides inside the state object (SessionState.SessionID)", init.SessionID) } snap, err := store.GetLatestSnapshot(ctx, init.SessionID) if err != nil { - return nil, nil, core.NewError(core.INTERNAL, "failed to resolve latest snapshot for session %q: %v", init.SessionID, err) + return nil, nil, status.Errorf(status.ErrInternal, "failed to resolve latest snapshot for session %q: %w", init.SessionID, err) } if snap == nil { // No snapshot exists for this session ID yet: the caller is @@ -1819,7 +1820,7 @@ func loadSession[State any]( return s, nil, nil } if snap.SessionID != init.SessionID { - return nil, nil, core.NewError(core.INTERNAL, + return nil, nil, status.Errorf(status.ErrInternal, "store resolved session %q to snapshot %q, which belongs to session %q; the store violates the GetLatestSnapshot contract", init.SessionID, snap.SnapshotID, snap.SessionID) } return resumeSessionFrom(s, snap) @@ -1841,13 +1842,13 @@ func resumeSessionFrom[State any](s *Session[State], snap *SessionSnapshot[State if snap.Error != nil && snap.Error.Message != "" { msg = snap.Error.Message } - return nil, nil, core.NewError(core.FAILED_PRECONDITION, + return nil, nil, status.Errorf(status.ErrFailedPrecondition, "snapshot %q terminated with error: %s", snap.SnapshotID, msg) case SnapshotStatusPending: - return nil, nil, core.NewError(core.FAILED_PRECONDITION, + return nil, nil, status.Errorf(status.ErrFailedPrecondition, "snapshot %q is still pending: its detached invocation is still running; wait for it to finalize or abort it before resuming", snap.SnapshotID) case SnapshotStatusAborted: - return nil, nil, core.NewError(core.FAILED_PRECONDITION, + return nil, nil, status.Errorf(status.ErrFailedPrecondition, "snapshot %q was aborted", snap.SnapshotID) } if snap.State != nil { @@ -2451,7 +2452,7 @@ func validateUserMessage(m *ai.Message) error { return nil } if m.Role != "" && m.Role != ai.RoleUser { - return core.NewError(core.INVALID_ARGUMENT, + return status.Errorf(status.ErrInvalidArgument, "agent input message must have role %q, got %q", ai.RoleUser, m.Role) } for _, p := range m.Content { @@ -2459,7 +2460,7 @@ func validateUserMessage(m *ai.Message) error { continue } if p.IsToolRequest() || p.IsToolResponse() { - return core.NewError(core.INVALID_ARGUMENT, + return status.Errorf(status.ErrInvalidArgument, "agent input message must not contain tool request or response parts; use AgentInput.Resume instead") } } @@ -2520,12 +2521,12 @@ func ValidateResumeAgainstHistory(resume *ToolResume, history []*ai.Message) err req := p.ToolRequest match := find(req.Name, req.Ref) if match == nil { - return core.NewError(core.INVALID_ARGUMENT, + return status.Errorf(status.ErrInvalidArgument, "resume.restart references tool %q%s which was not found in session history", req.Name, toolRefSuffix(req.Ref)) } if !jsonEqual(normalizeJSON(req.Input), normalizeJSON(match.Input)) { - return core.NewError(core.INVALID_ARGUMENT, + return status.Errorf(status.ErrInvalidArgument, "resume.restart for tool %q%s has modified inputs that do not match the original tool request in session history; restart inputs must exactly match the interrupted tool request", req.Name, toolRefSuffix(req.Ref)) } @@ -2538,7 +2539,7 @@ func ValidateResumeAgainstHistory(resume *ToolResume, history []*ai.Message) err } resp := p.ToolResponse if find(resp.Name, resp.Ref) == nil { - return core.NewError(core.INVALID_ARGUMENT, + return status.Errorf(status.ErrInvalidArgument, "resume.respond references tool %q%s which was not found in session history", resp.Name, toolRefSuffix(resp.Ref)) } @@ -2567,7 +2568,7 @@ func agentLoop[State any](r api.Registry, prompt ai.Prompt, defaultInput any) Ag return func(ctx context.Context, resp Responder, sess *SessionRunner[State]) (*AgentResult, error) { if err := sess.Run(ctx, func(ctx context.Context, input *AgentInput) (*TurnResult, error) { if !hasInputPayload(input) { - return nil, core.NewError(core.INVALID_ARGUMENT, "agent input message or resume is required") + return nil, status.Errorf(status.ErrInvalidArgument, "agent input message or resume is required") } if err := validateUserMessage(input.Message); err != nil { return nil, err @@ -2779,7 +2780,7 @@ type AgentConnection[State any] struct { // SendText, SendResume, and Detach helpers. func (c *AgentConnection[State]) Send(input *AgentInput) error { if input == nil { - return core.NewError(core.INVALID_ARGUMENT, "agent input must not be nil") + return status.Errorf(status.ErrInvalidArgument, "agent input must not be nil") } return c.conn.Send(input) } diff --git a/go/ai/exp/localstore/file.go b/go/ai/exp/localstore/file.go index 23d14f1095..01e113c0b5 100644 --- a/go/ai/exp/localstore/file.go +++ b/go/ai/exp/localstore/file.go @@ -28,9 +28,10 @@ import ( "sync" "time" - "github.com/firebase/genkit/go/ai/exp" - "github.com/firebase/genkit/go/core" "github.com/google/uuid" + + "github.com/firebase/genkit/go/ai/exp" + "github.com/firebase/genkit/go/core/status" ) // FileSessionStore is a snapshot store that persists snapshots as JSON files on @@ -217,7 +218,7 @@ func (s *FileSessionStore[State]) SaveSnapshot( // A snapshot must belong to a session; stores never mint or infer one. The // runtime stamps a session ID on every row it writes, so an empty one // indicates misuse. - return nil, core.NewError(core.INVALID_ARGUMENT, "FileSessionStore requires sessionId to be set on the snapshot") + return nil, status.Errorf(exp.ErrSessionIDRequired, "FileSessionStore requires sessionId to be set on the snapshot") } // The session ID names the per-session pointer file, so it must be a safe // path segment - the same rule snapshot IDs and prefixes obey. Reject up diff --git a/go/ai/exp/localstore/inmemory.go b/go/ai/exp/localstore/inmemory.go index 5904879841..6a66b462ac 100644 --- a/go/ai/exp/localstore/inmemory.go +++ b/go/ai/exp/localstore/inmemory.go @@ -28,9 +28,10 @@ import ( "slices" "sync" - "github.com/firebase/genkit/go/ai/exp" - "github.com/firebase/genkit/go/core" "github.com/google/uuid" + + "github.com/firebase/genkit/go/ai/exp" + "github.com/firebase/genkit/go/core/status" ) // InMemorySessionStore provides a thread-safe in-memory snapshot store. State @@ -134,7 +135,7 @@ func (s *InMemorySessionStore[State]) SaveSnapshot( // A snapshot must belong to a session; stores never mint or infer one. The // runtime stamps a session ID on every row it writes, so an empty one // indicates misuse. - return nil, core.NewError(core.INVALID_ARGUMENT, "InMemorySessionStore requires sessionId to be set on the snapshot") + return nil, status.Errorf(exp.ErrSessionIDRequired, "InMemorySessionStore requires sessionId to be set on the snapshot") } if next.Status == "" { next.Status = exp.SnapshotStatusCompleted diff --git a/go/ai/exp/session.go b/go/ai/exp/session.go index 86cb6959c8..547a30f357 100644 --- a/go/ai/exp/session.go +++ b/go/ai/exp/session.go @@ -25,6 +25,7 @@ import ( "github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/core" "github.com/firebase/genkit/go/core/api" + "github.com/firebase/genkit/go/core/status" "github.com/firebase/genkit/go/internal/base" ) @@ -222,22 +223,22 @@ func readSnapshot[State any]( if snapshotID != "" { snap, err = store.GetSnapshot(ctx, snapshotID) if err != nil { - return nil, core.NewError(core.INTERNAL, "getSnapshot: %v", err) + return nil, status.Errorf(status.ErrInternal, "getSnapshot: %w", err) } if snap == nil { - return nil, core.NewError(core.NOT_FOUND, "getSnapshot: snapshot %q not found", snapshotID) + return nil, status.Errorf(ErrSnapshotNotFound, "getSnapshot: snapshot %q not found", snapshotID) } if sessionID != "" && snap.SessionID != sessionID { - return nil, core.NewError(core.INVALID_ARGUMENT, + return nil, status.Errorf(status.ErrInvalidArgument, "getSnapshot: snapshot %q does not belong to session %q (snapshot's session: %q)", snapshotID, sessionID, snap.SessionID) } } else { snap, err = store.GetLatestSnapshot(ctx, sessionID) if err != nil { - return nil, core.NewError(core.INTERNAL, "getSnapshot: %v", err) + return nil, status.Errorf(status.ErrInternal, "getSnapshot: %w", err) } if snap == nil { - return nil, core.NewError(core.NOT_FOUND, "getSnapshot: no snapshot found for session %q", sessionID) + return nil, status.Errorf(ErrSnapshotNotFound, "getSnapshot: no snapshot found for session %q", sessionID) } } @@ -291,7 +292,7 @@ func newSnapshotActions[State any]( getSnapshotAction := core.NewAction(agentName, api.ActionTypeAgentSnapshot, nil, nil, func(ctx context.Context, req *GetSnapshotRequest) (*SessionSnapshot[State], error) { if req == nil || (req.SnapshotID == "" && req.SessionID == "") { - return nil, core.NewError(core.INVALID_ARGUMENT, "getSnapshot: snapshotId or sessionId is required") + return nil, status.Errorf(status.ErrInvalidArgument, "getSnapshot: snapshotId or sessionId is required") } return readSnapshot(ctx, store, transform, req.SnapshotID, req.SessionID) @@ -305,18 +306,18 @@ func newSnapshotActions[State any]( abortAction := core.NewAction(agentName, api.ActionTypeAgentAbort, nil, nil, func(ctx context.Context, req *AgentAbortRequest) (*AgentAbortResponse, error) { if req == nil || req.SnapshotID == "" { - return nil, core.NewError(core.INVALID_ARGUMENT, "abort: snapshotId is required") + return nil, status.Errorf(status.ErrInvalidArgument, "abort: snapshotId is required") } // Aborting is an ordinary SaveSnapshot that flips a pending row to // aborted; the store has no dedicated abort method. - status, err := abortPendingSnapshot(ctx, store, req.SnapshotID) + snapStatus, err := abortPendingSnapshot(ctx, store, req.SnapshotID) if err != nil { - return nil, core.NewError(core.INTERNAL, "abort: %v", err) + return nil, status.Errorf(status.ErrInternal, "abort: %w", err) } - if status == "" { - return nil, core.NewError(core.NOT_FOUND, "abort: snapshot %q not found", req.SnapshotID) + if snapStatus == "" { + return nil, status.Errorf(ErrSnapshotNotFound, "abort: snapshot %q not found", req.SnapshotID) } - return &AgentAbortResponse{SnapshotID: req.SnapshotID, Status: status}, nil + return &AgentAbortResponse{SnapshotID: req.SnapshotID, Status: snapStatus}, nil }) return getSnapshotAction, abortAction } diff --git a/go/ai/format.go b/go/ai/format.go index f6143929ac..f231af9b93 100644 --- a/go/ai/format.go +++ b/go/ai/format.go @@ -21,8 +21,8 @@ import ( "slices" "strings" - "github.com/firebase/genkit/go/core" "github.com/firebase/genkit/go/core/api" + "github.com/firebase/genkit/go/core/status" "github.com/firebase/genkit/go/internal/base" ) @@ -120,7 +120,7 @@ func resolveFormat(reg api.Registry, schema map[string]any, format string) (Form if f, ok := formatter.(Formatter); ok { return f, nil } - return nil, core.NewError(core.INVALID_ARGUMENT, "output format %q is invalid", format) + return nil, status.Errorf(status.ErrInvalidArgument, "output format %q is invalid", format) } // injectInstructions looks through the messages and injects formatting directives @@ -399,7 +399,7 @@ func (j jsonlFormatter) Name() string { // Handler returns a new formatter handler for the given schema. func (j jsonlFormatter) Handler(schema map[string]any) (FormatHandler, error) { if schema == nil || !base.ValidateIsJSONArray(schema) { - return nil, core.NewError(core.INVALID_ARGUMENT, "schema must be an array of objects for JSONL format") + return nil, status.Errorf(status.ErrInvalidArgument, "schema must be an array of objects for JSONL format") } jsonBytes, err := json.Marshal(schema["items"]) @@ -630,7 +630,7 @@ func (e enumFormatter) Name() string { func (e enumFormatter) Handler(schema map[string]any) (FormatHandler, error) { enums := objectEnums(schema) if schema == nil || len(enums) == 0 { - return nil, core.NewError(core.INVALID_ARGUMENT, "schema must be an object with an 'enum' property for enum format") + return nil, status.Errorf(status.ErrInvalidArgument, "schema must be an object with an 'enum' property for enum format") } instructions := fmt.Sprintf("Output should be ONLY one of the following enum values. Do not output any additional information or add quotes.\n\n```%s```", strings.Join(enums, "\n")) diff --git a/go/ai/generate.go b/go/ai/generate.go index 170058f0cc..3ca485a115 100644 --- a/go/ai/generate.go +++ b/go/ai/generate.go @@ -26,13 +26,15 @@ import ( "strings" "sync" + "github.com/google/uuid" + "github.com/invopop/jsonschema" + "github.com/firebase/genkit/go/core" "github.com/firebase/genkit/go/core/api" "github.com/firebase/genkit/go/core/logger" + "github.com/firebase/genkit/go/core/status" "github.com/firebase/genkit/go/core/tracing" "github.com/firebase/genkit/go/internal/base" - "github.com/google/uuid" - "github.com/invopop/jsonschema" ) // Model represents a model that can generate content based on a request. @@ -215,14 +217,14 @@ func GenerateWithRequest(ctx context.Context, r api.Registry, opts *GenerateActi opts.Model = defaultModel } if opts.Model == "" { - return nil, core.NewError(core.INVALID_ARGUMENT, "ai.GenerateWithRequest: model is required") + return nil, status.Errorf(status.ErrInvalidArgument, "ai.GenerateWithRequest: model is required") } } m := LookupModel(r, opts.Model) bm := LookupBackgroundModel(r, opts.Model) if m == nil && bm == nil { - return nil, core.NewError(core.NOT_FOUND, "ai.GenerateWithRequest: model %q not found", opts.Model) + return nil, status.Errorf(ErrModelNotFound, "ai.GenerateWithRequest: model %q not found", opts.Model) } mws, err := resolveRefs(ctx, r, opts.Use) @@ -236,12 +238,12 @@ func GenerateWithRequest(ctx context.Context, r api.Registry, opts *GenerateActi toolDefMap := make(map[string]*ToolDefinition) for _, t := range opts.Tools { if _, ok := toolDefMap[t]; ok { - return nil, core.NewError(core.INVALID_ARGUMENT, "ai.GenerateWithRequest: duplicate tool %q", t) + return nil, status.Errorf(status.ErrInvalidArgument, "ai.GenerateWithRequest: duplicate tool %q", t) } tool := LookupTool(r, t) if tool == nil { - return nil, core.NewError(core.NOT_FOUND, "ai.GenerateWithRequest: tool %q not found", t) + return nil, status.Errorf(ErrToolNotFound, "ai.GenerateWithRequest: tool %q not found", t) } toolDefMap[t] = tool.Definition() @@ -253,7 +255,7 @@ func GenerateWithRequest(ctx context.Context, r api.Registry, opts *GenerateActi } for _, t := range mw.Tools { if _, ok := toolDefMap[t.Name()]; ok { - return nil, core.NewError(core.INVALID_ARGUMENT, "ai.GenerateWithRequest: tool %q is contributed by middleware but already declared elsewhere", t.Name()) + return nil, status.Errorf(status.ErrInvalidArgument, "ai.GenerateWithRequest: tool %q is contributed by middleware but already declared elsewhere", t.Name()) } toolDefMap[t.Name()] = t.Definition() middlewareTools = append(middlewareTools, t) @@ -274,7 +276,7 @@ func GenerateWithRequest(ctx context.Context, r api.Registry, opts *GenerateActi maxTurns := opts.MaxTurns if maxTurns < 0 { - return nil, core.NewError(core.INVALID_ARGUMENT, "ai.GenerateWithRequest: max turns must be greater than 0, got %d", maxTurns) + return nil, status.Errorf(status.ErrInvalidArgument, "ai.GenerateWithRequest: max turns must be greater than 0, got %d", maxTurns) } if maxTurns == 0 { maxTurns = 5 // Default max turns. @@ -418,7 +420,7 @@ func GenerateWithRequest(ctx context.Context, r api.Registry, opts *GenerateActi } if resumeOutput.interruptedResponse != nil { - return nil, core.NewError(core.FAILED_PRECONDITION, + return nil, status.Errorf(status.ErrFailedPrecondition, "One or more tools triggered an interrupt during a restarted execution.") } @@ -463,7 +465,7 @@ func GenerateWithRequest(ctx context.Context, r api.Registry, opts *GenerateActi resp.Message, err = formatHandler.ParseMessage(resp.Message) if err != nil { logger.FromContext(ctx).Debug("model failed to generate output matching expected schema", "error", err.Error()) - return nil, core.NewError(core.INTERNAL, "model failed to generate output matching expected schema: %v", err) + return nil, status.Errorf(status.ErrInternal, "model failed to generate output matching expected schema: %w", err) } } @@ -472,7 +474,7 @@ func GenerateWithRequest(ctx context.Context, r api.Registry, opts *GenerateActi } if currentTurn+1 > maxTurns { - return nil, core.NewError(core.ABORTED, "exceeded maximum tool call iterations (%d)", maxTurns) + return nil, status.Errorf(ErrMaxTurnsExceeded, "exceeded maximum tool call iterations (%d)", maxTurns) } newReq, interruptMsg, err := handleToolRequests(ctx, r, req, resp, wrappedCb, currentIndex, runTool) @@ -591,14 +593,14 @@ func Generate(ctx context.Context, r api.Registry, opts ...GenerateOption) (*Mod genOpts := &generateOptions{} for _, opt := range opts { if err := opt.applyGenerate(genOpts); err != nil { - return nil, core.NewError(core.INVALID_ARGUMENT, "ai.Generate: error applying options: %v", err) + return nil, status.Errorf(status.ErrInvalidArgument, "ai.Generate: error applying options: %w", err) } } if genOpts.OutputSchema != nil { resolved, err := core.ResolveSchema(r, genOpts.OutputSchema) if err != nil { - return nil, core.NewError(core.INVALID_ARGUMENT, "ai.Generate: invalid output schema: %v", err) + return nil, status.Errorf(status.ErrInvalidArgument, "ai.Generate: invalid output schema: %w", err) } genOpts.OutputSchema = resolved if genOpts.OutputFormat == "" { @@ -701,7 +703,7 @@ func Generate(ctx context.Context, r api.Registry, opts ...GenerateOption) (*Mod processedMessages, err := processResources(ctx, r, messages) if err != nil { - return nil, core.NewError(core.INTERNAL, "ai.Generate: error processing resources: %v", err) + return nil, status.Errorf(status.ErrInternal, "ai.Generate: error processing resources: %w", err) } actionOpts.Messages = processedMessages @@ -876,7 +878,7 @@ func GenerateDataStream[Out any](ctx context.Context, r api.Registry, opts ...Ge // Generate applies the [Action] to provided request. func (m *model) Generate(ctx context.Context, req *ModelRequest, cb ModelStreamCallback) (*ModelResponse, error) { if m == nil { - return nil, core.NewError(core.INVALID_ARGUMENT, "Model.Generate: generate called on a nil model; check that all models are defined") + return nil, status.Errorf(status.ErrInvalidArgument, "Model.Generate: generate called on a nil model; check that all models are defined") } return m.Action.Run(ctx, req, cb) @@ -995,7 +997,7 @@ func handleToolRequests(ctx context.Context, r api.Registry, req *ModelRequest, toolReq := p.ToolRequest tool := LookupTool(r, p.ToolRequest.Name) if tool == nil { - resultChan <- result[*MultipartToolResponse]{index: idx, err: core.NewError(core.NOT_FOUND, "tool %q not found", toolReq.Name)} + resultChan <- result[*MultipartToolResponse]{index: idx, err: status.Errorf(ErrToolNotFound, "tool %q not found", toolReq.Name)} return } @@ -1044,7 +1046,7 @@ func handleToolRequests(ctx context.Context, r api.Registry, req *ModelRequest, return } - resultChan <- result[*MultipartToolResponse]{index: idx, err: core.NewError(core.INTERNAL, "tool %q failed: %v", toolReq.Name, err)} + resultChan <- result[*MultipartToolResponse]{index: idx, err: status.Errorf(ErrToolFailed, "tool %q failed: %w", toolReq.Name, err)} return } @@ -1473,7 +1475,7 @@ func (ModelRef) JSONSchema() *jsonschema.Schema { // pending output, or explicit 'respond' or 'restart' directives in the resume options. func handleResumedToolRequest(ctx context.Context, r api.Registry, genOpts *GenerateActionOptions, p *Part, runTool toolRunnerFunc) (*resumedToolRequestOutput, error) { if p == nil || !p.IsToolRequest() { - return nil, core.NewError(core.INVALID_ARGUMENT, "handleResumedToolRequest: part is not a tool request") + return nil, status.Errorf(status.ErrInvalidArgument, "handleResumedToolRequest: part is not a tool request") } if pendingOutputVal, ok := p.Metadata["pendingOutput"]; ok { @@ -1504,23 +1506,23 @@ func handleResumedToolRequest(ctx context.Context, r api.Registry, genOpts *Gene tool := LookupTool(r, toolReq.Name) if tool == nil { - return nil, core.NewError(core.NOT_FOUND, "handleResumedToolRequest: tool %q not found", toolReq.Name) + return nil, status.Errorf(ErrToolNotFound, "handleResumedToolRequest: tool %q not found", toolReq.Name) } toolDef := tool.Definition() if len(toolDef.OutputSchema) > 0 { outputBytes, err := json.Marshal(respondPart.ToolResponse.Output) if err != nil { - return nil, core.NewError(core.INVALID_ARGUMENT, "handleResumedToolRequest: failed to marshal tool output for validation: %v", err) + return nil, status.Errorf(status.ErrInvalidArgument, "handleResumedToolRequest: failed to marshal tool output for validation: %w", err) } schemaBytes, err := json.Marshal(toolDef.OutputSchema) if err != nil { - return nil, core.NewError(core.INTERNAL, "handleResumedToolRequest: tool %q has invalid output schema: %v", toolReq.Name, err) + return nil, status.Errorf(status.ErrInternal, "handleResumedToolRequest: tool %q has invalid output schema: %w", toolReq.Name, err) } if err := base.ValidateRaw(outputBytes, schemaBytes); err != nil { - return nil, core.NewError(core.INVALID_ARGUMENT, "handleResumedToolRequest: tool %q output validation failed: %v", toolReq.Name, err) + return nil, status.Errorf(status.ErrInvalidArgument, "handleResumedToolRequest: tool %q output validation failed: %w", toolReq.Name, err) } } @@ -1540,7 +1542,7 @@ func handleResumedToolRequest(ctx context.Context, r api.Registry, genOpts *Gene restartPart.ToolRequest.Ref == toolReq.Ref { tool := LookupTool(r, restartPart.ToolRequest.Name) if tool == nil { - return nil, core.NewError(core.NOT_FOUND, "handleResumedToolRequest: tool %q not found", restartPart.ToolRequest.Name) + return nil, status.Errorf(ErrToolNotFound, "handleResumedToolRequest: tool %q not found", restartPart.ToolRequest.Name) } resumedCtx := ctx @@ -1581,7 +1583,7 @@ func handleResumedToolRequest(ctx context.Context, r api.Registry, genOpts *Gene }, nil } - return nil, core.NewError(core.INTERNAL, "tool %q failed: %v", restartPart.ToolRequest.Name, err) + return nil, status.Errorf(ErrToolFailed, "tool %q failed: %w", restartPart.ToolRequest.Name, err) } newToolReq := clone(p) @@ -1610,7 +1612,7 @@ func handleResumedToolRequest(ctx context.Context, r api.Registry, genOpts *Gene if p.ToolRequest.Ref != "" { refStr = "#" + p.ToolRequest.Ref } - return nil, core.NewError(core.INVALID_ARGUMENT, "unresolved tool request %q was not handled by the Resume argument; you must supply Respond or Restart directives, or ensure there is pending output from a previous tool call", refStr) + return nil, status.Errorf(ErrUnresolvedToolRequest, "unresolved tool request %q was not handled by the Resume argument; you must supply Respond or Restart directives, or ensure there is pending output from a previous tool call", refStr) } // handleResumeOption amends message history to handle `resume` arguments. @@ -1622,12 +1624,12 @@ func handleResumeOption(ctx context.Context, r api.Registry, genOpts *GenerateAc for _, part := range genOpts.Resume.Respond { if !part.IsToolResponse() { - return nil, core.NewError(core.INVALID_ARGUMENT, "handleResumeOption: respond part is not a tool response") + return nil, status.Errorf(status.ErrInvalidArgument, "handleResumeOption: respond part is not a tool response") } } for _, part := range genOpts.Resume.Restart { if !part.IsToolRequest() { - return nil, core.NewError(core.INVALID_ARGUMENT, "handleResumeOption: restart part is not a tool request") + return nil, status.Errorf(status.ErrInvalidArgument, "handleResumeOption: restart part is not a tool request") } } @@ -1635,19 +1637,19 @@ func handleResumeOption(ctx context.Context, r api.Registry, genOpts *GenerateAc for _, t := range genOpts.Tools { tool := LookupTool(r, t) if tool == nil { - return nil, core.NewError(core.NOT_FOUND, "handleResumeOption: tool %q not found", t) + return nil, status.Errorf(ErrToolNotFound, "handleResumeOption: tool %q not found", t) } toolDefMap[t] = tool.Definition() } messages := genOpts.Messages if len(messages) == 0 { - return nil, core.NewError(core.FAILED_PRECONDITION, "handleResumeOption: cannot resume generation with no messages") + return nil, status.Errorf(status.ErrFailedPrecondition, "handleResumeOption: cannot resume generation with no messages") } lastMessage := messages[len(messages)-1] if lastMessage.Role != RoleModel || !slices.ContainsFunc(lastMessage.Content, func(p *Part) bool { return p.IsToolRequest() }) { - return nil, core.NewError(core.FAILED_PRECONDITION, "handleResumeOption: cannot resume generation unless the last message is by a model with at least one tool request") + return nil, status.Errorf(status.ErrFailedPrecondition, "handleResumeOption: cannot resume generation unless the last message is by a model with at least one tool request") } toolReqCount := 0 @@ -1707,7 +1709,7 @@ func handleResumeOption(ctx context.Context, r api.Registry, genOpts *GenerateAc } if len(toolResps) != toolReqCount { - return nil, core.NewError(core.FAILED_PRECONDITION, "handleResumeOption: Expected %d tool responses but resolved to %d.", toolReqCount, len(toolResps)) + return nil, status.Errorf(status.ErrFailedPrecondition, "handleResumeOption: Expected %d tool responses but resolved to %d.", toolReqCount, len(toolResps)) } toolMessage := &Message{ diff --git a/go/ai/middleware.go b/go/ai/middleware.go index 96c0c8e38c..f4a8486c8b 100644 --- a/go/ai/middleware.go +++ b/go/ai/middleware.go @@ -22,6 +22,7 @@ import ( "github.com/firebase/genkit/go/core" "github.com/firebase/genkit/go/core/api" + "github.com/firebase/genkit/go/core/status" ) // Hooks is the per-call bundle of hook functions produced by a [Middleware]'s @@ -144,7 +145,7 @@ func NewMiddleware[M Middleware](description string, prototype M) *MiddlewareDes cfg := prototype // value copy preserves unexported fields, shares pointers if len(configJSON) > 0 { if err := json.Unmarshal(configJSON, &cfg); err != nil { - return nil, core.NewError(core.INVALID_ARGUMENT, "middleware %q: %w", name, err) + return nil, status.Errorf(status.ErrInvalidArgument, "middleware %q: %w", name, err) } } return cfg.New(ctx) @@ -195,7 +196,7 @@ func (r middlewareRefArg) Name() string { return r.name } // for a name-only [MiddlewareRef] before [resolveRefs] sees it; the error // here surfaces a routing bug instead of returning nil hooks. func (middlewareRefArg) New(context.Context) (*Hooks, error) { - return nil, core.NewError(core.INTERNAL, "ai: middlewareRefArg must be resolved via the registry") + return nil, status.Errorf(status.ErrInternal, "ai: middlewareRefArg must be resolved via the registry") } // LookupMiddleware returns the registered middleware descriptor with the @@ -233,7 +234,7 @@ func configsToRefs(configs []Middleware) ([]*MiddlewareRef, error) { refs := make([]*MiddlewareRef, 0, len(configs)) for _, c := range configs { if c == nil { - return nil, core.NewError(core.INVALID_ARGUMENT, "ai: nil middleware") + return nil, status.Errorf(status.ErrInvalidArgument, "ai: nil middleware") } if lazy, ok := c.(middlewareRefArg); ok { refs = append(refs, &MiddlewareRef{Name: lazy.name, Config: lazy.config}) @@ -258,32 +259,32 @@ func resolveRefs(ctx context.Context, r api.Registry, refs []*MiddlewareRef) ([] if mw, ok := ref.Config.(Middleware); ok { h, err := mw.New(ctx) if err != nil { - return nil, core.NewError(core.INVALID_ARGUMENT, "ai: failed to build middleware %q: %v", ref.Name, err) + return nil, status.Errorf(status.ErrInvalidArgument, "ai: failed to build middleware %q: %w", ref.Name, err) } if h == nil { - return nil, core.NewError(core.INTERNAL, "ai: middleware %q returned nil hooks", ref.Name) + return nil, status.Errorf(status.ErrInternal, "ai: middleware %q returned nil hooks", ref.Name) } bundles = append(bundles, h) continue } d := LookupMiddleware(r, ref.Name) if d == nil { - return nil, core.NewError(core.NOT_FOUND, "ai: middleware %q not registered (is the providing plugin installed?)", ref.Name) + return nil, status.Errorf(status.ErrNotFound, "ai: middleware %q not registered (is the providing plugin installed?)", ref.Name) } var configJSON []byte if ref.Config != nil { b, err := json.Marshal(ref.Config) if err != nil { - return nil, core.NewError(core.INTERNAL, "ai: failed to marshal config for middleware %q: %v", ref.Name, err) + return nil, status.Errorf(status.ErrInternal, "ai: failed to marshal config for middleware %q: %w", ref.Name, err) } configJSON = b } h, err := d.buildFromJSON(ctx, configJSON) if err != nil { - return nil, core.NewError(core.INVALID_ARGUMENT, "ai: failed to build middleware %q: %v", ref.Name, err) + return nil, status.Errorf(status.ErrInvalidArgument, "ai: failed to build middleware %q: %w", ref.Name, err) } if h == nil { - return nil, core.NewError(core.INTERNAL, "ai: middleware %q factory returned nil", ref.Name) + return nil, status.Errorf(status.ErrInternal, "ai: middleware %q factory returned nil", ref.Name) } bundles = append(bundles, h) } diff --git a/go/ai/model_middleware.go b/go/ai/model_middleware.go index ed5aa9340c..baeeeb5468 100644 --- a/go/ai/model_middleware.go +++ b/go/ai/model_middleware.go @@ -28,8 +28,8 @@ import ( "strings" "time" - "github.com/firebase/genkit/go/core" "github.com/firebase/genkit/go/core/logger" + "github.com/firebase/genkit/go/core/status" ) // AugmentWithContextOptions configures how a request is augmented with context. @@ -228,28 +228,28 @@ func validateSupport(model string, opts *ModelOptions) ModelMiddleware { for _, msg := range input.Messages { for _, part := range msg.Content { if part.IsMedia() { - return nil, core.NewError(core.INVALID_ARGUMENT, "model %q does not support media, but media was provided. Request: %+v", model, input) + return nil, status.Errorf(ErrUnsupportedByModel, "model %q does not support media, but media was provided. Request: %+v", model, input) } } } } if !opts.Supports.Tools && len(input.Tools) > 0 { - return nil, core.NewError(core.INVALID_ARGUMENT, "model %q does not support tool use, but tools were provided. Request: %+v", model, input) + return nil, status.Errorf(ErrUnsupportedByModel, "model %q does not support tool use, but tools were provided. Request: %+v", model, input) } if !opts.Supports.Multiturn && len(input.Messages) > 1 { - return nil, core.NewError(core.INVALID_ARGUMENT, "model %q does not support multiple messages, but %d were provided. Request: %+v", model, len(input.Messages), input) + return nil, status.Errorf(ErrUnsupportedByModel, "model %q does not support multiple messages, but %d were provided. Request: %+v", model, len(input.Messages), input) } if !opts.Supports.ToolChoice && input.ToolChoice != "" && input.ToolChoice != ToolChoiceAuto { - return nil, core.NewError(core.INVALID_ARGUMENT, "model %q does not support tool choice, but tool choice was provided. Request: %+v", model, input) + return nil, status.Errorf(ErrUnsupportedByModel, "model %q does not support tool choice, but tool choice was provided. Request: %+v", model, input) } if !opts.Supports.SystemRole { for _, msg := range input.Messages { if msg.Role == RoleSystem { - return nil, core.NewError(core.INVALID_ARGUMENT, "model %q does not support system role, but system role was provided. Request: %+v", model, input) + return nil, status.Errorf(ErrUnsupportedByModel, "model %q does not support system role, but system role was provided. Request: %+v", model, input) } } } @@ -262,7 +262,7 @@ func validateSupport(model string, opts *ModelOptions) ModelMiddleware { opts.Supports.Constrained == ConstrainedSupportNone || (opts.Supports.Constrained == ConstrainedSupportNoTools && len(input.Tools) > 0)) && input.Output != nil && input.Output.Constrained { - return nil, core.NewError(core.INVALID_ARGUMENT, "model %q does not support native constrained output, but constrained output was requested. Request: %+v", model, input) + return nil, status.Errorf(ErrUnsupportedByModel, "model %q does not support native constrained output, but constrained output was requested. Request: %+v", model, input) } if err := validateVersion(model, opts.Versions, input.Config); err != nil { @@ -298,14 +298,14 @@ func validateVersion(model string, versions []string, config any) error { version, ok := versionVal.(string) if !ok { - return core.NewError(core.INVALID_ARGUMENT, "version must be a string, got %T", versionVal) + return status.Errorf(status.ErrInvalidArgument, "version must be a string, got %T", versionVal) } if slices.Contains(versions, version) { return nil } - return core.NewError(core.INVALID_ARGUMENT, "model %q does not support version %q, supported versions: %v", model, version, versions) + return status.Errorf(ErrUnsupportedByModel, "model %q does not support version %q, supported versions: %v", model, version, versions) } // ContextItemTemplate is the default item template for context augmentation. @@ -424,13 +424,13 @@ func DownloadRequestMedia(opts *DownloadMediaOptions) ModelMiddleware { resp, err := client.Get(mediaUrl) if err != nil { - return nil, core.NewError(core.INVALID_ARGUMENT, "HTTP error downloading media %q: %v", mediaUrl, err) + return nil, status.Errorf(status.ErrInvalidArgument, "HTTP error downloading media %q: %w", mediaUrl, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return nil, core.NewError(core.UNKNOWN, "HTTP error downloading media %q: %s", mediaUrl, string(body)) + return nil, status.Errorf(status.ErrUnknown, "HTTP error downloading media %q: %s", mediaUrl, string(body)) } contentType := part.ContentType @@ -446,7 +446,7 @@ func DownloadRequestMedia(opts *DownloadMediaOptions) ModelMiddleware { data, err = io.ReadAll(resp.Body) } if err != nil { - return nil, core.NewError(core.UNKNOWN, "error reading media %q: %v", mediaUrl, err) + return nil, status.Errorf(status.ErrUnknown, "error reading media %q: %w", mediaUrl, err) } message.Content[j] = NewMediaPart(contentType, fmt.Sprintf("data:%s;base64,%s", contentType, base64.StdEncoding.EncodeToString(data))) diff --git a/go/ai/prompt.go b/go/ai/prompt.go index 2320a6547f..95aaa1cc98 100644 --- a/go/ai/prompt.go +++ b/go/ai/prompt.go @@ -29,12 +29,14 @@ import ( "slices" "strings" + "github.com/google/dotprompt/go/dotprompt" + "github.com/invopop/jsonschema" + "github.com/firebase/genkit/go/core" "github.com/firebase/genkit/go/core/api" "github.com/firebase/genkit/go/core/logger" + "github.com/firebase/genkit/go/core/status" "github.com/firebase/genkit/go/internal/base" - "github.com/google/dotprompt/go/dotprompt" - "github.com/invopop/jsonschema" ) // Prompt is the interface for a prompt that can be executed and rendered. @@ -154,7 +156,7 @@ func LookupPrompt(r api.Registry, name string) Prompt { // passes the rendered template to the AI model specified by the prompt. func (p *prompt) Execute(ctx context.Context, opts ...PromptExecuteOption) (*ModelResponse, error) { if p == nil { - return nil, core.NewError(core.INVALID_ARGUMENT, "Prompt.Execute: prompt is nil") + return nil, status.Errorf(status.ErrInvalidArgument, "Prompt.Execute: prompt is nil") } execOpts := &promptExecutionOptions{} @@ -280,7 +282,7 @@ func (p *prompt) Execute(ctx context.Context, opts ...PromptExecuteOption) (*Mod func (p *prompt) ExecuteStream(ctx context.Context, opts ...PromptExecuteOption) iter.Seq2[*ModelStreamValue, error] { return func(yield func(*ModelStreamValue, error) bool) { if p == nil { - yield(nil, core.NewError(core.INVALID_ARGUMENT, "Prompt.ExecuteStream: prompt is nil")) + yield(nil, status.Errorf(status.ErrInvalidArgument, "Prompt.ExecuteStream: prompt is nil")) return } @@ -316,7 +318,7 @@ func (p *prompt) ExecuteStream(ctx context.Context, opts ...PromptExecuteOption) // Render renders the prompt template based on user input. func (p *prompt) Render(ctx context.Context, input any) (*GenerateActionOptions, error) { if p == nil { - return nil, core.NewError(core.INVALID_ARGUMENT, "Prompt.Render: prompt is nil") + return nil, status.Errorf(status.ErrInvalidArgument, "Prompt.Render: prompt is nil") } if len(p.Middleware) > 0 { @@ -466,7 +468,7 @@ func (p *prompt) buildRequest(ctx context.Context, input any) (*GenerateActionOp outputSchema, err := core.ResolveSchema(p.registry, p.OutputSchema) if err != nil { - return nil, core.NewError(core.INVALID_ARGUMENT, "invalid output schema for prompt %q: %v", p.Name(), err) + return nil, status.Errorf(status.ErrInvalidArgument, "invalid output schema for prompt %q: %w", p.Name(), err) } useRefs, err := configsToRefs(p.Use) @@ -1004,7 +1006,7 @@ func AsDataPrompt[In, Out any](p Prompt) *DataPrompt[In, Out] { // output schema, either through [DefineDataPrompt] or by using [WithOutputType] when defining the prompt. func (dp *DataPrompt[In, Out]) Execute(ctx context.Context, input In, opts ...PromptExecuteOption) (Out, *ModelResponse, error) { if dp == nil { - return base.Zero[Out](), nil, core.NewError(core.INVALID_ARGUMENT, "DataPrompt.Execute: prompt is nil") + return base.Zero[Out](), nil, status.Errorf(status.ErrInvalidArgument, "DataPrompt.Execute: prompt is nil") } allOpts := append(slices.Clone(opts), WithInput(input)) @@ -1037,7 +1039,7 @@ func (dp *DataPrompt[In, Out]) Execute(ctx context.Context, input In, opts ...Pr func (dp *DataPrompt[In, Out]) ExecuteStream(ctx context.Context, input In, opts ...PromptExecuteOption) iter.Seq2[*StreamValue[Out, Out], error] { return func(yield func(*StreamValue[Out, Out], error) bool) { if dp == nil { - yield(nil, core.NewError(core.INVALID_ARGUMENT, "DataPrompt.ExecuteStream: prompt is nil")) + yield(nil, status.Errorf(status.ErrInvalidArgument, "DataPrompt.ExecuteStream: prompt is nil")) return } diff --git a/go/ai/retriever.go b/go/ai/retriever.go index 392fbc41c2..cdaf8ad799 100644 --- a/go/ai/retriever.go +++ b/go/ai/retriever.go @@ -23,6 +23,7 @@ import ( "github.com/firebase/genkit/go/core" "github.com/firebase/genkit/go/core/api" + "github.com/firebase/genkit/go/core/status" ) // RetrieverFunc is the function type for retriever implementations. @@ -148,7 +149,7 @@ func LookupRetriever(r api.Registry, name string) Retriever { // Retrieve runs the given [Retriever]. func (r *retriever) Retrieve(ctx context.Context, req *RetrieverRequest) (*RetrieverResponse, error) { if r == nil { - return nil, core.NewError(core.INVALID_ARGUMENT, "Retriever.Retrieve: retriever called on a nil retriever; check that all retrievers are defined") + return nil, status.Errorf(status.ErrInvalidArgument, "Retriever.Retrieve: retriever called on a nil retriever; check that all retrievers are defined") } return r.Run(ctx, req, nil) diff --git a/go/ai/tools.go b/go/ai/tools.go index 0e66ab72be..eb5e84fe79 100644 --- a/go/ai/tools.go +++ b/go/ai/tools.go @@ -26,6 +26,7 @@ import ( "github.com/firebase/genkit/go/core" "github.com/firebase/genkit/go/core/api" + "github.com/firebase/genkit/go/core/status" "github.com/firebase/genkit/go/internal/base" ) @@ -542,7 +543,7 @@ func (t *ToolDef[In, Out]) RunRaw(ctx context.Context, input any) (any, error) { // It returns the full multipart response. func (t *ToolDef[In, Out]) RunRawMultipart(ctx context.Context, input any) (*MultipartToolResponse, error) { if t == nil { - return nil, core.NewError(core.INVALID_ARGUMENT, "ai.Tool.RunRawMultipart: tool called on a nil tool; check that all tools are defined") + return nil, status.Errorf(status.ErrInvalidArgument, "ai.Tool.RunRawMultipart: tool called on a nil tool; check that all tools are defined") } mi, err := json.Marshal(input) @@ -678,19 +679,19 @@ func (t *ToolDef[In, Out]) Restart(p *Part, opts *RestartOptions) *Part { // part, err := myTool.RespondWith(toolReq, output, WithResponseMetadata[MyOutput](meta)) func (t *ToolDef[In, Out]) RespondWith(toolReq *Part, output Out, opts ...RespondWithOption[Out]) (*Part, error) { if toolReq == nil { - return nil, core.NewError(core.INVALID_ARGUMENT, "ai.RespondWith: toolReq is nil") + return nil, status.Errorf(status.ErrInvalidArgument, "ai.RespondWith: toolReq is nil") } if !toolReq.IsToolRequest() { - return nil, core.NewError(core.INVALID_ARGUMENT, "ai.RespondWith: part is not a tool request") + return nil, status.Errorf(status.ErrInvalidArgument, "ai.RespondWith: part is not a tool request") } if toolReq.ToolRequest.Name != t.Name() { - return nil, core.NewError(core.INVALID_ARGUMENT, "ai.RespondWith: tool request is for %q, not %q", toolReq.ToolRequest.Name, t.Name()) + return nil, status.Errorf(status.ErrInvalidArgument, "ai.RespondWith: tool request is for %q, not %q", toolReq.ToolRequest.Name, t.Name()) } cfg := &RespondOptions{} for _, opt := range opts { if err := opt.applyRespondWith(cfg); err != nil { - return nil, core.NewError(core.INVALID_ARGUMENT, "ai.RespondWith: %v", err) + return nil, status.Errorf(status.ErrInvalidArgument, "ai.RespondWith: %w", err) } } @@ -712,19 +713,19 @@ func (t *ToolDef[In, Out]) RespondWith(toolReq *Part, output Out, opts ...Respon // part, err := myTool.RestartWith(toolReq, WithNewInput(newInput), WithResumedMetadata[MyInput](meta)) func (t *ToolDef[In, Out]) RestartWith(toolReq *Part, opts ...RestartWithOption[In]) (*Part, error) { if toolReq == nil { - return nil, core.NewError(core.INVALID_ARGUMENT, "ai.RestartWith: toolReq is nil") + return nil, status.Errorf(status.ErrInvalidArgument, "ai.RestartWith: toolReq is nil") } if !toolReq.IsToolRequest() { - return nil, core.NewError(core.INVALID_ARGUMENT, "ai.RestartWith: part is not a tool request") + return nil, status.Errorf(status.ErrInvalidArgument, "ai.RestartWith: part is not a tool request") } if toolReq.ToolRequest.Name != t.Name() { - return nil, core.NewError(core.INVALID_ARGUMENT, "ai.RestartWith: tool request is for %q, not %q", toolReq.ToolRequest.Name, t.Name()) + return nil, status.Errorf(status.ErrInvalidArgument, "ai.RestartWith: tool request is for %q, not %q", toolReq.ToolRequest.Name, t.Name()) } cfg := &RestartOptions{} for _, opt := range opts { if err := opt.applyRestartWith(cfg); err != nil { - return nil, core.NewError(core.INVALID_ARGUMENT, "ai.RestartWith: %v", err) + return nil, status.Errorf(status.ErrInvalidArgument, "ai.RestartWith: %w", err) } } @@ -771,7 +772,7 @@ func resolveUniqueTools(r api.Registry, toolRefs []ToolRef) (toolNames []string, name := toolRef.Name() if toolMap[name] { - return nil, nil, core.NewError(core.INVALID_ARGUMENT, "duplicate tool %q", name) + return nil, nil, status.Errorf(status.ErrInvalidArgument, "duplicate tool %q", name) } toolMap[name] = true toolNames = append(toolNames, name) diff --git a/go/core/action.go b/go/core/action.go index 53a91213cd..5c11f7a8e1 100644 --- a/go/core/action.go +++ b/go/core/action.go @@ -24,6 +24,7 @@ import ( "github.com/firebase/genkit/go/core/api" "github.com/firebase/genkit/go/core/logger" + "github.com/firebase/genkit/go/core/status" "github.com/firebase/genkit/go/core/tracing" "github.com/firebase/genkit/go/internal/base" "github.com/firebase/genkit/go/internal/metrics" @@ -258,7 +259,7 @@ func (a *Action[In, Out, Stream]) runWithTelemetry(ctx context.Context, input In var inputSchema map[string]any inputSchema, err = ResolveSchema(a.registry, a.desc.InputSchema) if err != nil { - return base.Zero[Out](), NewError(INVALID_ARGUMENT, "invalid input schema for action %q: %v", a.desc.Key, err) + return base.Zero[Out](), status.Errorf(status.ErrInvalidSchema, "invalid input schema for action %q: %v", a.desc.Key, err) } var outputSchema map[string]any @@ -320,7 +321,7 @@ func recordActionMetrics(ctx context.Context, name string, start time.Time, err func (a *Action[In, Out, Stream]) resolveOutputSchema() (map[string]any, error) { schema, err := ResolveSchema(a.registry, a.desc.OutputSchema) if err != nil { - return nil, NewError(INVALID_ARGUMENT, "invalid output schema for action %q: %v", a.desc.Key, err) + return nil, status.Errorf(status.ErrInvalidSchema, "invalid output schema for action %q: %v", a.desc.Key, err) } return schema, nil } @@ -329,7 +330,7 @@ func (a *Action[In, Out, Stream]) resolveOutputSchema() (map[string]any, error) // schema. func (a *Action[In, Out, Stream]) validateOutput(out Out, schema map[string]any) error { if err := base.ValidateValue(out, schema); err != nil { - return NewError(INTERNAL, "invalid output from action %q: %v", a.desc.Key, err) + return status.Errorf(status.ErrInvalidOutput, "invalid output from action %q: %v", a.desc.Key, err) } return nil } diff --git a/go/core/background_action.go b/go/core/background_action.go index e3777f01ff..5aa3cbff6f 100644 --- a/go/core/background_action.go +++ b/go/core/background_action.go @@ -20,6 +20,7 @@ import ( "context" "github.com/firebase/genkit/go/core/api" + "github.com/firebase/genkit/go/core/status" ) // StartOpFunc starts a background operation. @@ -64,7 +65,7 @@ func (b *BackgroundActionDef[In, Out]) Check(ctx context.Context, op *Operation[ // Cancel attempts to cancel a background operation. It returns an error if the background action does not support cancellation. func (b *BackgroundActionDef[In, Out]) Cancel(ctx context.Context, op *Operation[Out]) (*Operation[Out], error) { if !b.SupportsCancel() { - return nil, NewError(UNAVAILABLE, "model %q does not support canceling operations", b.Name()) + return nil, status.Errorf(status.ErrUnavailable, "model %q does not support canceling operations", b.Name()) } return b.cancel.Run(ctx, op, nil) @@ -187,16 +188,16 @@ func LookupBackgroundAction[In, Out any](r api.Registry, key string) *Background // CheckOperation checks the status of a background operation by looking up the action and calling its Check method. func CheckOperation[In, Out any](ctx context.Context, r api.Registry, op *Operation[Out]) (*Operation[Out], error) { if op == nil { - return nil, NewError(INVALID_ARGUMENT, "core.CheckOperation: operation is nil") + return nil, status.Errorf(status.ErrInvalidArgument, "core.CheckOperation: operation is nil") } if op.Action == "" { - return nil, NewError(INVALID_ARGUMENT, "core.CheckOperation: operation is missing original request information") + return nil, status.Errorf(status.ErrInvalidArgument, "core.CheckOperation: operation is missing original request information") } m := LookupBackgroundAction[In, Out](r, op.Action) if m == nil { - return nil, NewError(INVALID_ARGUMENT, "core.CheckOperation: failed to resolve background model %q from original request", op.Action) + return nil, status.Errorf(status.ErrInvalidArgument, "core.CheckOperation: failed to resolve background model %q from original request", op.Action) } return m.Check(ctx, op) diff --git a/go/core/bidi.go b/go/core/bidi.go index fb7e753cf7..c7348cf276 100644 --- a/go/core/bidi.go +++ b/go/core/bidi.go @@ -228,7 +228,7 @@ func (b *BidiAction[In, Out, Stream, Init]) RunBidiJSON(ctx context.Context, inp // schema). Deferring input past startup is a streaming session // capability; see ConnectJSON. if !base.HasJSONValue(input) { - return nil, NewError(INVALID_ARGUMENT, "action %q requires input for a one-shot run; open a streaming session to defer input", b.desc.Key) + return nil, status.Errorf(status.ErrInvalidArgument, "action %q requires input for a one-shot run; open a streaming session to defer input", b.desc.Key) } init, hasInit, err := b.decodeInit(opts) if err != nil { @@ -270,13 +270,13 @@ func (b *BidiAction[In, Out, Stream, Init]) ConnectJSON(ctx context.Context, opt } inputSchema, err := ResolveSchema(b.registry, b.desc.InputSchema) if err != nil { - return nil, NewError(INVALID_ARGUMENT, "invalid input schema for action %q: %v", b.desc.Key, err) + return nil, status.Errorf(status.ErrInvalidSchema, "invalid input schema for action %q: %v", b.desc.Key, err) } // Compiled once per session: Send validates every inbound chunk, and // recompiling the schema per chunk would dominate the streaming hot path. compiledInput, err := base.CompileSchema(inputSchema) if err != nil { - return nil, NewError(INVALID_ARGUMENT, "invalid input schema for action %q: %v", b.desc.Key, err) + return nil, status.Errorf(status.ErrInvalidSchema, "invalid input schema for action %q: %v", b.desc.Key, err) } // Like RunBidiJSON, record init on the span only when the client actually // supplied one; the zero value from an absent init is not meaningful. @@ -315,11 +315,11 @@ func (b *BidiAction[In, Out, Stream, Init]) decodeInit(opts *api.BidiJSONOptions } schema, err := ResolveSchema(b.registry, b.desc.InitSchema) if err != nil { - return init, false, NewError(INVALID_ARGUMENT, "invalid init schema for action %q: %v", b.desc.Key, err) + return init, false, status.Errorf(status.ErrInvalidSchema, "invalid init schema for action %q: %v", b.desc.Key, err) } init, err = base.UnmarshalAndNormalize[Init](opts.Init, schema) if err != nil { - return init, false, NewError(INVALID_ARGUMENT, "invalid init for action %q: %v", b.desc.Key, err) + return init, false, status.Errorf(status.ErrInvalidInput, "invalid init for action %q: %v", b.desc.Key, err) } return init, true, nil } @@ -342,10 +342,10 @@ func (b *BidiAction[In, Out, Stream, Init]) validateInit(init Init) error { } schema, err := ResolveSchema(b.registry, b.desc.InitSchema) if err != nil { - return NewError(INVALID_ARGUMENT, "invalid init schema for action %q: %v", b.desc.Key, err) + return status.Errorf(status.ErrInvalidSchema, "invalid init schema for action %q: %v", b.desc.Key, err) } if err := base.ValidateValue(init, schema); err != nil { - return NewError(INVALID_ARGUMENT, "invalid init for action %q: %v", b.desc.Key, err) + return status.Errorf(status.ErrInvalidInput, "invalid init for action %q: %v", b.desc.Key, err) } return nil } @@ -415,7 +415,7 @@ func callBidiFn[In, Out, Stream, Init any]( ) (out Out, err error) { defer func() { if r := recover(); r != nil { - err = NewError(INTERNAL, "panic in bidi action %q: %v", name, r) + err = status.Errorf(status.ErrPanic, "panic in bidi action %q: %v", name, r) } }() return fn(ctx, init, inCh, outCh) @@ -486,12 +486,12 @@ func (c *BidiConnection[In, Out, Stream]) run(name string, fn func(context.Conte if closingStream { // The close below panicked: the action closed the output // channel itself, which the framework owns. - c.err = NewError(INTERNAL, "bidi action %q closed its output channel; the framework owns closing it", name) + c.err = status.Errorf(status.ErrInternal, "bidi action %q closed its output channel; the framework owns closing it", name) } else { // A panic escaped fn's own wrapping (span, schema // resolution, metrics); report it as what it is rather // than misattributing it to the channel close. - c.err = NewError(INTERNAL, "panic in bidi session %q: %v", name, r) + c.err = status.Errorf(status.ErrPanic, "panic in bidi session %q: %v", name, r) } } c.mu.Unlock() @@ -543,7 +543,7 @@ func (c *BidiConnection[In, Out, Stream]) Send(input In) (err error) { // "connection is closed" error a pre-checked Send would return. defer func() { if r := recover(); r != nil { - err = NewError(FAILED_PRECONDITION, "%v", ErrConnectionClosed) + err = status.Errorf(ErrConnectionClosed, "connection is closed") } }() @@ -555,7 +555,7 @@ func (c *BidiConnection[In, Out, Stream]) Send(input In) (err error) { // cancellation. select { case <-c.doneCh: - return NewError(FAILED_PRECONDITION, "%v", ErrActionCompleted) + return status.Errorf(ErrActionCompleted, "action has completed") default: } select { @@ -570,7 +570,7 @@ func (c *BidiConnection[In, Out, Stream]) Send(input In) (err error) { case <-c.ctx.Done(): return c.ctxErr() case <-c.doneCh: - return NewError(FAILED_PRECONDITION, "%v", ErrActionCompleted) + return status.Errorf(ErrActionCompleted, "action has completed") } } @@ -695,7 +695,7 @@ func (b *bidiJSONConn[In, Out, Stream]) Send(chunk json.RawMessage) error { // the one-shot path, where invalid input fails the call): the error // poisons the connection as its cancel cause so Output reports it, // and is also returned for the transport to log or relay. - err = NewError(INVALID_ARGUMENT, "invalid stream chunk for action %q: %v", b.key, err) + err = status.Errorf(status.ErrInvalidArgument, "invalid stream chunk for action %q: %v", b.key, err) b.conn.cancel(err) return err } diff --git a/go/core/doc.go b/go/core/doc.go index aca636df1e..e0d201ac61 100644 --- a/go/core/doc.go +++ b/go/core/doc.go @@ -200,7 +200,7 @@ with an operation ID that can be polled for completion: Return user-facing errors with appropriate status codes: if err := validate(input); err != nil { - return nil, core.NewPublicError(core.INVALID_ARGUMENT, "Invalid input", map[string]any{ + return nil, status.PublicErrorf(status.ErrInvalidArgument, "Invalid input").WithDetails(map[string]any{ "field": "email", "error": err.Error(), }) @@ -208,7 +208,7 @@ Return user-facing errors with appropriate status codes: For internal errors that should be logged but not exposed to users: - return nil, core.NewError(core.INTERNAL, "database connection failed: %v", err) + return nil, status.Errorf(status.ErrInternal, "database connection failed: %w", err) # Context diff --git a/go/genkit/reflection.go b/go/genkit/reflection.go index cd23488b3a..b8df0b3665 100644 --- a/go/genkit/reflection.go +++ b/go/genkit/reflection.go @@ -35,6 +35,7 @@ import ( "github.com/firebase/genkit/go/core" "github.com/firebase/genkit/go/core/api" "github.com/firebase/genkit/go/core/logger" + "github.com/firebase/genkit/go/core/status" "github.com/firebase/genkit/go/core/tracing" "github.com/firebase/genkit/go/internal" "github.com/firebase/genkit/go/internal/base" @@ -356,7 +357,7 @@ func handleRunAction(g *Genkit, activeActions *activeActionsMap) func(w http.Res } defer r.Body.Close() if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - return core.NewError(core.INVALID_ARGUMENT, "%s", err) + return status.Errorf(status.ErrInvalidArgument, "%w", err) } stream, err := parseBoolQueryParam(r, "stream") @@ -543,11 +544,11 @@ func handleCancelAction(activeActions *activeActionsMap) func(w http.ResponseWri defer r.Body.Close() if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - return core.NewError(core.INVALID_ARGUMENT, "%s", err) + return status.Errorf(status.ErrInvalidArgument, "%w", err) } if body.TraceID == "" { - return core.NewError(core.INVALID_ARGUMENT, "traceId is required") + return status.Errorf(status.ErrInvalidArgument, "traceId is required") } action, exists := activeActions.Get(body.TraceID) @@ -596,7 +597,7 @@ func handleNotify() func(w http.ResponseWriter, r *http.Request) error { defer r.Body.Close() if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - return core.NewError(core.INVALID_ARGUMENT, "%s", err) + return status.Errorf(status.ErrInvalidArgument, "%w", err) } configureTelemetry(body.TelemetryServerURL) @@ -630,7 +631,7 @@ func handleListValues(g *Genkit) func(w http.ResponseWriter, r *http.Request) er return func(w http.ResponseWriter, r *http.Request) error { valueType := r.URL.Query().Get("type") if valueType == "" { - return core.NewError(core.INVALID_ARGUMENT, `query parameter "type" is required`) + return status.Errorf(status.ErrInvalidArgument, `query parameter "type" is required`) } prefix := "/" + valueType + "/" result := map[string]any{} @@ -724,7 +725,7 @@ type errorResponse struct { func runAction(ctx context.Context, g *Genkit, key string, input, init json.RawMessage, telemetryLabels json.RawMessage, cb streamingCallback[json.RawMessage], runtimeContext map[string]any) (*runActionResponse, error) { action := g.reg.ResolveAction(key) if action == nil { - return nil, core.NewError(core.NOT_FOUND, "action %q not found", key) + return nil, status.Errorf(status.ErrActionNotFound, "action %q not found", key) } ctx = core.WithActionContext(ctx, runtimeContext) @@ -733,7 +734,7 @@ func runAction(ctx context.Context, g *Genkit, key string, input, init json.RawM var telemetryAttributes map[string]string err := json.Unmarshal(telemetryLabels, &telemetryAttributes) if err != nil { - return nil, core.NewError(core.INVALID_ARGUMENT, "Error unmarshalling telemetryLabels: %v", err) + return nil, status.Errorf(status.ErrInvalidArgument, "Error unmarshalling telemetryLabels: %w", err) } ctx = tracing.WithTelemetryLabels(ctx, telemetryAttributes) } @@ -770,7 +771,7 @@ func runAction(ctx context.Context, g *Genkit, key string, input, init json.RawM func checkInitSupported(a api.Action, init json.RawMessage) error { if base.HasJSONValue(init) { if _, ok := a.(api.BidiAction); !ok { - return core.NewError(core.INVALID_ARGUMENT, "action %q does not accept init", a.Name()) + return status.Errorf(status.ErrInvalidArgument, "action %q does not accept init", a.Name()) } } return nil diff --git a/go/genkit/servers.go b/go/genkit/servers.go index cb6b7acc05..844fabe384 100644 --- a/go/genkit/servers.go +++ b/go/genkit/servers.go @@ -28,11 +28,13 @@ import ( "strings" "sync/atomic" + "github.com/google/uuid" + "github.com/firebase/genkit/go/core" "github.com/firebase/genkit/go/core/api" "github.com/firebase/genkit/go/core/logger" + "github.com/firebase/genkit/go/core/status" "github.com/firebase/genkit/go/core/x/streaming" - "github.com/google/uuid" ) // HandlerOption configures a Handler. @@ -171,7 +173,7 @@ func handler(a api.Action, opts *handlerOptions) func(http.ResponseWriter, *http if r.Body != nil && r.ContentLength > 0 { defer r.Body.Close() if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - return core.NewPublicError(core.INVALID_ARGUMENT, err.Error(), nil) + return status.PublicErrorf(status.ErrInvalidArgument, "%w", err) } } @@ -490,7 +492,7 @@ func parseBoolQueryParam(r *http.Request, name string) (bool, error) { var err error b, err = strconv.ParseBool(s) if err != nil { - return false, core.NewPublicError(core.INVALID_ARGUMENT, err.Error(), nil) + return false, status.PublicErrorf(status.ErrInvalidArgument, "%w", err) } } return b, nil diff --git a/go/plugins/firebase/auth.go b/go/plugins/firebase/auth.go index 9280973232..0aad8534d5 100644 --- a/go/plugins/firebase/auth.go +++ b/go/plugins/firebase/auth.go @@ -19,11 +19,12 @@ package firebase import ( "context" "encoding/json" - "fmt" "strings" "firebase.google.com/go/v4/auth" + "github.com/firebase/genkit/go/core" + "github.com/firebase/genkit/go/core/status" "github.com/firebase/genkit/go/genkit" ) @@ -52,19 +53,19 @@ func ContextProvider(ctx context.Context, g *genkit.Genkit, policy AuthPolicy) ( return func(ctx context.Context, input core.RequestData) (core.ActionContext, error) { authHeader, ok := input.Headers["authorization"] if !ok { - return nil, core.NewPublicError(core.UNAUTHENTICATED, "authorization header is required but not provided", nil) + return nil, status.PublicErrorf(status.ErrUnauthenticated, "authorization header is required but not provided") } const bearerPrefix = "bearer " if !strings.HasPrefix(strings.ToLower(authHeader), bearerPrefix) { - return nil, core.NewPublicError(core.UNAUTHENTICATED, "invalid authorization header format", nil) + return nil, status.PublicErrorf(status.ErrUnauthenticated, "invalid authorization header format") } token := authHeader[len(bearerPrefix):] authCtx, err := client.VerifyIDToken(ctx, token) if err != nil { - return nil, core.NewPublicError(core.UNAUTHENTICATED, fmt.Sprintf("error verifying ID token: %v", err), nil) + return nil, status.PublicErrorf(status.ErrUnauthenticated, "error verifying ID token: %w", err) } if policy != nil { diff --git a/go/plugins/firebase/exp/firestore_session_store.go b/go/plugins/firebase/exp/firestore_session_store.go index 3218a3da11..1e81831d73 100644 --- a/go/plugins/firebase/exp/firestore_session_store.go +++ b/go/plugins/firebase/exp/firestore_session_store.go @@ -42,11 +42,12 @@ import ( "time" "cloud.google.com/go/firestore" + "github.com/google/uuid" + aix "github.com/firebase/genkit/go/ai/exp" - "github.com/firebase/genkit/go/core" + "github.com/firebase/genkit/go/core/status" "github.com/firebase/genkit/go/genkit" "github.com/firebase/genkit/go/plugins/firebase" - "github.com/google/uuid" ) // Document "kind" values for a snapshot document. @@ -192,7 +193,7 @@ type snapshotDoc struct { Status string `firestore:"status,omitempty"` HeartbeatAt *time.Time `firestore:"heartbeatAt,omitempty"` FinishReason string `firestore:"finishReason,omitempty"` - // Error is a JSON-encoded *core.GenkitError, or nil. + // Error is a JSON-encoded *status.Error, or nil. Error []byte `firestore:"error,omitempty"` // Kind is "diff" or "checkpoint". Kind string `firestore:"kind"` @@ -575,7 +576,7 @@ func (s *FirestoreSessionStore[State]) SaveSnapshot( // per-session pointer by session ID and cannot persist a session-less // row. The runtime stamps a session ID on every row it writes, so an // empty one indicates misuse. Matches the other session stores. - return core.NewError(core.INVALID_ARGUMENT, "FirestoreSessionStore requires sessionId to be set on the snapshot") + return status.Errorf(aix.ErrSessionIDRequired, "FirestoreSessionStore requires sessionId to be set on the snapshot") } next.SessionID = sessionID if next.Status == "" { @@ -913,7 +914,7 @@ func (s *FirestoreSessionStore[State]) toSnapshot(doc snapshotDoc, stateAny any) State: state, } if len(doc.Error) > 0 { - var ge core.GenkitError + var ge status.Error if err := json.Unmarshal(doc.Error, &ge); err != nil { return nil, fmt.Errorf("unmarshal error: %w", err) } diff --git a/go/plugins/firebase/exp/stream_manager.go b/go/plugins/firebase/exp/stream_manager.go index 6ec5d2584d..39c72b4217 100644 --- a/go/plugins/firebase/exp/stream_manager.go +++ b/go/plugins/firebase/exp/stream_manager.go @@ -25,12 +25,14 @@ import ( "time" "cloud.google.com/go/firestore" + "github.com/google/uuid" + "google.golang.org/grpc/codes" + grpcstatus "google.golang.org/grpc/status" + "github.com/firebase/genkit/go/core" + "github.com/firebase/genkit/go/core/status" "github.com/firebase/genkit/go/core/x/streaming" "github.com/firebase/genkit/go/genkit" - "github.com/google/uuid" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" ) const ( @@ -126,8 +128,8 @@ func (m *FirestoreStreamManager) Open(ctx context.Context, streamID string) (str ExpiresAt: &expiresAt, }) if err != nil { - if status.Code(err) == codes.AlreadyExists { - return nil, core.NewPublicError(core.ALREADY_EXISTS, "stream already exists", nil) + if grpcstatus.Code(err) == codes.AlreadyExists { + return nil, status.PublicErrorf(status.ErrAlreadyExists, "stream already exists") } return nil, err } @@ -145,12 +147,12 @@ func (m *FirestoreStreamManager) Subscribe(ctx context.Context, streamID string) snapshot, err := docRef.Get(ctx) if err != nil { if isNotFound(err) { - return nil, nil, core.NewPublicError(core.NOT_FOUND, "stream not found", nil) + return nil, nil, status.PublicErrorf(status.ErrNotFound, "stream not found") } return nil, nil, err } if !snapshot.Exists() { - return nil, nil, core.NewPublicError(core.NOT_FOUND, "stream not found", nil) + return nil, nil, status.PublicErrorf(status.ErrNotFound, "stream not found") } ch := make(chan streaming.StreamEvent, streamBufferSize) @@ -175,7 +177,7 @@ func (m *FirestoreStreamManager) Subscribe(ctx context.Context, streamID string) unsubscribed = true ch <- streaming.StreamEvent{ Type: streaming.StreamEventError, - Err: core.NewPublicError(core.DEADLINE_EXCEEDED, "stream timed out", nil), + Err: status.PublicErrorf(status.ErrDeadlineExceeded, "stream timed out"), } close(ch) cancelSnapshot() @@ -274,18 +276,18 @@ func (m *FirestoreStreamManager) Subscribe(ctx context.Context, streamID string) return case streamEventError: if !unsubscribed { - var errStatus core.StatusName = core.UNKNOWN + errStatus := status.Unknown var errMsg string if entry.Err != nil { errMsg = entry.Err.Message if entry.Err.Status != "" { - errStatus = core.StatusName(entry.Err.Status) + errStatus = status.Name(entry.Err.Status) } } select { case ch <- streaming.StreamEvent{ Type: streaming.StreamEventError, - Err: core.NewPublicError(errStatus, errMsg, nil), + Err: status.PublicErrorf(status.Base(errStatus), "%s", errMsg), }: default: } @@ -312,7 +314,7 @@ func isNotFound(err error) bool { if err == nil { return false } - if grpcErr, ok := status.FromError(err); ok { + if grpcErr, ok := grpcstatus.FromError(err); ok { return grpcErr.Code() == codes.NotFound } return false @@ -332,7 +334,7 @@ func (s *firestoreStreamInput) Write(ctx context.Context, chunk json.RawMessage) defer s.mu.Unlock() if s.closed { - return core.NewPublicError(core.FAILED_PRECONDITION, "stream writer is closed", nil) + return status.PublicErrorf(status.ErrFailedPrecondition, "stream writer is closed") } _, err := s.docRef.Update(ctx, []firestore.Update{ @@ -357,7 +359,7 @@ func (s *firestoreStreamInput) Done(ctx context.Context, output json.RawMessage) defer s.mu.Unlock() if s.closed { - return core.NewPublicError(core.FAILED_PRECONDITION, "stream writer is closed", nil) + return status.PublicErrorf(status.ErrFailedPrecondition, "stream writer is closed") } s.closed = true @@ -387,21 +389,20 @@ func (s *firestoreStreamInput) Error(ctx context.Context, err error) error { defer s.mu.Unlock() if s.closed { - return core.NewPublicError(core.FAILED_PRECONDITION, "stream writer is closed", nil) + return status.PublicErrorf(status.ErrFailedPrecondition, "stream writer is closed") } s.closed = true streamErr := &streamError{ - Status: string(core.UNKNOWN), + Status: string(status.Of(err)), Message: err.Error(), } + // A status.Error stringifies as its bare message, but the deprecated + // core.UserFacingError stringifies as "STATUS: message" while the status is + // carried separately here. Keep the bare message so the Subscribe path, which + // rebuilds the error from status + message, does not double-prefix it. var ufErr *core.UserFacingError if errors.As(err, &ufErr) { - streamErr.Status = string(ufErr.Status) - // Store the bare message, not err.Error(): a UserFacingError stringifies - // as "STATUS: message", and the status is already carried separately. This - // keeps the persisted message clean and prevents the Subscribe path (which - // rebuilds the error from status + message) from double-prefixing it. streamErr.Message = ufErr.Message } diff --git a/go/plugins/googlegenai/errors.go b/go/plugins/googlegenai/errors.go index b086fb3d03..7e353b70f3 100644 --- a/go/plugins/googlegenai/errors.go +++ b/go/plugins/googlegenai/errors.go @@ -19,16 +19,17 @@ package googlegenai import ( "errors" - "github.com/firebase/genkit/go/core" "google.golang.org/genai" + + "github.com/firebase/genkit/go/core/status" ) -// wrapAPIError wraps a [genai.APIError] in a [core.GenkitError] whose status +// wrapAPIError wraps a [genai.APIError] in a [status.Error] whose status // matches the one the server reported so status-aware middleware (retry, // fallback, ...) can reason about it. Non-APIError values pass through. // // The SDK's Status string is a canonical Google / gRPC status name and so -// matches the string value of each [core.StatusName] constant directly. +// matches the string value of each [status.Name] constant directly. // When Status is missing or unrecognised the HTTP Code is the fallback. func wrapAPIError(err error) error { if err == nil { @@ -38,13 +39,12 @@ func wrapAPIError(err error) error { if !errors.As(err, &apiErr) { return err } - return core.NewError(statusForAPIError(apiErr), "%s", err) + return status.Errorf(status.Base(statusForAPIError(apiErr)), "%w", err) } -func statusForAPIError(e genai.APIError) core.StatusName { - s := core.StatusName(e.Status) - if _, ok := core.StatusNameToCode[s]; ok { - return s +func statusForAPIError(e genai.APIError) status.Name { + if n := status.Name(e.Status); n.IsValid() { + return n } - return core.StatusFromHTTPCode(e.Code) + return status.FromHTTPCode(e.Code) } diff --git a/go/plugins/googlegenai/gemini.go b/go/plugins/googlegenai/gemini.go index b72ea71bf1..29038a33ae 100644 --- a/go/plugins/googlegenai/gemini.go +++ b/go/plugins/googlegenai/gemini.go @@ -26,14 +26,16 @@ import ( "slices" "strings" + "github.com/invopop/jsonschema" + "google.golang.org/genai" + "github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/core" "github.com/firebase/genkit/go/core/api" + "github.com/firebase/genkit/go/core/status" "github.com/firebase/genkit/go/internal" "github.com/firebase/genkit/go/internal/base" "github.com/firebase/genkit/go/plugins/internal/uri" - "github.com/invopop/jsonschema" - "google.golang.org/genai" ) var ( @@ -76,12 +78,12 @@ func configFromRequest(input *ai.ModelRequest) (*genai.GenerateContentConfig, er var err error result, err = base.MapToStruct[genai.GenerateContentConfig](config) if err != nil { - return nil, core.NewPublicError(core.INVALID_ARGUMENT, fmt.Sprintf("The configuration settings are not in the correct format. Check that the names and values match what the model expects: %v", err), nil) + return nil, status.PublicErrorf(status.ErrInvalidArgument, "The configuration settings are not in the correct format. Check that the names and values match what the model expects: %w", err) } case nil: // Empty but valid config default: - return nil, core.NewPublicError(core.INVALID_ARGUMENT, fmt.Sprintf("Invalid configuration type: %T. Expected *genai.GenerateContentConfig. Ensure you are using the correct ModelRef helper (e.g., ModelRef) or passing the correct configuration struct.", input.Config), nil) + return nil, status.PublicErrorf(status.ErrInvalidArgument, "Invalid configuration type: %T. Expected *genai.GenerateContentConfig. Ensure you are using the correct ModelRef helper (e.g., ModelRef) or passing the correct configuration struct.", input.Config) } return &result, nil diff --git a/go/plugins/googlegenai/imagen.go b/go/plugins/googlegenai/imagen.go index 5ecda8e6ca..84fcaa6f74 100644 --- a/go/plugins/googlegenai/imagen.go +++ b/go/plugins/googlegenai/imagen.go @@ -21,10 +21,11 @@ import ( "encoding/base64" "fmt" + "google.golang.org/genai" + "github.com/firebase/genkit/go/ai" - "github.com/firebase/genkit/go/core" + "github.com/firebase/genkit/go/core/status" "github.com/firebase/genkit/go/internal/base" - "google.golang.org/genai" ) // imagenConfigFromRequest translates an [*ai.ModelRequest] configuration to [*genai.GenerateImagesConfig] @@ -40,12 +41,12 @@ func imagenConfigFromRequest(input *ai.ModelRequest) (*genai.GenerateImagesConfi var err error result, err = base.MapToStruct[genai.GenerateImagesConfig](config) if err != nil { - return nil, core.NewPublicError(core.INVALID_ARGUMENT, fmt.Sprintf("The image configuration settings are not in the correct format. Check that the names and values match what the model expects: %v", err), nil) + return nil, status.PublicErrorf(status.ErrInvalidArgument, "The image configuration settings are not in the correct format. Check that the names and values match what the model expects: %w", err) } case nil: // empty but valid config default: - return nil, core.NewPublicError(core.INVALID_ARGUMENT, fmt.Sprintf("Invalid configuration type: %T. Expected *genai.GenerateImagesConfig. Ensure you are using the correct ModelRef helper (e.g., ImageModelRef) or passing the correct configuration struct.", input.Config), nil) + return nil, status.PublicErrorf(status.ErrInvalidArgument, "Invalid configuration type: %T. Expected *genai.GenerateImagesConfig. Ensure you are using the correct ModelRef helper (e.g., ImageModelRef) or passing the correct configuration struct.", input.Config) } return &result, nil diff --git a/go/plugins/googlegenai/veo.go b/go/plugins/googlegenai/veo.go index b394ebe404..afb321bec1 100644 --- a/go/plugins/googlegenai/veo.go +++ b/go/plugins/googlegenai/veo.go @@ -23,12 +23,14 @@ import ( "strings" "time" + "google.golang.org/genai" + "github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/core" "github.com/firebase/genkit/go/core/api" + "github.com/firebase/genkit/go/core/status" "github.com/firebase/genkit/go/internal/base" "github.com/firebase/genkit/go/plugins/internal/uri" - "google.golang.org/genai" ) // defineVeoModels defines a new Veo background model for video generation. @@ -216,11 +218,11 @@ func toVeoParameters(request *ai.ModelRequest) (*genai.GenerateVideosConfig, err var err error result, err = base.MapToStruct[genai.GenerateVideosConfig](config) if err != nil { - return nil, core.NewPublicError(core.INVALID_ARGUMENT, fmt.Sprintf("The video configuration settings are not in the correct format. Check that the names and values match what the model expects: %v", err), nil) + return nil, status.PublicErrorf(status.ErrInvalidArgument, "The video configuration settings are not in the correct format. Check that the names and values match what the model expects: %w", err) } return &result, nil default: - return nil, core.NewPublicError(core.INVALID_ARGUMENT, fmt.Sprintf("The configuration type %T is not supported. Use the correct configuration for this model (like VideoModelRef) or a configuration struct.", request.Config), nil) + return nil, status.PublicErrorf(status.ErrInvalidArgument, "The configuration type %T is not supported. Use the correct configuration for this model (like VideoModelRef) or a configuration struct.", request.Config) } } diff --git a/go/plugins/middleware/fallback.go b/go/plugins/middleware/fallback.go index a22c4bfc07..0d32dad997 100644 --- a/go/plugins/middleware/fallback.go +++ b/go/plugins/middleware/fallback.go @@ -18,23 +18,22 @@ package middleware import ( "context" - "errors" "slices" "github.com/firebase/genkit/go/ai" - "github.com/firebase/genkit/go/core" + "github.com/firebase/genkit/go/core/status" "github.com/firebase/genkit/go/genkit" ) // defaultFallbackStatuses are the status codes that trigger a fallback by default. -var defaultFallbackStatuses = []core.StatusName{ - core.UNAVAILABLE, - core.DEADLINE_EXCEEDED, - core.RESOURCE_EXHAUSTED, - core.ABORTED, - core.INTERNAL, - core.NOT_FOUND, - core.UNIMPLEMENTED, +var defaultFallbackStatuses = []status.Name{ + status.Unavailable, + status.DeadlineExceeded, + status.ResourceExhausted, + status.Aborted, + status.Internal, + status.NotFound, + status.Unimplemented, } // Fallback is a middleware that tries alternative models when the primary model @@ -63,10 +62,10 @@ type Fallback struct { // Config is not inherited. Use [ai.NewModelRef] to attach config. Models []ai.ModelRef `json:"models,omitempty"` // Statuses is the set of status codes that trigger a fallback. - // Only [core.GenkitError] errors with a matching status will trigger fallback; + // An error's status comes from [status.Of], so unclassified errors count // non-GenkitError errors propagate immediately. // Defaults to [defaultFallbackStatuses]. - Statuses []core.StatusName `json:"statuses,omitempty"` + Statuses []status.Name `json:"statuses,omitempty"` } func (f *Fallback) Name() string { return provider + "/fallback" } @@ -77,7 +76,7 @@ func (f *Fallback) New(ctx context.Context) (*ai.Hooks, error) { }, nil } -func (f *Fallback) statuses() []core.StatusName { +func (f *Fallback) statuses() []status.Name { if len(f.Statuses) > 0 { return f.Statuses } @@ -99,7 +98,7 @@ func (f *Fallback) wrapModel(ctx context.Context, params *ai.ModelParams, next a name := ref.Name() m := genkit.LookupModel(genkit.FromContext(ctx), name) if m == nil { - return nil, core.NewError(core.NOT_FOUND, "fallback: model %q not found", name) + return nil, status.Errorf(status.ErrNotFound, "fallback: model %q not found", name) } req := *params.Request req.Config = ref.Config() @@ -115,12 +114,10 @@ func (f *Fallback) wrapModel(ctx context.Context, params *ai.ModelParams, next a return nil, lastErr } -// isFallbackRetryable reports whether err should trigger trying the next model. -// Only GenkitErrors with a matching status trigger fallback. -func isFallbackRetryable(err error, statuses []core.StatusName) bool { - var ge *core.GenkitError - if !errors.As(err, &ge) { - return false - } - return slices.Contains(statuses, ge.Status) +// isFallbackRetryable reports whether err should trigger trying the next model: +// its status must be in statuses. Unclassified errors report INTERNAL, which is +// in the default set, so a network failure now falls back the same way it +// retries; previously only a *core.GenkitError could trigger fallback at all. +func isFallbackRetryable(err error, statuses []status.Name) bool { + return slices.Contains(statuses, status.Of(err)) } diff --git a/go/plugins/middleware/fallback_test.go b/go/plugins/middleware/fallback_test.go index 27c8b7b949..19a27bbbd7 100644 --- a/go/plugins/middleware/fallback_test.go +++ b/go/plugins/middleware/fallback_test.go @@ -175,7 +175,11 @@ func TestFallbackDoesNotTriggerOnNonRetryableError(t *testing.T) { } } -func TestFallbackDoesNotTriggerOnNonGenkitError(t *testing.T) { +// An unclassified error reports INTERNAL, which is in the default set, so it +// triggers fallback. Retry has always treated such errors as retryable; before +// status.Of, fallback ignored them, so the two middlewares disagreed about the +// same failure. +func TestFallbackTriggersOnUnclassifiedError(t *testing.T) { g := newTestGenkit(t) secondaryCalls := 0 @@ -189,12 +193,39 @@ func TestFallbackDoesNotTriggerOnNonGenkitError(t *testing.T) { fb := &Fallback{Models: []ai.ModelRef{ai.NewModelRef(secondary.Name(), nil)}} - _, err := genkit.Generate(ctx, g, ai.WithModel(primary), ai.WithPrompt("hello"), ai.WithUse(fb)) - if err == nil { + resp, err := genkit.Generate(ctx, g, ai.WithModel(primary), ai.WithPrompt("hello"), ai.WithUse(fb)) + if err != nil { + t.Fatalf("Generate() = %v, want fallback to succeed", err) + } + if got := resp.Text(); got != "secondary" { + t.Errorf("text = %q, want %q", got, "secondary") + } + if secondaryCalls != 1 { + t.Errorf("secondary called %d times, want 1", secondaryCalls) + } +} + +// A cancelled context reports CANCELLED, which is not in the default set, so it +// must not burn through the fallback chain. +func TestFallbackDoesNotTriggerOnCancelledContext(t *testing.T) { + g := newTestGenkit(t) + secondaryCalls := 0 + + primary := defineTestModel(t, g, "test/primary", func(ctx context.Context, req *ai.ModelRequest, cb ai.ModelStreamCallback) (*ai.ModelResponse, error) { + return nil, context.Canceled + }) + secondary := defineTestModel(t, g, "test/secondary", func(ctx context.Context, req *ai.ModelRequest, cb ai.ModelStreamCallback) (*ai.ModelResponse, error) { + secondaryCalls++ + return &ai.ModelResponse{Message: ai.NewModelTextMessage("secondary")}, nil + }) + + fb := &Fallback{Models: []ai.ModelRef{ai.NewModelRef(secondary.Name(), nil)}} + + if _, err := genkit.Generate(ctx, g, ai.WithModel(primary), ai.WithPrompt("hello"), ai.WithUse(fb)); err == nil { t.Fatal("expected error, got nil") } if secondaryCalls != 0 { - t.Errorf("secondary called %d times, want 0 (non-GenkitError)", secondaryCalls) + t.Errorf("secondary called %d times, want 0 (cancelled)", secondaryCalls) } } diff --git a/go/plugins/middleware/filesystem.go b/go/plugins/middleware/filesystem.go index c8623066ee..b60aa07728 100644 --- a/go/plugins/middleware/filesystem.go +++ b/go/plugins/middleware/filesystem.go @@ -32,7 +32,7 @@ import ( "time" "github.com/firebase/genkit/go/ai" - "github.com/firebase/genkit/go/core" + "github.com/firebase/genkit/go/core/status" ) // readMaxBytes caps a single full read or returned slice. Models can step @@ -169,15 +169,15 @@ func (f *Filesystem) Name() string { return provider + "/filesystem" } // model on the next turn. func (f *Filesystem) New(ctx context.Context) (*ai.Hooks, error) { if strings.TrimSpace(f.RootDir) == "" { - return nil, core.NewError(core.INVALID_ARGUMENT, "filesystem middleware: RootDir is required") + return nil, status.Errorf(status.ErrInvalidArgument, "filesystem middleware: RootDir is required") } abs, err := filepath.Abs(f.RootDir) if err != nil { - return nil, core.NewError(core.INTERNAL, "filesystem middleware: resolve %q: %v", f.RootDir, err) + return nil, status.Errorf(status.ErrInternal, "filesystem middleware: resolve %q: %w", f.RootDir, err) } root, err := os.OpenRoot(abs) if err != nil { - return nil, core.NewError(core.FAILED_PRECONDITION, "filesystem middleware: open root %q: %v", abs, err) + return nil, status.Errorf(status.ErrFailedPrecondition, "filesystem middleware: open root %q: %w", abs, err) } var ( diff --git a/go/plugins/middleware/retry.go b/go/plugins/middleware/retry.go index 9080c4b6b3..b0f2eba35d 100644 --- a/go/plugins/middleware/retry.go +++ b/go/plugins/middleware/retry.go @@ -20,23 +20,22 @@ package middleware import ( "context" - "errors" "math" "math/rand" "slices" "time" "github.com/firebase/genkit/go/ai" - "github.com/firebase/genkit/go/core" + "github.com/firebase/genkit/go/core/status" ) // defaultRetryStatuses are the status codes that trigger a retry by default. -var defaultRetryStatuses = []core.StatusName{ - core.UNAVAILABLE, - core.DEADLINE_EXCEEDED, - core.RESOURCE_EXHAUSTED, - core.ABORTED, - core.INTERNAL, +var defaultRetryStatuses = []status.Name{ + status.Unavailable, + status.DeadlineExceeded, + status.ResourceExhausted, + status.Aborted, + status.Internal, } // sleepFunc is the function used for delays. It blocks for d or until ctx is @@ -57,9 +56,11 @@ var sleepFunc = func(ctx context.Context, d time.Duration) error { // It only hooks the Model stage — individual model API calls are retried, // not the entire generate loop. // -// By default, retries occur for non-[core.GenkitError] errors (e.g. network failures) -// and for [core.GenkitError] errors whose status is one of UNAVAILABLE, DEADLINE_EXCEEDED, -// RESOURCE_EXHAUSTED, ABORTED, or INTERNAL. +// An error is retried when its status is in Statuses, which defaults to +// UNAVAILABLE, DEADLINE_EXCEEDED, RESOURCE_EXHAUSTED, ABORTED, and INTERNAL. +// An unclassified error reports INTERNAL (see [status.Of]), so network failures +// and the like are retried by default; a cancelled context reports CANCELLED and +// is not. // // Usage: // @@ -71,10 +72,10 @@ var sleepFunc = func(ctx context.Context, d time.Duration) error { type Retry struct { // MaxRetries is the maximum number of retry attempts. Defaults to 3. MaxRetries int `json:"maxRetries,omitempty"` - // Statuses is the set of status codes that trigger a retry for [core.GenkitError] errors. - // Non-GenkitError errors are always retried regardless of this setting. + // Statuses is the set of status codes that trigger a retry. An error's + // status comes from [status.Of], so unclassified errors count as INTERNAL. // Defaults to [defaultRetryStatuses]. - Statuses []core.StatusName `json:"statuses,omitempty"` + Statuses []status.Name `json:"statuses,omitempty"` // InitialDelayMs is the delay before the first retry, in milliseconds. Defaults to 1000. InitialDelayMs int `json:"initialDelayMs,omitempty"` // MaxDelayMs is the upper bound on retry delay, in milliseconds. Defaults to 60000. @@ -101,7 +102,7 @@ func (r *Retry) maxRetries() int { return 3 } -func (r *Retry) statuses() []core.StatusName { +func (r *Retry) statuses() []status.Name { if len(r.Statuses) > 0 { return r.Statuses } @@ -167,13 +168,9 @@ func (r *Retry) wrapModel(ctx context.Context, params *ai.ModelParams, next ai.M return nil, lastErr } -// isRetryable reports whether err should trigger a retry. -// Non-GenkitError errors are always retried. GenkitErrors are retried -// only if their status is in the provided list. -func isRetryable(err error, statuses []core.StatusName) bool { - var ge *core.GenkitError - if !errors.As(err, &ge) { - return true // unknown errors are retryable - } - return slices.Contains(statuses, ge.Status) +// isRetryable reports whether err should trigger a retry: its status must be +// in statuses. Unclassified errors report INTERNAL, which is in the default +// set, so they stay retryable unless a caller narrows Statuses. +func isRetryable(err error, statuses []status.Name) bool { + return slices.Contains(statuses, status.Of(err)) } From cb7893dbad08ed01874dbaaac54b6be201bb8826 Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Tue, 28 Jul 2026 09:34:31 -0700 Subject: [PATCH 04/14] fix(go): stop leaking internal failure detail at the flow HTTP boundary wrapHandler wrote err.Error() verbatim on both of its branches, so whatever an error happened to say reached the client: raw provider text (project and model resource paths, quota and permission detail), output-schema violations naming the action and the shape it failed to produce, and any unclassified error from user code or a third-party library. writeSSEError additionally put the whole err.Error() in a details field with no status gating at all. Route both through clientError, which takes the status from status.Of and the message from status.PublicMessage: a message only leaves the process when the error was built with PublicErrorf, and everything else becomes a generic string derived from the status. The full error is still logged. GENKIT_ENV=dev is exempt, since redacting there only hides the failure from the developer causing it, and the reflection API is untouched: the Dev UI keeps full messages and stacks. This also fixes public errors returning the wrong status code. NewPublicError returns a *core.UserFacingError, which is structurally unrelated to *core.GenkitError, so wrapHandler's errors.As could never match it and every deliberately-public error fell through to 500. Firebase auth's UNAUTHENTICATED errors, for instance, came back as 500 instead of 401. Errors that describe the shape of the caller's own request and name only public API concepts are marked public so the guidance still reaches them: the one-shot input requirement, init rejection, the agent state/session mismatches, and snapshot-not-found. Schema validation dumps stay internal. The SSE error payload loses its details field, which existed only to carry that text. reflection_v2 now takes its code from status.Of rather than hardcoding INTERNAL, and reads stacks from status.Error.Stack as well as Details. Adds regression tests for each confirmed leak path and a core compatibility suite covering errors.As for *core.GenkitError against errors raised as *status.Error, stdlib errors.Unwrap chain walking, and the v1 constructors' preserved behaviour. --- go/ai/exp/agent.go | 8 +- go/ai/exp/session.go | 4 +- go/core/bidi.go | 2 +- go/core/compat_test.go | 203 +++++++++++++++++++++++++++++++++ go/core/status/error.go | 22 +++- go/genkit/reflection.go | 2 +- go/genkit/reflection_v2.go | 20 +++- go/genkit/servers.go | 50 ++++---- go/genkit/servers_leak_test.go | 149 ++++++++++++++++++++++++ go/genkit/servers_test.go | 23 ++-- 10 files changed, 437 insertions(+), 46 deletions(-) create mode 100644 go/core/compat_test.go create mode 100644 go/genkit/servers_leak_test.go diff --git a/go/ai/exp/agent.go b/go/ai/exp/agent.go index aab076490c..e83fed61c6 100644 --- a/go/ai/exp/agent.go +++ b/go/ai/exp/agent.go @@ -1770,7 +1770,7 @@ func loadSession[State any]( switch { case init.State != nil: if store != nil { - return nil, nil, status.Errorf(status.ErrFailedPrecondition, + return nil, nil, status.PublicErrorf(status.ErrFailedPrecondition, "state provided but agent has a session store configured (server-managed state); use snapshot ID instead") } // Deep-copy at the entry boundary: an in-process caller retains @@ -1783,7 +1783,7 @@ func loadSession[State any]( case init.SnapshotID != "": if store == nil { - return nil, nil, status.Errorf(status.ErrFailedPrecondition, + return nil, nil, status.PublicErrorf(status.ErrFailedPrecondition, "snapshot ID %q provided but agent has no session store configured (client-managed state); use state instead", init.SnapshotID) } snap, err := store.GetSnapshot(ctx, init.SnapshotID) @@ -1791,7 +1791,7 @@ func loadSession[State any]( return nil, nil, status.Errorf(status.ErrInternal, "failed to load snapshot %q: %w", init.SnapshotID, err) } if snap == nil { - return nil, nil, status.Errorf(ErrSnapshotNotFound, "snapshot %q not found", init.SnapshotID) + return nil, nil, status.PublicErrorf(ErrSnapshotNotFound, "snapshot %q not found", init.SnapshotID) } // A session ID sent alongside the snapshot ID asserts which // conversation the snapshot belongs to; a mismatch means the @@ -1804,7 +1804,7 @@ func loadSession[State any]( case init.SessionID != "": if store == nil { - return nil, nil, status.Errorf(status.ErrFailedPrecondition, + return nil, nil, status.PublicErrorf(status.ErrFailedPrecondition, "session ID %q provided but agent has no session store configured (client-managed state); the conversation's identity rides inside the state object (SessionState.SessionID)", init.SessionID) } snap, err := store.GetLatestSnapshot(ctx, init.SessionID) diff --git a/go/ai/exp/session.go b/go/ai/exp/session.go index 547a30f357..a83dc2980b 100644 --- a/go/ai/exp/session.go +++ b/go/ai/exp/session.go @@ -226,7 +226,7 @@ func readSnapshot[State any]( return nil, status.Errorf(status.ErrInternal, "getSnapshot: %w", err) } if snap == nil { - return nil, status.Errorf(ErrSnapshotNotFound, "getSnapshot: snapshot %q not found", snapshotID) + return nil, status.PublicErrorf(ErrSnapshotNotFound, "getSnapshot: snapshot %q not found", snapshotID) } if sessionID != "" && snap.SessionID != sessionID { return nil, status.Errorf(status.ErrInvalidArgument, @@ -315,7 +315,7 @@ func newSnapshotActions[State any]( return nil, status.Errorf(status.ErrInternal, "abort: %w", err) } if snapStatus == "" { - return nil, status.Errorf(ErrSnapshotNotFound, "abort: snapshot %q not found", req.SnapshotID) + return nil, status.PublicErrorf(ErrSnapshotNotFound, "abort: snapshot %q not found", req.SnapshotID) } return &AgentAbortResponse{SnapshotID: req.SnapshotID, Status: snapStatus}, nil }) diff --git a/go/core/bidi.go b/go/core/bidi.go index c7348cf276..fa5629de73 100644 --- a/go/core/bidi.go +++ b/go/core/bidi.go @@ -228,7 +228,7 @@ func (b *BidiAction[In, Out, Stream, Init]) RunBidiJSON(ctx context.Context, inp // schema). Deferring input past startup is a streaming session // capability; see ConnectJSON. if !base.HasJSONValue(input) { - return nil, status.Errorf(status.ErrInvalidArgument, "action %q requires input for a one-shot run; open a streaming session to defer input", b.desc.Key) + return nil, status.PublicErrorf(status.ErrInvalidArgument, "action %q requires input for a one-shot run; open a streaming session to defer input", b.desc.Key) } init, hasInit, err := b.decodeInit(opts) if err != nil { diff --git a/go/core/compat_test.go b/go/core/compat_test.go new file mode 100644 index 0000000000..281bcd0815 --- /dev/null +++ b/go/core/compat_test.go @@ -0,0 +1,203 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package core_test + +import ( + "errors" + "fmt" + "net/http" + "testing" + + "github.com/firebase/genkit/go/core" + "github.com/firebase/genkit/go/core/status" +) + +// errModelNotFound stands in for a domain sentinel like ai.ErrModelNotFound, +// declared here so core's tests do not import ai. +var errModelNotFound = status.ErrNotFound.Subtype("model not found") + +// internalError is what a migrated internal call site now returns. +func internalError() error { + return status.Errorf(errModelNotFound, "model %q not found", "googleai/gemini-flash-latest") +} + +// TestV1ErrorsAsStillMatches is the load-bearing compatibility guarantee: code +// written against *core.GenkitError keeps matching errors that Genkit now +// raises as *status.Error, because the two are the same type. +func TestV1ErrorsAsStillMatches(t *testing.T) { + for name, err := range map[string]error{ + "direct": internalError(), + "wrapped": fmt.Errorf("resolving model: %w", internalError()), + } { + t.Run(name, func(t *testing.T) { + var ge *core.GenkitError + if !errors.As(err, &ge) { + t.Fatalf("errors.As(*core.GenkitError) = false for %T", err) + } + if ge.Status != core.NOT_FOUND { + t.Errorf("Status = %q, want %q", ge.Status, core.NOT_FOUND) + } + if ge.HTTPCode != http.StatusNotFound { + t.Errorf("HTTPCode = %d, want %d", ge.HTTPCode, http.StatusNotFound) + } + if got := core.HTTPStatusCode(ge.Status); got != http.StatusNotFound { + t.Errorf("core.HTTPStatusCode = %d, want %d", got, http.StatusNotFound) + } + + // The same error also answers to the v2 surface. + if !errors.Is(err, errModelNotFound) { + t.Error("errors.Is(specific sentinel) = false") + } + if !errors.Is(err, status.ErrNotFound) { + t.Error("errors.Is(base sentinel) = false") + } + if got := status.Of(err); got != status.NotFound { + t.Errorf("status.Of = %q, want %q", got, status.NotFound) + } + }) + } +} + +// TestErrorsUnwrapStillWalks guards the reason Error keeps a single-cause +// Unwrap: the stdlib errors.Unwrap returns nil for an Unwrap() []error, which +// would silently truncate hand-rolled chain walks in logging and telemetry +// middleware. +func TestErrorsUnwrapStillWalks(t *testing.T) { + cause := errors.New("boom") + err := fmt.Errorf("outer: %w", status.Errorf(status.ErrInternal, "inner: %w", cause)) + + depth := 0 + for e := err; e != nil; e = errors.Unwrap(e) { + depth++ + } + if depth != 3 { // fmt wrapper -> status.Error -> cause + t.Errorf("chain walk depth = %d, want 3", depth) + } + if got := errors.Unwrap(errors.Unwrap(err)); got != cause { + t.Errorf("errors.Unwrap through status.Error = %v, want %v", got, cause) + } +} + +// TestV1ConstructorsPreserveV1Behaviour: anything built through the deprecated +// constructors behaves exactly as it did before, including the two behaviours +// status.Errorf deliberately drops. +func TestV1ConstructorsPreserveV1Behaviour(t *testing.T) { + t.Run("NewError implicitly wraps the last error argument", func(t *testing.T) { + cause := errors.New("boom") + // Note %v, not %w: v1 wrapped by scanning args, not by verb. + err := core.NewError(core.INVALID_ARGUMENT, "bad input: %v", cause) + if !errors.Is(err, cause) { + t.Error("errors.Is(cause) = false; implicit wrapping lost") + } + if got := err.Error(); got != "bad input: boom" { + t.Errorf("Error() = %q, want %q", got, "bad input: boom") + } + // And it now classifies for v2 consumers too. + if !errors.Is(err, status.ErrInvalidArgument) { + t.Error("v1-constructed error does not match its base sentinel") + } + }) + + t.Run("NewError records a stack in Details", func(t *testing.T) { + err := core.NewError(core.INTERNAL, "boom") + if _, ok := err.Details["stack"]; !ok { + t.Error(`Details["stack"] missing`) + } + }) + + t.Run("UserFacingError keeps its shape and text", func(t *testing.T) { + err := core.NewPublicError(core.INVALID_ARGUMENT, "invalid email", map[string]any{"field": "email"}) + var uf *core.UserFacingError + if !errors.As(error(err), &uf) { + t.Fatal("errors.As(*core.UserFacingError) = false") + } + if got, want := err.Error(), "INVALID_ARGUMENT: invalid email"; got != want { + t.Errorf("Error() = %q, want %q", got, want) + } + if uf.Details["field"] != "email" { + t.Errorf("Details = %v, want field=email", uf.Details) + } + // It stays a distinct type from GenkitError. + if errors.As(error(err), new(*core.GenkitError)) { + t.Error("UserFacingError matched *core.GenkitError; the two must stay distinct") + } + }) + + t.Run("UserFacingError now carries a usable status", func(t *testing.T) { + err := core.NewPublicError(core.INVALID_ARGUMENT, "invalid email", nil) + // v1 bug: transports could not read this status, so a public + // INVALID_ARGUMENT went out as HTTP 500. + if got := status.Of(err); got != status.InvalidArgument { + t.Errorf("status.Of = %q, want %q", got, status.InvalidArgument) + } + if !errors.Is(err, status.ErrInvalidArgument) { + t.Error("errors.Is(base sentinel) = false") + } + msg, public := status.PublicMessage(err) + if !public || msg != "invalid email" { + t.Errorf("PublicMessage = (%q, %v), want (%q, true)", msg, public, "invalid email") + } + }) + + t.Run("SchemaValidationError still matches and now classifies", func(t *testing.T) { + cause := errors.New("field x: expected string") + err := error(core.NewSchemaValidationError("/flow/foo", cause)) + + var sve *core.SchemaValidationError + if !errors.As(err, &sve) { + t.Fatal("errors.As(*core.SchemaValidationError) = false") + } + if sve.GenkitError == nil { + t.Error("embedded GenkitError is nil") + } + if !errors.Is(err, cause) { + t.Error("errors.Is(cause) = false") + } + if !errors.Is(err, status.ErrInvalidInput) { + t.Error("errors.Is(status.ErrInvalidInput) = false") + } + if got := status.Of(err); got != status.InvalidArgument { + t.Errorf("status.Of = %q, want %q", got, status.InvalidArgument) + } + }) +} + +// TestUnclassifiedErrorsStayRetryable pins the classification that keeps the +// retry middleware's default behaviour unchanged: an error nobody classified is +// INTERNAL, which is in the default retry set. A cancelled context is not. +func TestUnclassifiedErrorsStayRetryable(t *testing.T) { + if got := status.Of(errors.New("transient network blip")); got != status.Internal { + t.Errorf("status.Of(unclassified) = %q, want %q", got, status.Internal) + } +} + +// TestStatusNameAliasIsInterchangeable covers the exported plugin config that +// is typed []core.StatusName (retry, fallback): the alias must let callers use +// either spelling. +func TestStatusNameAliasIsInterchangeable(t *testing.T) { + v1 := []core.StatusName{core.UNAVAILABLE, core.NOT_FOUND} + v2 := []status.Name{status.Unavailable, status.NotFound} + for i := range v1 { + if v1[i] != v2[i] { + t.Errorf("v1[%d] = %q, v2[%d] = %q; alias is not transparent", i, v1[i], i, v2[i]) + } + } + var n status.Name = core.INVALID_ARGUMENT + if n.HTTPCode() != http.StatusBadRequest { + t.Errorf("HTTPCode = %d, want %d", n.HTTPCode(), http.StatusBadRequest) + } +} diff --git a/go/core/status/error.go b/go/core/status/error.go index 54105f9d97..50e095c8fc 100644 --- a/go/core/status/error.go +++ b/go/core/status/error.go @@ -225,12 +225,30 @@ func PublicMessage(err error) (msg string, public bool) { return "", false } var e *Error - if errors.As(err, &e) && e.Public { - return e.Message, true + if errors.As(err, &e) { + if e.Public { + return e.Message, true + } + return genericMessage(e.Status), false + } + // No Error in the chain: fall back to the interface, which the deprecated + // core.UserFacingError implements so its message still reaches clients. + var pm publicMessager + if errors.As(err, &pm) { + if m, ok := pm.PublicMessage(); ok { + return m, true + } } return genericMessage(Of(err)), false } +// publicMessager lets a type declared outside this package mark its message +// safe to return to clients. It exists for the deprecated core.UserFacingError, +// whose whole purpose was to be public but which predates [Error.Public]. +type publicMessager interface { + PublicMessage() (string, bool) +} + func genericMessage(n Name) string { if s, ok := baseSentinels[n]; ok { return s.label diff --git a/go/genkit/reflection.go b/go/genkit/reflection.go index b8df0b3665..1627a252a2 100644 --- a/go/genkit/reflection.go +++ b/go/genkit/reflection.go @@ -771,7 +771,7 @@ func runAction(ctx context.Context, g *Genkit, key string, input, init json.RawM func checkInitSupported(a api.Action, init json.RawMessage) error { if base.HasJSONValue(init) { if _, ok := a.(api.BidiAction); !ok { - return status.Errorf(status.ErrInvalidArgument, "action %q does not accept init", a.Name()) + return status.PublicErrorf(status.ErrInvalidArgument, "action %q does not accept init", a.Name()) } } return nil diff --git a/go/genkit/reflection_v2.go b/go/genkit/reflection_v2.go index 00d8fdfe8b..1e85b709c3 100644 --- a/go/genkit/reflection_v2.go +++ b/go/genkit/reflection_v2.go @@ -31,8 +31,10 @@ import ( "github.com/coder/websocket" "github.com/coder/websocket/wsjson" + "github.com/firebase/genkit/go/core" "github.com/firebase/genkit/go/core/api" + "github.com/firebase/genkit/go/core/status" "github.com/firebase/genkit/go/core/tracing" "github.com/firebase/genkit/go/internal" ) @@ -792,10 +794,13 @@ func (s *bidiSession) stop() { // sendRunActionError maps a runAction error to a JSON-RPC error response // with a Status-shaped data field matching the JS implementation. func (s *reflectionServerV2) sendRunActionError(id string, err error, traceID string) { - code := core.INTERNAL + // The reflection API serves the Dev UI, so it reports the real message and + // stack. Suppressing them here would only hide the failure from the + // developer causing it; the redaction that matters is at the flow HTTP + // boundary (see clientError in servers.go). + code := status.Of(err) msg := err.Error() if errors.Is(err, context.Canceled) { - code = core.CANCELLED msg = "Action was cancelled" } @@ -803,15 +808,18 @@ func (s *reflectionServerV2) sendRunActionError(id string, err error, traceID st if traceID != "" { details["traceId"] = traceID } - var ge *core.GenkitError - if errors.As(err, &ge) && ge.Details != nil { - if stack, ok := ge.Details["stack"].(string); ok { + if e := status.Convert(err); e != nil { + // status.Errorf records the stack out of band; core.NewError still puts + // one in Details for compatibility. Prefer whichever is present. + if stack, ok := e.Details["stack"].(string); ok { + details["stack"] = stack + } else if stack := e.Stack(); stack != "" { details["stack"] = stack } } data := map[string]any{ - "code": core.StatusNameToCode[code], + "code": code.Code(), "message": msg, } if len(details) > 0 { diff --git a/go/genkit/servers.go b/go/genkit/servers.go index 844fabe384..e5517d3d4b 100644 --- a/go/genkit/servers.go +++ b/go/genkit/servers.go @@ -148,16 +148,31 @@ func wrapHandler(h func(http.ResponseWriter, *http.Request) error) http.HandlerF }() if err = h(w, r); err != nil { - var herr *core.GenkitError - if errors.As(err, &herr) { - http.Error(w, herr.Error(), core.HTTPStatusCode(herr.Status)) - } else { - http.Error(w, err.Error(), http.StatusInternalServerError) - } + msg, code := clientError(err) + http.Error(w, msg, code) } } } +// clientError returns the message and HTTP status to send a client for err. +// +// The status always comes from the error, so an error deliberately marked +// public reaches the client with its own code rather than falling through to +// 500. The message only leaves the process when the error was built with +// [status.PublicErrorf]; anything else becomes a generic string derived from +// the status, so schema dumps, provider text, and internal identifiers stay +// server-side. The full error is still logged by wrapHandler. +// +// GENKIT_ENV=dev is exempt: suppressing the message during local development +// only hides the failure from the developer causing it. +func clientError(err error) (string, int) { + msg, public := status.PublicMessage(err) + if !public && api.CurrentEnvironment() == api.EnvironmentDev { + msg = err.Error() + } + return msg, status.Of(err).HTTPCode() +} + // handler returns an HTTP handler function that serves the action with the provided options. // Streaming responses are written in server-sent events (SSE) format. func handler(a api.Action, opts *handlerOptions) func(http.ResponseWriter, *http.Request) error { @@ -417,10 +432,12 @@ type flowErrorResponse struct { } // flowError represents the error payload in a streaming error response. +// +// It carries no details field: it used to hold the full err.Error() text, which +// put internal failure detail on the wire on every streamed error. type flowError struct { - Status core.StatusName `json:"status"` - Message string `json:"message"` - Details string `json:"details,omitempty"` + Status status.Name `json:"status"` + Message string `json:"message"` } // writeResultResponse writes a JSON result response for non-streaming requests. @@ -462,20 +479,11 @@ func writeSSEMessage(w http.ResponseWriter, msg json.RawMessage) error { // writeSSEError writes an error as a server-sent event for streaming requests. func writeSSEError(w http.ResponseWriter, flowErr error) error { - status := core.INTERNAL - var ufErr *core.UserFacingError - var gErr *core.GenkitError - if errors.As(flowErr, &ufErr) { - status = ufErr.Status - } else if errors.As(flowErr, &gErr) { - status = gErr.Status - } - + msg, _ := clientError(flowErr) resp := flowErrorResponse{ Error: &flowError{ - Status: status, - Message: "stream flow error", - Details: flowErr.Error(), + Status: status.Of(flowErr), + Message: msg, }, } data, err := json.Marshal(resp) diff --git a/go/genkit/servers_leak_test.go b/go/genkit/servers_leak_test.go new file mode 100644 index 0000000000..59e2cd5d24 --- /dev/null +++ b/go/genkit/servers_leak_test.go @@ -0,0 +1,149 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package genkit + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/firebase/genkit/go/core/api" + "github.com/firebase/genkit/go/core/status" +) + +// Each case below is a path that used to put internal failure detail on the +// wire. The flow HTTP boundary wrote err.Error() verbatim on every branch, so +// whatever an error happened to say reached the client. +func TestHandlerDoesNotLeakInternalDetail(t *testing.T) { + g := Init(context.Background()) + + // A provider error relayed verbatim. googlegenai wraps the raw SDK error, + // whose text can carry project and model resource paths, quota specifics, + // and permission detail. + providerFlow := DefineFlow(g, "leakProvider", func(ctx context.Context, in string) (string, error) { + return "", status.Errorf(status.Base(status.FromHTTPCode(429)), + "googleapi: Error 429: Quota exceeded for project 12345678, model projects/acme-prod/locations/us/models/x") + }) + + // An output schema violation. The message embeds the action key and a + // field-by-field dump of the shape the action failed to produce. + outputFlow := DefineFlow(g, "leakOutput", func(ctx context.Context, in string) (string, error) { + return "", status.Errorf(status.ErrInvalidOutput, + "invalid output from action %q: data did not match expected schema:\n- ssn: Invalid type. Expected: string", "/flow/leakOutput") + }) + + // An unclassified error from user code: the default path for anything not + // deliberately classified. + unclassifiedFlow := DefineFlow(g, "leakUnclassified", func(ctx context.Context, in string) (string, error) { + return "", errors.New("dial tcp 10.0.0.7:5432: connect: connection refused") + }) + + tests := []struct { + name string + flow api.Action + code int + secrets []string + }{ + {"provider error", providerFlow, http.StatusTooManyRequests, []string{"acme-prod", "12345678", "googleapi"}}, + {"output schema violation", outputFlow, http.StatusInternalServerError, []string{"ssn", "leakOutput", "expected schema"}}, + {"unclassified error", unclassifiedFlow, http.StatusInternalServerError, []string{"10.0.0.7", "connection refused"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := Handler(tt.flow) + + req := httptest.NewRequest("POST", "/", strings.NewReader(`{"data":"x"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler(w, req) + + resp := w.Result() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != tt.code { + t.Errorf("status = %d, want %d", resp.StatusCode, tt.code) + } + for _, s := range tt.secrets { + if strings.Contains(string(body), s) { + t.Errorf("response leaked %q; body = %q", s, string(body)) + } + } + }) + } +} + +// The generic message must still name the status so a client can act on it, +// even though the specific message is withheld. +func TestGenericMessageStillCarriesStatus(t *testing.T) { + for _, tt := range []struct { + err error + want string + }{ + {status.Errorf(status.ErrNotFound, "model %q not found", "secret-internal-name"), "not found"}, + {status.Errorf(status.ErrPermissionDenied, "caller lacks roles/aiplatform.user"), "permission denied"}, + {errors.New("raw"), "internal"}, + } { + msg, public := status.PublicMessage(tt.err) + if public { + t.Errorf("PublicMessage(%v) reported public", tt.err) + } + if msg != tt.want { + t.Errorf("PublicMessage = %q, want %q", msg, tt.want) + } + } +} + +// GENKIT_ENV=dev keeps the full message, so local development is not blinded by +// the redaction that protects deployed servers. +func TestDevEnvironmentKeepsFullMessage(t *testing.T) { + t.Setenv("GENKIT_ENV", "dev") + + err := status.Errorf(status.ErrNotFound, "model %q not found", "googleai/nope") + msg, code := clientError(err) + if !strings.Contains(msg, "googleai/nope") { + t.Errorf("dev message = %q, want the full text", msg) + } + if code != http.StatusNotFound { + t.Errorf("code = %d, want %d", code, http.StatusNotFound) + } +} + +// A public error keeps its own status code. It used to fall through to 500 +// because *core.UserFacingError is structurally unrelated to *core.GenkitError, +// so the handler's errors.As could never match it. +func TestPublicErrorKeepsItsStatusCode(t *testing.T) { + for _, tt := range []struct { + err error + code int + }{ + {status.PublicErrorf(status.ErrUnauthenticated, "authorization header is required"), http.StatusUnauthorized}, + {status.PublicErrorf(status.ErrInvalidArgument, "field %q is required", "email"), http.StatusBadRequest}, + } { + msg, code := clientError(tt.err) + if code != tt.code { + t.Errorf("code = %d, want %d", code, tt.code) + } + if msg != tt.err.Error() { + t.Errorf("msg = %q, want the public message %q", msg, tt.err.Error()) + } + } +} diff --git a/go/genkit/servers_test.go b/go/genkit/servers_test.go index 6500d40f22..a96faf861e 100644 --- a/go/genkit/servers_test.go +++ b/go/genkit/servers_test.go @@ -101,8 +101,10 @@ func TestHandler(t *testing.T) { t.Errorf("want status code %d, got %d", http.StatusInternalServerError, resp.StatusCode) } - if !strings.Contains(string(body), "generic error message") { - t.Errorf("want error message in response body, got %q", string(body)) + // The message is deliberately withheld: an unclassified error was never + // vetted as safe to return, so the client gets the status alone. + if strings.Contains(string(body), "generic error message") { + t.Errorf("internal message leaked to client: %q", string(body)) } }) @@ -143,8 +145,8 @@ func TestHandler(t *testing.T) { t.Errorf("want status code %d for NOT_FOUND, got %d", http.StatusNotFound, resp.StatusCode) } - if !strings.Contains(string(body), "resource not found") { - t.Errorf("want error message in response body, got %q", string(body)) + if strings.Contains(string(body), "resource not found") { + t.Errorf("internal message leaked to client: %q", string(body)) } }) @@ -165,11 +167,14 @@ func TestHandler(t *testing.T) { } if !strings.Contains(string(body), "permission denied") { - t.Errorf("want error message in response body, got %q", string(body)) + t.Errorf("want the generic status message, got %q", string(body)) } }) - t.Run("UserFacingError returns internal server error", func(t *testing.T) { + // A public error reaches the client with its own status. It used to fall + // through to 500 because *core.UserFacingError is unrelated to + // *core.GenkitError, so the handler's errors.As could never match it. + t.Run("UserFacingError keeps its status and message", func(t *testing.T) { handler := Handler(userFacingErrorFlow) req := httptest.NewRequest("POST", "/", strings.NewReader(`{"data":"test"}`)) @@ -181,8 +186,8 @@ func TestHandler(t *testing.T) { resp := w.Result() body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != http.StatusInternalServerError { - t.Errorf("want status code %d, got %d", http.StatusInternalServerError, resp.StatusCode) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("want status code %d, got %d", http.StatusBadRequest, resp.StatusCode) } if !strings.Contains(string(body), "public error message") { @@ -423,7 +428,7 @@ data: {"result":"hello-end"} resp := w.Result() body, _ := io.ReadAll(resp.Body) - expected := `data: {"error":{"status":"INTERNAL","message":"stream flow error","details":"streaming error"}} + expected := `data: {"error":{"status":"INTERNAL","message":"internal"}} ` if string(body) != expected { From 8e9fa2a004440f054d0806ae3161a471ad3d6c8a Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Tue, 28 Jul 2026 09:36:16 -0700 Subject: [PATCH 05/14] test(go): add core/status test suite and point core's docs at it Ports the status package's own tests and adds the invariants specific to this implementation: that Unwrap stays walkable by the stdlib errors.Unwrap, that Is matches a sentinel at every level of a Subtype chain without matching siblings, that sentinel and cause matching are independent, and that an Error decoded from the wire (no sentinel) is safe to match against. 90.7% statement coverage. --- go/core/doc.go | 7 + go/core/status/error_test.go | 321 +++++++++++++++++++++++++++++++++ go/core/status/example_test.go | 85 +++++++++ go/core/status/status_test.go | 162 +++++++++++++++++ go/core/status/unwrap_test.go | 123 +++++++++++++ 5 files changed, 698 insertions(+) create mode 100644 go/core/status/error_test.go create mode 100644 go/core/status/example_test.go create mode 100644 go/core/status/status_test.go create mode 100644 go/core/status/unwrap_test.go diff --git a/go/core/doc.go b/go/core/doc.go index e0d201ac61..1445dcd7f2 100644 --- a/go/core/doc.go +++ b/go/core/doc.go @@ -197,6 +197,13 @@ with an operation ID that can be polled for completion: # Error Handling +Errors live in [github.com/firebase/genkit/go/core/status]. Classify a failure +with a sentinel so callers can branch on it with errors.Is rather than by +matching message text, and mark a message public only when it is safe to return +to a client: + + import "github.com/firebase/genkit/go/core/status" + Return user-facing errors with appropriate status codes: if err := validate(input); err != nil { diff --git a/go/core/status/error_test.go b/go/core/status/error_test.go new file mode 100644 index 0000000000..ea4d29066d --- /dev/null +++ b/go/core/status/error_test.go @@ -0,0 +1,321 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package status + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "testing" +) + +// errMaxTurns stands in for a domain sentinel a feature package would declare. +var errMaxTurns = ErrAborted.Subtype("max turns exceeded") + +func TestErrorf(t *testing.T) { + err := Errorf(ErrNotFound, "model %q not found", "gemini") + + if got, want := err.Error(), `model "gemini" not found`; got != want { + t.Errorf("Error() = %q, want %q", got, want) + } + if got, want := err.Status, NotFound; got != want { + t.Errorf("Status = %q, want %q", got, want) + } + if err.Public { + t.Error("Errorf produced a public error") + } + if got, want := err.Status.HTTPCode(), http.StatusNotFound; got != want { + t.Errorf("Status.HTTPCode() = %d, want %d", got, want) + } +} + +func TestErrorfEmptyMessageFallsBackToSentinel(t *testing.T) { + if got, want := Errorf(ErrAborted, "").Error(), "aborted"; got != want { + t.Errorf("Error() = %q, want the sentinel label %q", got, want) + } +} + +func TestErrorfNilSentinelIsInternal(t *testing.T) { + err := Errorf(nil, "boom") + if got, want := err.Status, Internal; got != want { + t.Errorf("Status = %q, want %q", got, want) + } + if !errors.Is(err, ErrInternal) { + t.Error("errors.Is(err, ErrInternal) = false") + } +} + +func TestPublicErrorf(t *testing.T) { + err := PublicErrorf(ErrInvalidArgument, "invalid %q parameter", "stream") + if !err.Public { + t.Error("PublicErrorf produced a non-public error") + } + msg, public := PublicMessage(err) + if !public || msg != `invalid "stream" parameter` { + t.Errorf("PublicMessage() = (%q, %v), want the message verbatim", msg, public) + } +} + +func TestPublicMessageHidesNonPublicText(t *testing.T) { + err := Errorf(ErrPermissionDenied, "user alice lacks role admin on project p-42") + + msg, public := PublicMessage(err) + if public { + t.Error("PublicMessage reported a non-public error as public") + } + if strings.Contains(msg, "alice") || strings.Contains(msg, "p-42") { + t.Errorf("PublicMessage() = %q, leaked internal detail", msg) + } + if msg != "permission denied" { + t.Errorf("PublicMessage() = %q, want the generic status label", msg) + } +} + +func TestPublicMessageOfPlainError(t *testing.T) { + msg, public := PublicMessage(errors.New("connection string: postgres://user:pw@host")) + if public { + t.Error("a plain error was reported as public") + } + if strings.Contains(msg, "postgres") { + t.Errorf("PublicMessage() = %q, leaked the underlying message", msg) + } +} + +func TestSentinelMatchingIsTwoLevel(t *testing.T) { + err := Errorf(errMaxTurns, "stopped after %d turns", 5) + + if !errors.Is(err, errMaxTurns) { + t.Error("errors.Is(err, errMaxTurns) = false, want a specific match") + } + if !errors.Is(err, ErrAborted) { + t.Error("errors.Is(err, ErrAborted) = false, want a broad match via the parent") + } + if errors.Is(err, ErrInternal) { + t.Error("errors.Is(err, ErrInternal) = true, want no match on an unrelated sentinel") + } + if got, want := err.Status, Aborted; got != want { + t.Errorf("Status = %q, want the parent's %q", got, want) + } +} + +func TestSubInheritsStatus(t *testing.T) { + sub := ErrNotFound.Subtype("model not found") + if got, want := sub.Status(), NotFound; got != want { + t.Errorf("Sub().Status() = %q, want %q", got, want) + } + if !errors.Is(sub, ErrNotFound) { + t.Error("a sub-sentinel does not match its parent") + } + // A bare sentinel is a usable error value on its own. + if got := Of(sub); got != NotFound { + t.Errorf("Of(sentinel) = %q, want NOT_FOUND", got) + } +} + +func TestErrorfWrapsCause(t *testing.T) { + cause := errors.New("dial tcp: connection refused") + err := Errorf(ErrUnavailable, "reaching provider: %w", cause) + + if !errors.Is(err, cause) { + t.Error("errors.Is(err, cause) = false, want the %w cause reachable") + } + if !errors.Is(err, ErrUnavailable) { + t.Error("errors.Is(err, ErrUnavailable) = false, want the sentinel reachable alongside the cause") + } + if got, want := err.Error(), "reaching provider: dial tcp: connection refused"; got != want { + t.Errorf("Error() = %q, want %q", got, want) + } +} + +func TestErrorfWrapsMultipleCauses(t *testing.T) { + a, b := errors.New("a"), errors.New("b") + err := Errorf(ErrInternal, "%w and %w", a, b) + + for _, target := range []error{a, b, ErrInternal} { + if !errors.Is(err, target) { + t.Errorf("errors.Is(err, %v) = false", target) + } + } +} + +// Adding context with fmt.Errorf must leave the classification alone: this is +// the common case as an error travels up the stack. +func TestContextWrappingPreservesStatus(t *testing.T) { + err := error(Errorf(errMaxTurns, "stopped after 5 turns")) + err = fmt.Errorf("agent %q: %w", "planner", err) + err = fmt.Errorf("flow %q: %w", "chat", err) + + if got, want := Of(err), Aborted; got != want { + t.Errorf("Of(err) = %q, want %q", got, want) + } + if !errors.Is(err, errMaxTurns) { + t.Error("the sentinel did not survive two layers of fmt.Errorf") + } + if got, want := err.Error(), `flow "chat": agent "planner": stopped after 5 turns`; got != want { + t.Errorf("Error() = %q, want %q", got, want) + } +} + +// Reclassifying at a boundary is deliberate, and the outermost classification +// is the one transports report. +func TestReclassificationWins(t *testing.T) { + inner := Errorf(ErrNotFound, "row not found") + outer := Errorf(ErrInternal, "tool %q: %w", "lookup", inner) + + if got, want := Of(outer), Internal; got != want { + t.Errorf("Of(outer) = %q, want the outermost status %q", got, want) + } + // The original classification is still reachable for callers that want it. + if !errors.Is(outer, ErrNotFound) { + t.Error("the inner sentinel is no longer reachable") + } + + var got *Error + if !errors.As(outer, &got) { + t.Fatal("errors.As found no *Error") + } + if got != outer { + t.Error("errors.As returned the inner *Error, want the outermost") + } +} + +func TestOf(t *testing.T) { + canceled, cancel := context.WithCancel(context.Background()) + cancel() + + tests := []struct { + name string + err error + want Name + }{ + {"nil", nil, OK}, + {"plain error", errors.New("boom"), Internal}, + {"status error", Errorf(ErrAlreadyExists, "dup"), AlreadyExists}, + {"bare sentinel", ErrUnimplemented, Unimplemented}, + {"wrapped sentinel", fmt.Errorf("x: %w", ErrDataLoss), DataLoss}, + {"context canceled", canceled.Err(), Cancelled}, + {"wrapped cancel", fmt.Errorf("x: %w", context.Canceled), Cancelled}, + {"deadline", context.DeadlineExceeded, DeadlineExceeded}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Of(tt.err); got != tt.want { + t.Errorf("Of() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestConvert(t *testing.T) { + t.Run("nil", func(t *testing.T) { + if got := Convert(nil); got != nil { + t.Errorf("Convert(nil) = %+v, want nil", got) + } + }) + t.Run("passes through an existing Error", func(t *testing.T) { + orig := Errorf(ErrOutOfRange, "index 9") + if got := Convert(fmt.Errorf("x: %w", orig)); got != orig { + t.Errorf("Convert() = %+v, want the wrapped *Error itself", got) + } + }) + t.Run("converts a plain error", func(t *testing.T) { + cause := errors.New("boom") + got := Convert(cause) + if got.Status != Internal || got.Message != "boom" { + t.Errorf("Convert() = %+v, want INTERNAL/boom", got) + } + if got.Public { + t.Error("a converted error must not be public") + } + if !errors.Is(got, cause) { + t.Error("the original error is no longer reachable") + } + }) +} + +func TestStack(t *testing.T) { + stack := Errorf(ErrInternal, "boom").Stack() + if !strings.Contains(stack, "TestStack") { + t.Errorf("Stack() does not name the calling test:\n%s", stack) + } + if strings.Contains(strings.SplitN(stack, "\n", 2)[0], "status.Errorf") { + t.Errorf("Stack() starts inside the status package:\n%s", stack) + } + // A wire-decoded error has no stack. + var decoded Error + if err := json.Unmarshal([]byte(`{"status":"INTERNAL","message":"x"}`), &decoded); err != nil { + t.Fatal(err) + } + if got := decoded.Stack(); got != "" { + t.Errorf("Stack() on a decoded error = %q, want empty", got) + } +} + +func TestJSONRoundTrip(t *testing.T) { + err := Errorf(ErrFailedPrecondition, "not ready").WithDetails(map[string]any{"retryAfter": "5s"}) + + data, mErr := json.Marshal(err) + if mErr != nil { + t.Fatal(mErr) + } + // The stack and the public flag are in-process only. + if got, want := string(data), `{"details":{"retryAfter":"5s"},"message":"not ready","status":"FAILED_PRECONDITION"}`; got != want { + t.Errorf("MarshalJSON() = %s, want %s", got, want) + } + + var back Error + if err := json.Unmarshal(data, &back); err != nil { + t.Fatal(err) + } + if back.Status != FailedPrecondition || back.Message != "not ready" { + t.Errorf("round trip = %+v", back) + } + if back.Sentinel() != nil { + t.Error("a decoded error should carry no sentinel") + } +} + +func TestMarshalOmitsStack(t *testing.T) { + data, err := json.Marshal(Errorf(ErrInternal, "boom")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "stack") || strings.Contains(string(data), "status_test.go") { + t.Errorf("MarshalJSON() leaked the stack: %s", data) + } +} + +func TestBase(t *testing.T) { + if got := Base(ResourceExhausted); got != ErrResourceExhausted { + t.Errorf("Base(RESOURCE_EXHAUSTED) = %v, want ErrResourceExhausted", got) + } + if got := Base("NOT_A_STATUS"); got != ErrUnknown { + t.Errorf("Base(unknown) = %v, want ErrUnknown", got) + } + // Every canonical name except OK has a base sentinel that agrees with it. + for name := range statuses { + if name == OK { + continue + } + if got := Base(name); got.Status() != name { + t.Errorf("Base(%q).Status() = %q", name, got.Status()) + } + } +} diff --git a/go/core/status/example_test.go b/go/core/status/example_test.go new file mode 100644 index 0000000000..c68eb416d2 --- /dev/null +++ b/go/core/status/example_test.go @@ -0,0 +1,85 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package status_test + +import ( + "errors" + "fmt" + + "github.com/firebase/genkit/go/core/status" +) + +// A package declares its domain failure modes from a base sentinel, so callers +// can match at either granularity. +var errMaxTurnsExceeded = status.ErrAborted.Subtype("max turns exceeded") + +func ExampleErrorf() { + err := status.Errorf(errMaxTurnsExceeded, "stopped after %d turns", 5) + + fmt.Println(err) + fmt.Println("specific:", errors.Is(err, errMaxTurnsExceeded)) + fmt.Println("broad: ", errors.Is(err, status.ErrAborted)) + fmt.Println("status: ", status.Of(err)) + // Output: + // stopped after 5 turns + // specific: true + // broad: true + // status: ABORTED +} + +// Adding context as an error travels up the stack must not reclassify it. +func ExampleOf_wrapping() { + err := error(status.Errorf(status.ErrNotFound, "model %q not found", "gemini")) + err = fmt.Errorf("agent %q: %w", "planner", err) + + fmt.Println(err) + fmt.Println("status:", status.Of(err)) + fmt.Println("still not found:", errors.Is(err, status.ErrNotFound)) + // Output: + // agent "planner": model "gemini" not found + // status: NOT_FOUND + // still not found: true +} + +// A tool's own NOT_FOUND is not a NOT_FOUND for the request that invoked it. +// Reclassify deliberately at the boundary; the original stays reachable. +func ExampleErrorf_reclassify() { + inner := status.Errorf(status.ErrNotFound, "no row for id 42") + err := status.Errorf(status.ErrInternal, "tool %q: %w", "lookup", inner) + + fmt.Println(err) + fmt.Println("status:", status.Of(err)) + fmt.Println("cause still reachable:", errors.Is(err, status.ErrNotFound)) + // Output: + // tool "lookup": no row for id 42 + // status: INTERNAL + // cause still reachable: true +} + +// Transports report the message only when it was marked safe to return. +func ExamplePublicMessage() { + internal := status.Errorf(status.ErrPermissionDenied, "user alice lacks role admin") + public := status.PublicErrorf(status.ErrInvalidArgument, "invalid %q parameter", "stream") + + for _, err := range []error{internal, public} { + msg, ok := status.PublicMessage(err) + fmt.Printf("%d %q (public=%v)\n", status.Of(err).HTTPCode(), msg, ok) + } + // Output: + // 403 "permission denied" (public=false) + // 400 "invalid \"stream\" parameter" (public=true) +} diff --git a/go/core/status/status_test.go b/go/core/status/status_test.go new file mode 100644 index 0000000000..515179b601 --- /dev/null +++ b/go/core/status/status_test.go @@ -0,0 +1,162 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package status + +import ( + "net/http" + "testing" +) + +func TestNameHTTPCode(t *testing.T) { + tests := []struct { + name string + status Name + wantCode int + }{ + {"OK", OK, http.StatusOK}, + {"CANCELLED", Cancelled, 499}, + {"UNKNOWN", Unknown, http.StatusInternalServerError}, + {"INVALID_ARGUMENT", InvalidArgument, http.StatusBadRequest}, + {"DEADLINE_EXCEEDED", DeadlineExceeded, http.StatusGatewayTimeout}, + {"NOT_FOUND", NotFound, http.StatusNotFound}, + {"ALREADY_EXISTS", AlreadyExists, http.StatusConflict}, + {"PERMISSION_DENIED", PermissionDenied, http.StatusForbidden}, + {"UNAUTHENTICATED", Unauthenticated, http.StatusUnauthorized}, + {"RESOURCE_EXHAUSTED", ResourceExhausted, http.StatusTooManyRequests}, + {"FAILED_PRECONDITION", FailedPrecondition, http.StatusBadRequest}, + {"ABORTED", Aborted, http.StatusConflict}, + {"OUT_OF_RANGE", OutOfRange, http.StatusBadRequest}, + {"UNIMPLEMENTED", Unimplemented, http.StatusNotImplemented}, + {"INTERNAL", Internal, http.StatusInternalServerError}, + {"UNAVAILABLE", Unavailable, http.StatusServiceUnavailable}, + {"DATA_LOSS", DataLoss, http.StatusInternalServerError}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.status.HTTPCode() + if got != tt.wantCode { + t.Errorf("%q.HTTPCode() = %d, want %d", tt.status, got, tt.wantCode) + } + }) + } + + t.Run("unknown status returns 500", func(t *testing.T) { + got := Name("UNKNOWN_STATUS").HTTPCode() + if got != http.StatusInternalServerError { + t.Errorf("unknown Name.HTTPCode() = %d, want %d", got, http.StatusInternalServerError) + } + }) +} + +func TestFromHTTPCode(t *testing.T) { + tests := []struct { + code int + want Name + }{ + {http.StatusOK, OK}, + {499, Cancelled}, + {http.StatusBadRequest, InvalidArgument}, + {http.StatusUnauthorized, Unauthenticated}, + {http.StatusForbidden, PermissionDenied}, + {http.StatusNotFound, NotFound}, + {http.StatusConflict, Aborted}, + {http.StatusTooManyRequests, ResourceExhausted}, + {http.StatusInternalServerError, Internal}, + {http.StatusNotImplemented, Unimplemented}, + {http.StatusServiceUnavailable, Unavailable}, + {http.StatusGatewayTimeout, DeadlineExceeded}, + // Unmapped 5xx codes fall through to Internal. + {http.StatusBadGateway, Internal}, + {599, Internal}, + // Unmapped non-5xx codes land on Unknown. + {http.StatusTeapot, Unknown}, + {0, Unknown}, + } + for _, tt := range tests { + t.Run(http.StatusText(tt.code), func(t *testing.T) { + if got := FromHTTPCode(tt.code); got != tt.want { + t.Errorf("FromHTTPCode(%d) = %q, want %q", tt.code, got, tt.want) + } + }) + } +} + +func TestNameIsValid(t *testing.T) { + if !InvalidArgument.IsValid() { + t.Error("INVALID_ARGUMENT.IsValid() = false") + } + if Name("NOPE").IsValid() { + t.Error(`Name("NOPE").IsValid() = true`) + } + if got := Name("NOPE").Code(); got != 2 { + t.Errorf(`Name("NOPE").Code() = %d, want 2 (Unknown)`, got) + } +} + +func TestNameCode(t *testing.T) { + t.Run("every canonical name maps to its gRPC code", func(t *testing.T) { + expectedMappings := map[Name]int{ + OK: 0, Cancelled: 1, Unknown: 2, InvalidArgument: 3, + DeadlineExceeded: 4, NotFound: 5, AlreadyExists: 6, + PermissionDenied: 7, ResourceExhausted: 8, + FailedPrecondition: 9, Aborted: 10, OutOfRange: 11, + Unimplemented: 12, Internal: 13, Unavailable: 14, + DataLoss: 15, Unauthenticated: 16, + } + + for name, wantCode := range expectedMappings { + if got := name.Code(); got != wantCode { + t.Errorf("%q.Code() = %d, want %d", name, got, wantCode) + } + } + }) +} + +// The Go identifiers are Go-cased but the values they carry are the wire +// format, shared verbatim with the JS and Python runtimes. Drift here breaks +// cross-runtime compatibility silently, so pin every one of them. +func TestWireValues(t *testing.T) { + want := map[Name]string{ + OK: "OK", + Cancelled: "CANCELLED", + Unknown: "UNKNOWN", + InvalidArgument: "INVALID_ARGUMENT", + DeadlineExceeded: "DEADLINE_EXCEEDED", + NotFound: "NOT_FOUND", + AlreadyExists: "ALREADY_EXISTS", + PermissionDenied: "PERMISSION_DENIED", + Unauthenticated: "UNAUTHENTICATED", + ResourceExhausted: "RESOURCE_EXHAUSTED", + + FailedPrecondition: "FAILED_PRECONDITION", + Aborted: "ABORTED", + OutOfRange: "OUT_OF_RANGE", + Unimplemented: "UNIMPLEMENTED", + Internal: "INTERNAL", + Unavailable: "UNAVAILABLE", + DataLoss: "DATA_LOSS", + } + for name, wire := range want { + if string(name) != wire { + t.Errorf("wire value drift: %q, want %q", string(name), wire) + } + } + if len(want) != len(statuses) { + t.Errorf("statuses has %d entries but only %d wire values are pinned", len(statuses), len(want)) + } +} diff --git a/go/core/status/unwrap_test.go b/go/core/status/unwrap_test.go new file mode 100644 index 0000000000..d8f06fb233 --- /dev/null +++ b/go/core/status/unwrap_test.go @@ -0,0 +1,123 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package status + +import ( + "errors" + "fmt" + "io" + "testing" +) + +// Error exposes a single cause through Unwrap and matches its sentinel through +// Is, rather than putting both in an Unwrap() []error. The stdlib errors.Unwrap +// returns nil for a multi-cause unwrapper, so the []error form would silently +// truncate the hand-rolled chain walks that logging, telemetry, and third-party +// error tooling do. These tests pin that the single-cause form loses nothing. +func TestUnwrapIsWalkableByStdlib(t *testing.T) { + cause := errors.New("boom") + err := error(Errorf(ErrNotFound, "wrapped: %w", cause)) + + if got := errors.Unwrap(err); got != cause { + t.Errorf("errors.Unwrap = %v, want %v", got, cause) + } + + depth := 0 + for e := fmt.Errorf("outer: %w", err); e != nil; e = errors.Unwrap(e) { + depth++ + } + if depth != 3 { // fmt wrapper -> Error -> cause + t.Errorf("chain walk depth = %d, want 3", depth) + } +} + +func TestUnwrapIsNilWithoutCause(t *testing.T) { + if got := errors.Unwrap(error(Errorf(ErrNotFound, "no cause here"))); got != nil { + t.Errorf("errors.Unwrap = %v, want nil", got) + } +} + +func TestIsMatchesSentinelAtEveryLevel(t *testing.T) { + mid := ErrAborted.Subtype("mid") + leaf := mid.Subtype("leaf") + err := error(Errorf(leaf, "boom")) + + for _, target := range []*Sentinel{leaf, mid, ErrAborted} { + if !errors.Is(err, target) { + t.Errorf("errors.Is(err, %v) = false", target) + } + } + if errors.Is(err, ErrNotFound) { + t.Error("errors.Is(err, ErrNotFound) = true, want false") + } + // A sibling subtype of the same parent must not match. + if sibling := ErrAborted.Subtype("sibling"); errors.Is(err, sibling) { + t.Error("errors.Is(err, sibling) = true, want false") + } +} + +// Sentinel matching and cause matching are independent: adding a cause must not +// shadow the sentinel, and classifying must not hide the cause. +func TestIsMatchesSentinelAndCauseTogether(t *testing.T) { + err := error(Errorf(ErrNotFound.Subtype("model not found"), "model %q: %w", "x", io.EOF)) + if !errors.Is(err, io.EOF) { + t.Error("errors.Is(io.EOF) = false") + } + if !errors.Is(err, ErrNotFound) { + t.Error("errors.Is(ErrNotFound) = false") + } + + // Through an intervening fmt wrapper as well. + wrapped := fmt.Errorf("context: %w", err) + if !errors.Is(wrapped, io.EOF) || !errors.Is(wrapped, ErrNotFound) { + t.Error("matching broke through fmt.Errorf") + } +} + +func TestWithCauseRecordsWithoutChangingMessage(t *testing.T) { + cause := errors.New("boom") + err := Errorf(ErrInternal, "tool %q failed", "weather").WithCause(cause) + + if got, want := err.Error(), `tool "weather" failed`; got != want { + t.Errorf("Error() = %q, want %q", got, want) + } + if !errors.Is(err, cause) { + t.Error("errors.Is(cause) = false") + } + if !errors.Is(err, ErrInternal) { + t.Error("errors.Is(ErrInternal) = false") + } + // A second call is a no-op: the first cause wins. + if err.WithCause(errors.New("other")); errors.Unwrap(error(err)) != cause { + t.Error("second WithCause overwrote the first cause") + } +} + +// A literal-constructed Error (no sentinel) must not panic in Is, and still +// reports its status. +func TestZeroSentinelIsSafe(t *testing.T) { + err := &Error{Status: NotFound, Message: "decoded from the wire"} + if errors.Is(err, ErrNotFound) { + t.Error("errors.Is = true for an Error with no sentinel") + } + if got := Of(err); got != NotFound { + t.Errorf("Of = %q, want %q", got, NotFound) + } + if got := errors.Unwrap(error(err)); got != nil { + t.Errorf("Unwrap = %v, want nil", got) + } +} From 09de79bac140dcc87cff89459282aed3817fca7e Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Tue, 28 Jul 2026 09:52:45 -0700 Subject: [PATCH 06/14] fix(go): tighten error classification and close three residual leaks Audit of the preceding commits, now that they are all in. Three paths were still marked public that should not have been. The agent's three store-mode mismatches describe how the agent was wired, not what the caller sent, so an anonymous client could learn from them whether state is server- or client-managed. Firebase's ID token failure wrapped the Admin SDK's message, which quotes the expected project ID on an audience-claim mismatch. googlegenai's config-binding failures describe the config the developer set, which the caller cannot act on. All three now stay internal; the text still reaches the developer through the log and GENKIT_ENV=dev. The Firestore stream manager leaked around the boundary entirely: it persisted err.Error() for any error and rehydrated it with PublicErrorf, so an internal failure from the producing process came back out of Firestore flagged safe to return. streamError now records whether the message was public and the subscribe path rebuilds with that flag. The field is absent on existing documents, which decodes to false, the safe default. ai.ErrInvalidPart and streaming.ErrStreamTimeout were declared but never raised, which is worse than not declaring them: errors.Is against a dead sentinel silently never matches. Both are now wired to the sites they were written for. Adds tests pinning every sentinel's status, so a domain sentinel cannot drift from the status its call sites sent before classification, and fixes two handler tests that passed only because the flow's message happened to equal the generic label for its status, leaving them unable to tell redaction from a leak. core.NewError also restores a non-canonical StatusName verbatim rather than letting status.Base coerce it to UNKNOWN, since the shim's contract is to behave exactly as v1 did. --- go/ai/document.go | 7 +- go/ai/errors_test.go | 46 ++++++++++++ go/ai/exp/agent.go | 11 ++- go/ai/generate.go | 4 +- go/ai/tools.go | 4 +- go/core/compat_test.go | 13 ++++ go/core/error.go | 19 ++--- go/core/status/sentinel_status_test.go | 89 +++++++++++++++++++++++ go/genkit/servers_test.go | 16 ++-- go/plugins/firebase/auth.go | 5 +- go/plugins/firebase/exp/stream_manager.go | 25 ++++++- go/plugins/googlegenai/gemini.go | 4 +- go/plugins/googlegenai/imagen.go | 4 +- go/plugins/googlegenai/veo.go | 4 +- 14 files changed, 217 insertions(+), 34 deletions(-) create mode 100644 go/ai/errors_test.go create mode 100644 go/core/status/sentinel_status_test.go diff --git a/go/ai/document.go b/go/ai/document.go index ddcd29c266..5688a38b65 100644 --- a/go/ai/document.go +++ b/go/ai/document.go @@ -18,10 +18,11 @@ package ai import ( "encoding/json" - "fmt" "maps" "slices" "strings" + + "github.com/firebase/genkit/go/core/status" ) // A Document is a piece of data that can be embedded, indexed, or retrieved. @@ -240,7 +241,7 @@ func (p *Part) IsResource() bool { // MarshalJSON is called by the JSON marshaler to write out a Part. func (p *Part) MarshalJSON() ([]byte, error) { if p == nil { - return nil, fmt.Errorf("part is nil") + return nil, status.Errorf(ErrInvalidPart, "part is nil") } // This is not handled by the schema generator because @@ -298,7 +299,7 @@ func (p *Part) MarshalJSON() ([]byte, error) { } return json.Marshal(v) default: - return nil, fmt.Errorf("invalid part kind %v", p.Kind) + return nil, status.Errorf(ErrInvalidPart, "invalid part kind %v", p.Kind) } } diff --git a/go/ai/errors_test.go b/go/ai/errors_test.go new file mode 100644 index 0000000000..fd59f18f94 --- /dev/null +++ b/go/ai/errors_test.go @@ -0,0 +1,46 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package ai + +import ( + "testing" + + "github.com/firebase/genkit/go/core/status" +) + +// Each domain sentinel must carry the status its call sites sent before they +// were classified. A drift here would change the HTTP code clients see and the +// retry/fallback decision, without any call site changing. +func TestDomainSentinelStatuses(t *testing.T) { + for _, tt := range []struct { + name string + s *status.Sentinel + want status.Name + }{ + {"ErrModelNotFound", ErrModelNotFound, status.NotFound}, + {"ErrToolNotFound", ErrToolNotFound, status.NotFound}, + {"ErrMaxTurnsExceeded", ErrMaxTurnsExceeded, status.Aborted}, + {"ErrToolFailed", ErrToolFailed, status.Internal}, + {"ErrUnsupportedByModel", ErrUnsupportedByModel, status.InvalidArgument}, + {"ErrInvalidPart", ErrInvalidPart, status.InvalidArgument}, + {"ErrUnresolvedToolRequest", ErrUnresolvedToolRequest, status.InvalidArgument}, + } { + if got := tt.s.Status(); got != tt.want { + t.Errorf("%s.Status() = %q, want %q", tt.name, got, tt.want) + } + } +} diff --git a/go/ai/exp/agent.go b/go/ai/exp/agent.go index e83fed61c6..10c64677fa 100644 --- a/go/ai/exp/agent.go +++ b/go/ai/exp/agent.go @@ -1767,10 +1767,15 @@ func loadSession[State any]( "state is mutually exclusive with session ID and snapshot ID; a client-managed conversation's identity rides inside the state (SessionState.SessionID)") } + // The three store-mode mismatches below stay internal: they describe how the + // agent was wired, not what the caller sent, so an anonymous client should + // not learn from them whether state is server- or client-managed. A + // developer integrating against the agent sees the full text under + // GENKIT_ENV=dev and in the server log. switch { case init.State != nil: if store != nil { - return nil, nil, status.PublicErrorf(status.ErrFailedPrecondition, + return nil, nil, status.Errorf(status.ErrFailedPrecondition, "state provided but agent has a session store configured (server-managed state); use snapshot ID instead") } // Deep-copy at the entry boundary: an in-process caller retains @@ -1783,7 +1788,7 @@ func loadSession[State any]( case init.SnapshotID != "": if store == nil { - return nil, nil, status.PublicErrorf(status.ErrFailedPrecondition, + return nil, nil, status.Errorf(status.ErrFailedPrecondition, "snapshot ID %q provided but agent has no session store configured (client-managed state); use state instead", init.SnapshotID) } snap, err := store.GetSnapshot(ctx, init.SnapshotID) @@ -1804,7 +1809,7 @@ func loadSession[State any]( case init.SessionID != "": if store == nil { - return nil, nil, status.PublicErrorf(status.ErrFailedPrecondition, + return nil, nil, status.Errorf(status.ErrFailedPrecondition, "session ID %q provided but agent has no session store configured (client-managed state); the conversation's identity rides inside the state object (SessionState.SessionID)", init.SessionID) } snap, err := store.GetLatestSnapshot(ctx, init.SessionID) diff --git a/go/ai/generate.go b/go/ai/generate.go index 3ca485a115..e64eb59f64 100644 --- a/go/ai/generate.go +++ b/go/ai/generate.go @@ -1475,7 +1475,7 @@ func (ModelRef) JSONSchema() *jsonschema.Schema { // pending output, or explicit 'respond' or 'restart' directives in the resume options. func handleResumedToolRequest(ctx context.Context, r api.Registry, genOpts *GenerateActionOptions, p *Part, runTool toolRunnerFunc) (*resumedToolRequestOutput, error) { if p == nil || !p.IsToolRequest() { - return nil, status.Errorf(status.ErrInvalidArgument, "handleResumedToolRequest: part is not a tool request") + return nil, status.Errorf(ErrInvalidPart, "handleResumedToolRequest: part is not a tool request") } if pendingOutputVal, ok := p.Metadata["pendingOutput"]; ok { @@ -1629,7 +1629,7 @@ func handleResumeOption(ctx context.Context, r api.Registry, genOpts *GenerateAc } for _, part := range genOpts.Resume.Restart { if !part.IsToolRequest() { - return nil, status.Errorf(status.ErrInvalidArgument, "handleResumeOption: restart part is not a tool request") + return nil, status.Errorf(ErrInvalidPart, "handleResumeOption: restart part is not a tool request") } } diff --git a/go/ai/tools.go b/go/ai/tools.go index eb5e84fe79..6c70052415 100644 --- a/go/ai/tools.go +++ b/go/ai/tools.go @@ -682,7 +682,7 @@ func (t *ToolDef[In, Out]) RespondWith(toolReq *Part, output Out, opts ...Respon return nil, status.Errorf(status.ErrInvalidArgument, "ai.RespondWith: toolReq is nil") } if !toolReq.IsToolRequest() { - return nil, status.Errorf(status.ErrInvalidArgument, "ai.RespondWith: part is not a tool request") + return nil, status.Errorf(ErrInvalidPart, "ai.RespondWith: part is not a tool request") } if toolReq.ToolRequest.Name != t.Name() { return nil, status.Errorf(status.ErrInvalidArgument, "ai.RespondWith: tool request is for %q, not %q", toolReq.ToolRequest.Name, t.Name()) @@ -716,7 +716,7 @@ func (t *ToolDef[In, Out]) RestartWith(toolReq *Part, opts ...RestartWithOption[ return nil, status.Errorf(status.ErrInvalidArgument, "ai.RestartWith: toolReq is nil") } if !toolReq.IsToolRequest() { - return nil, status.Errorf(status.ErrInvalidArgument, "ai.RestartWith: part is not a tool request") + return nil, status.Errorf(ErrInvalidPart, "ai.RestartWith: part is not a tool request") } if toolReq.ToolRequest.Name != t.Name() { return nil, status.Errorf(status.ErrInvalidArgument, "ai.RestartWith: tool request is for %q, not %q", toolReq.ToolRequest.Name, t.Name()) diff --git a/go/core/compat_test.go b/go/core/compat_test.go index 281bcd0815..0af5e55094 100644 --- a/go/core/compat_test.go +++ b/go/core/compat_test.go @@ -112,6 +112,19 @@ func TestV1ConstructorsPreserveV1Behaviour(t *testing.T) { } }) + t.Run("NewError keeps a non-canonical status name on the wire", func(t *testing.T) { + // v1 put whatever StatusName it was handed on the wire rather than + // coercing it, and the shim's contract is to behave the same. + weird := core.StatusName("NOT_A_REAL_STATUS") + err := core.NewError(weird, "boom") + if err.Status != weird { + t.Errorf("Status = %q, want %q", err.Status, weird) + } + if err.HTTPCode != http.StatusInternalServerError { + t.Errorf("HTTPCode = %d, want 500", err.HTTPCode) + } + }) + t.Run("NewError records a stack in Details", func(t *testing.T) { err := core.NewError(core.INTERNAL, "boom") if _, ok := err.Details["stack"]; !ok { diff --git a/go/core/error.go b/go/core/error.go index 3f7ed1826f..50a443452d 100644 --- a/go/core/error.go +++ b/go/core/error.go @@ -123,16 +123,16 @@ func (e *UserFacingError) PublicMessage() (string, bool) { return e.Message, tru // // Record a cause with %w rather than relying on the implicit wrapping of the // last error argument that this function performs. -func NewError(status StatusName, message string, args ...any) *GenkitError { - ge := newErrorSkip(status, message, args...) - ge.Details = map[string]any{"stack": string(debug.Stack())} - return ge -} - -// newErrorSkip builds the error NewError returns, minus the stack detail, so -// the stack NewError records starts at its caller. -func newErrorSkip(name StatusName, message string, args ...any) *GenkitError { +func NewError(name StatusName, message string, args ...any) *GenkitError { ge := status.Errorf(status.Base(name), message, args...) + // status.Base coerces a name outside the canonical set to UNKNOWN, but v1 + // put whatever it was given on the wire. Restore it: this constructor's + // contract is to behave exactly as it did, and the sentinel it was + // classified with stays ErrUnknown, which is the honest classification. + if !name.IsValid() { + ge.Status = name + ge.HTTPCode = name.HTTPCode() + } // v1 scanned args for the last error and wrapped it implicitly, with no %w // in the format. Preserve that so errors.Is and errors.As still reach it. for i := len(args) - 1; i >= 0; i-- { @@ -141,6 +141,7 @@ func newErrorSkip(name StatusName, message string, args ...any) *GenkitError { break } } + ge.Details = map[string]any{"stack": string(debug.Stack())} return ge } diff --git a/go/core/status/sentinel_status_test.go b/go/core/status/sentinel_status_test.go new file mode 100644 index 0000000000..ea25f75913 --- /dev/null +++ b/go/core/status/sentinel_status_test.go @@ -0,0 +1,89 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package status + +import "testing" + +// Every base sentinel must carry the status it is named for. A mismatch would +// silently change the HTTP code and the retry/fallback decision for every site +// classified with it. +func TestBaseSentinelStatuses(t *testing.T) { + want := map[*Sentinel]Name{ + ErrCancelled: Cancelled, + ErrUnknown: Unknown, + ErrInvalidArgument: InvalidArgument, + ErrDeadlineExceeded: DeadlineExceeded, + ErrNotFound: NotFound, + ErrAlreadyExists: AlreadyExists, + ErrPermissionDenied: PermissionDenied, + ErrUnauthenticated: Unauthenticated, + ErrResourceExhausted: ResourceExhausted, + ErrFailedPrecondition: FailedPrecondition, + ErrAborted: Aborted, + ErrOutOfRange: OutOfRange, + ErrUnimplemented: Unimplemented, + ErrInternal: Internal, + ErrUnavailable: Unavailable, + ErrDataLoss: DataLoss, + } + for s, n := range want { + if got := s.Status(); got != n { + t.Errorf("%v.Status() = %q, want %q", s, got, n) + } + if Base(n) != s { + t.Errorf("Base(%q) is not the sentinel named for it", n) + } + } + if len(want) != len(baseSentinels) { + t.Errorf("checked %d sentinels, table has %d", len(want), len(baseSentinels)) + } +} + +// The framework sentinels must keep the statuses their call sites sent before +// they were classified, or the migration silently changed HTTP codes. +func TestFrameworkSentinelStatuses(t *testing.T) { + for _, tt := range []struct { + name string + s *Sentinel + want Name + }{ + {"ErrInvalidSchema", ErrInvalidSchema, InvalidArgument}, + {"ErrInvalidInput", ErrInvalidInput, InvalidArgument}, + {"ErrInvalidOutput", ErrInvalidOutput, Internal}, + {"ErrActionNotFound", ErrActionNotFound, NotFound}, + {"ErrPanic", ErrPanic, Internal}, + } { + if got := tt.s.Status(); got != tt.want { + t.Errorf("%s.Status() = %q, want %q", tt.name, got, tt.want) + } + } +} + +// A subtype inherits its parent's status. This is what lets a call site swap a +// base sentinel for a domain one without changing what the client sees. +func TestSubtypeInheritsStatusTransitively(t *testing.T) { + a := ErrFailedPrecondition.Subtype("a") + b := a.Subtype("b") + for _, s := range []*Sentinel{a, b} { + if got := s.Status(); got != FailedPrecondition { + t.Errorf("%v.Status() = %q, want %q", s, got, FailedPrecondition) + } + } + if got := Errorf(b, "x").Status; got != FailedPrecondition { + t.Errorf("Errorf(subtype).Status = %q, want %q", got, FailedPrecondition) + } +} diff --git a/go/genkit/servers_test.go b/go/genkit/servers_test.go index a96faf861e..85a0fbb086 100644 --- a/go/genkit/servers_test.go +++ b/go/genkit/servers_test.go @@ -49,7 +49,7 @@ func TestHandler(t *testing.T) { }) genkitErrorInvalidArgFlow := DefineFlow(g, "handlerGenkitErrorInvalidArg", func(ctx context.Context, input string) (string, error) { - return "", core.NewError(core.INVALID_ARGUMENT, "invalid argument") + return "", core.NewError(core.INVALID_ARGUMENT, "field %q must be an RFC3339 timestamp", "startedAt") }) genkitErrorNotFoundFlow := DefineFlow(g, "handlerGenkitErrorNotFound", func(ctx context.Context, input string) (string, error) { @@ -57,7 +57,7 @@ func TestHandler(t *testing.T) { }) genkitErrorPermissionDeniedFlow := DefineFlow(g, "handlerGenkitErrorPermissionDenied", func(ctx context.Context, input string) (string, error) { - return "", core.NewError(core.PERMISSION_DENIED, "permission denied") + return "", core.NewError(core.PERMISSION_DENIED, "caller lacks roles/aiplatform.user on project acme-prod") }) userFacingErrorFlow := DefineFlow(g, "handlerUserFacingError", func(ctx context.Context, input string) (string, error) { @@ -124,8 +124,9 @@ func TestHandler(t *testing.T) { t.Errorf("want status code %d for INVALID_ARGUMENT, got %d", http.StatusBadRequest, resp.StatusCode) } - if !strings.Contains(string(body), "invalid argument") { - t.Errorf("want error message in response body, got %q", string(body)) + // The generic label for the status, not the flow's own text. + if got, want := strings.TrimSpace(string(body)), "invalid argument"; got != want { + t.Errorf("body = %q, want the generic %q", got, want) } }) @@ -166,8 +167,11 @@ func TestHandler(t *testing.T) { t.Errorf("want status code %d for PERMISSION_DENIED, got %d", http.StatusForbidden, resp.StatusCode) } - if !strings.Contains(string(body), "permission denied") { - t.Errorf("want the generic status message, got %q", string(body)) + if got, want := strings.TrimSpace(string(body)), "permission denied"; got != want { + t.Errorf("body = %q, want the generic %q", got, want) + } + if strings.Contains(string(body), "acme-prod") { + t.Errorf("internal detail leaked to client: %q", string(body)) } }) diff --git a/go/plugins/firebase/auth.go b/go/plugins/firebase/auth.go index 0aad8534d5..c229840cb1 100644 --- a/go/plugins/firebase/auth.go +++ b/go/plugins/firebase/auth.go @@ -65,7 +65,10 @@ func ContextProvider(ctx context.Context, g *genkit.Genkit, policy AuthPolicy) ( token := authHeader[len(bearerPrefix):] authCtx, err := client.VerifyIDToken(ctx, token) if err != nil { - return nil, status.PublicErrorf(status.ErrUnauthenticated, "error verifying ID token: %w", err) + // Not public: the Admin SDK's text can name the project (an audience + // claim mismatch quotes the expected project ID). The caller gets + // UNAUTHENTICATED; the detail goes to the log and to GENKIT_ENV=dev. + return nil, status.Errorf(status.ErrUnauthenticated, "error verifying ID token: %w", err) } if policy != nil { diff --git a/go/plugins/firebase/exp/stream_manager.go b/go/plugins/firebase/exp/stream_manager.go index 39c72b4217..2b11f4efcd 100644 --- a/go/plugins/firebase/exp/stream_manager.go +++ b/go/plugins/firebase/exp/stream_manager.go @@ -79,6 +79,13 @@ type streamEntry struct { type streamError struct { Status string `firestore:"status"` Message string `firestore:"message"` + // Public records whether Message was safe to return to a client. Without + // it, a subscriber resuming the stream would have to guess, and treating + // every persisted message as public would let an internal failure from the + // producing process reach a client by round-tripping through Firestore. + // Absent on documents written before this field existed, which decodes to + // false: the safe default. + Public bool `firestore:"public,omitempty"` } // NewFirestoreStreamManager creates a [FirestoreStreamManager] for durable streaming. @@ -177,7 +184,7 @@ func (m *FirestoreStreamManager) Subscribe(ctx context.Context, streamID string) unsubscribed = true ch <- streaming.StreamEvent{ Type: streaming.StreamEventError, - Err: status.PublicErrorf(status.ErrDeadlineExceeded, "stream timed out"), + Err: status.PublicErrorf(streaming.ErrStreamTimeout, "stream %q timed out", streamID), } close(ch) cancelSnapshot() @@ -278,16 +285,25 @@ func (m *FirestoreStreamManager) Subscribe(ctx context.Context, streamID string) if !unsubscribed { errStatus := status.Unknown var errMsg string + var errPublic bool if entry.Err != nil { errMsg = entry.Err.Message + errPublic = entry.Err.Public if entry.Err.Status != "" { errStatus = status.Name(entry.Err.Status) } } + // Rebuild with the publicness the producer recorded, so a + // message that was never safe to return does not become + // safe by having been persisted. + rebuild := status.Errorf + if errPublic { + rebuild = status.PublicErrorf + } select { case ch <- streaming.StreamEvent{ Type: streaming.StreamEventError, - Err: status.PublicErrorf(status.Base(errStatus), "%s", errMsg), + Err: rebuild(status.Base(errStatus), "%s", errMsg), }: default: } @@ -393,9 +409,14 @@ func (s *firestoreStreamInput) Error(ctx context.Context, err error) error { } s.closed = true + // Persist the full message either way: Firestore is the developer's own + // store and the text is worth having for diagnosis. Public is what governs + // whether a subscriber may pass it on. + _, public := status.PublicMessage(err) streamErr := &streamError{ Status: string(status.Of(err)), Message: err.Error(), + Public: public, } // A status.Error stringifies as its bare message, but the deprecated // core.UserFacingError stringifies as "STATUS: message" while the status is diff --git a/go/plugins/googlegenai/gemini.go b/go/plugins/googlegenai/gemini.go index 29038a33ae..933f06fee1 100644 --- a/go/plugins/googlegenai/gemini.go +++ b/go/plugins/googlegenai/gemini.go @@ -78,12 +78,12 @@ func configFromRequest(input *ai.ModelRequest) (*genai.GenerateContentConfig, er var err error result, err = base.MapToStruct[genai.GenerateContentConfig](config) if err != nil { - return nil, status.PublicErrorf(status.ErrInvalidArgument, "The configuration settings are not in the correct format. Check that the names and values match what the model expects: %w", err) + return nil, status.Errorf(status.ErrInvalidArgument, "The configuration settings are not in the correct format. Check that the names and values match what the model expects: %w", err) } case nil: // Empty but valid config default: - return nil, status.PublicErrorf(status.ErrInvalidArgument, "Invalid configuration type: %T. Expected *genai.GenerateContentConfig. Ensure you are using the correct ModelRef helper (e.g., ModelRef) or passing the correct configuration struct.", input.Config) + return nil, status.Errorf(status.ErrInvalidArgument, "Invalid configuration type: %T. Expected *genai.GenerateContentConfig. Ensure you are using the correct ModelRef helper (e.g., ModelRef) or passing the correct configuration struct.", input.Config) } return &result, nil diff --git a/go/plugins/googlegenai/imagen.go b/go/plugins/googlegenai/imagen.go index 84fcaa6f74..9bf8403a70 100644 --- a/go/plugins/googlegenai/imagen.go +++ b/go/plugins/googlegenai/imagen.go @@ -41,12 +41,12 @@ func imagenConfigFromRequest(input *ai.ModelRequest) (*genai.GenerateImagesConfi var err error result, err = base.MapToStruct[genai.GenerateImagesConfig](config) if err != nil { - return nil, status.PublicErrorf(status.ErrInvalidArgument, "The image configuration settings are not in the correct format. Check that the names and values match what the model expects: %w", err) + return nil, status.Errorf(status.ErrInvalidArgument, "The image configuration settings are not in the correct format. Check that the names and values match what the model expects: %w", err) } case nil: // empty but valid config default: - return nil, status.PublicErrorf(status.ErrInvalidArgument, "Invalid configuration type: %T. Expected *genai.GenerateImagesConfig. Ensure you are using the correct ModelRef helper (e.g., ImageModelRef) or passing the correct configuration struct.", input.Config) + return nil, status.Errorf(status.ErrInvalidArgument, "Invalid configuration type: %T. Expected *genai.GenerateImagesConfig. Ensure you are using the correct ModelRef helper (e.g., ImageModelRef) or passing the correct configuration struct.", input.Config) } return &result, nil diff --git a/go/plugins/googlegenai/veo.go b/go/plugins/googlegenai/veo.go index afb321bec1..648a0d15db 100644 --- a/go/plugins/googlegenai/veo.go +++ b/go/plugins/googlegenai/veo.go @@ -218,11 +218,11 @@ func toVeoParameters(request *ai.ModelRequest) (*genai.GenerateVideosConfig, err var err error result, err = base.MapToStruct[genai.GenerateVideosConfig](config) if err != nil { - return nil, status.PublicErrorf(status.ErrInvalidArgument, "The video configuration settings are not in the correct format. Check that the names and values match what the model expects: %w", err) + return nil, status.Errorf(status.ErrInvalidArgument, "The video configuration settings are not in the correct format. Check that the names and values match what the model expects: %w", err) } return &result, nil default: - return nil, status.PublicErrorf(status.ErrInvalidArgument, "The configuration type %T is not supported. Use the correct configuration for this model (like VideoModelRef) or a configuration struct.", request.Config) + return nil, status.Errorf(status.ErrInvalidArgument, "The configuration type %T is not supported. Use the correct configuration for this model (like VideoModelRef) or a configuration struct.", request.Config) } } From 2f0cc3a5954b01b9de56fee74230b7c7f48e5810 Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Tue, 28 Jul 2026 10:50:13 -0700 Subject: [PATCH 07/14] fix(go): align sentinel usage and publicness at classification boundaries Code-review fixes on top of the status classification work: - firebase/exp: the Firestore stream manager now returns the streaming.ErrStream* subtype sentinels (not the base sentinels) so transport-level errors.Is branches, like the 204 resume path, behave the same as the in-memory manager - googlegenai: restore publicness on the six config-validation errors so deployed clients keep the authored remediation guidance - ai/exp: stop re-stamping session-store errors as INTERNAL; add context with fmt.Errorf so the store's own classification (and ctx cancellation) survives to the boundary; deliberate reclassifications are untouched - ai/exp: make the session-path snapshot miss public like its sibling not-found paths; classify detach's nil-store check with ErrNoSessionStore like the other capability checks - genkit: reflection v2 forces CANCELLED for wrapped cancellations, matching v1, and derives code/stack from a single status.Convert - middleware: fallback's model-lookup miss uses ai.ErrModelNotFound, and the Fallback.Statuses doc no longer contradicts the code --- go/ai/exp/agent.go | 8 ++++---- go/ai/exp/session.go | 8 ++++---- go/genkit/reflection_v2.go | 21 ++++++++++++--------- go/plugins/firebase/exp/stream_manager.go | 12 ++++++------ go/plugins/googlegenai/gemini.go | 4 ++-- go/plugins/googlegenai/imagen.go | 4 ++-- go/plugins/googlegenai/veo.go | 4 ++-- go/plugins/middleware/fallback.go | 4 ++-- 8 files changed, 34 insertions(+), 31 deletions(-) diff --git a/go/ai/exp/agent.go b/go/ai/exp/agent.go index 10c64677fa..7841ce3814 100644 --- a/go/ai/exp/agent.go +++ b/go/ai/exp/agent.go @@ -1303,7 +1303,7 @@ func (rt *agentRuntime[State]) handleTransformFailure( // the abort flip and promptly cancel the background work without polling). func (rt *agentRuntime[State]) checkDetachCapabilities() error { if rt.cfg.store == nil { - return status.Errorf(status.ErrFailedPrecondition, + return status.Errorf(ErrNoSessionStore, "agent %q: detach requires a session store", rt.name) } if _, ok := rt.cfg.store.(SnapshotSubscriber); !ok { @@ -1534,7 +1534,7 @@ func (rt *agentRuntime[State]) handleDetach( }) if err != nil { rt.drainAndWait(cancelWork) - return rt.failedOutput(clientCtx, status.Errorf(status.ErrInternal, "agent %q: detach: save pending snapshot: %w", rt.name, err)), nil + return rt.failedOutput(clientCtx, fmt.Errorf("agent %q: detach: save pending snapshot: %w", rt.name, err)), nil } // The router can no longer write to outCh once we return; the bidi // framework closes it shortly after. Post-detach chunks never entered @@ -1793,7 +1793,7 @@ func loadSession[State any]( } snap, err := store.GetSnapshot(ctx, init.SnapshotID) if err != nil { - return nil, nil, status.Errorf(status.ErrInternal, "failed to load snapshot %q: %w", init.SnapshotID, err) + return nil, nil, fmt.Errorf("failed to load snapshot %q: %w", init.SnapshotID, err) } if snap == nil { return nil, nil, status.PublicErrorf(ErrSnapshotNotFound, "snapshot %q not found", init.SnapshotID) @@ -1814,7 +1814,7 @@ func loadSession[State any]( } snap, err := store.GetLatestSnapshot(ctx, init.SessionID) if err != nil { - return nil, nil, status.Errorf(status.ErrInternal, "failed to resolve latest snapshot for session %q: %w", init.SessionID, err) + return nil, nil, fmt.Errorf("failed to resolve latest snapshot for session %q: %w", init.SessionID, err) } if snap == nil { // No snapshot exists for this session ID yet: the caller is diff --git a/go/ai/exp/session.go b/go/ai/exp/session.go index a83dc2980b..c7cd3f6747 100644 --- a/go/ai/exp/session.go +++ b/go/ai/exp/session.go @@ -223,7 +223,7 @@ func readSnapshot[State any]( if snapshotID != "" { snap, err = store.GetSnapshot(ctx, snapshotID) if err != nil { - return nil, status.Errorf(status.ErrInternal, "getSnapshot: %w", err) + return nil, fmt.Errorf("getSnapshot: %w", err) } if snap == nil { return nil, status.PublicErrorf(ErrSnapshotNotFound, "getSnapshot: snapshot %q not found", snapshotID) @@ -235,10 +235,10 @@ func readSnapshot[State any]( } else { snap, err = store.GetLatestSnapshot(ctx, sessionID) if err != nil { - return nil, status.Errorf(status.ErrInternal, "getSnapshot: %w", err) + return nil, fmt.Errorf("getSnapshot: %w", err) } if snap == nil { - return nil, status.Errorf(ErrSnapshotNotFound, "getSnapshot: no snapshot found for session %q", sessionID) + return nil, status.PublicErrorf(ErrSnapshotNotFound, "getSnapshot: no snapshot found for session %q", sessionID) } } @@ -312,7 +312,7 @@ func newSnapshotActions[State any]( // aborted; the store has no dedicated abort method. snapStatus, err := abortPendingSnapshot(ctx, store, req.SnapshotID) if err != nil { - return nil, status.Errorf(status.ErrInternal, "abort: %w", err) + return nil, fmt.Errorf("abort: %w", err) } if snapStatus == "" { return nil, status.PublicErrorf(ErrSnapshotNotFound, "abort: snapshot %q not found", req.SnapshotID) diff --git a/go/genkit/reflection_v2.go b/go/genkit/reflection_v2.go index 1e85b709c3..df313e38d6 100644 --- a/go/genkit/reflection_v2.go +++ b/go/genkit/reflection_v2.go @@ -798,9 +798,14 @@ func (s *reflectionServerV2) sendRunActionError(id string, err error, traceID st // stack. Suppressing them here would only hide the failure from the // developer causing it; the redaction that matters is at the flow HTTP // boundary (see clientError in servers.go). - code := status.Of(err) + e := status.Convert(err) + code := e.Status msg := err.Error() if errors.Is(err, context.Canceled) { + // A cancellation anywhere in the chain wins, even when an intermediate + // frame reclassified the error; the Dev UI keys on CANCELLED to tell a + // user-initiated cancel from a failure. + code = status.Cancelled msg = "Action was cancelled" } @@ -808,14 +813,12 @@ func (s *reflectionServerV2) sendRunActionError(id string, err error, traceID st if traceID != "" { details["traceId"] = traceID } - if e := status.Convert(err); e != nil { - // status.Errorf records the stack out of band; core.NewError still puts - // one in Details for compatibility. Prefer whichever is present. - if stack, ok := e.Details["stack"].(string); ok { - details["stack"] = stack - } else if stack := e.Stack(); stack != "" { - details["stack"] = stack - } + // status.Errorf records the stack out of band; core.NewError still puts + // one in Details for compatibility. Prefer whichever is present. + if stack, ok := e.Details["stack"].(string); ok { + details["stack"] = stack + } else if stack := e.Stack(); stack != "" { + details["stack"] = stack } data := map[string]any{ diff --git a/go/plugins/firebase/exp/stream_manager.go b/go/plugins/firebase/exp/stream_manager.go index 2b11f4efcd..627bf33d21 100644 --- a/go/plugins/firebase/exp/stream_manager.go +++ b/go/plugins/firebase/exp/stream_manager.go @@ -136,7 +136,7 @@ func (m *FirestoreStreamManager) Open(ctx context.Context, streamID string) (str }) if err != nil { if grpcstatus.Code(err) == codes.AlreadyExists { - return nil, status.PublicErrorf(status.ErrAlreadyExists, "stream already exists") + return nil, status.PublicErrorf(streaming.ErrStreamExists, "stream already exists") } return nil, err } @@ -154,12 +154,12 @@ func (m *FirestoreStreamManager) Subscribe(ctx context.Context, streamID string) snapshot, err := docRef.Get(ctx) if err != nil { if isNotFound(err) { - return nil, nil, status.PublicErrorf(status.ErrNotFound, "stream not found") + return nil, nil, status.PublicErrorf(streaming.ErrStreamNotFound, "stream not found") } return nil, nil, err } if !snapshot.Exists() { - return nil, nil, status.PublicErrorf(status.ErrNotFound, "stream not found") + return nil, nil, status.PublicErrorf(streaming.ErrStreamNotFound, "stream not found") } ch := make(chan streaming.StreamEvent, streamBufferSize) @@ -350,7 +350,7 @@ func (s *firestoreStreamInput) Write(ctx context.Context, chunk json.RawMessage) defer s.mu.Unlock() if s.closed { - return status.PublicErrorf(status.ErrFailedPrecondition, "stream writer is closed") + return status.PublicErrorf(streaming.ErrStreamClosed, "stream writer is closed") } _, err := s.docRef.Update(ctx, []firestore.Update{ @@ -375,7 +375,7 @@ func (s *firestoreStreamInput) Done(ctx context.Context, output json.RawMessage) defer s.mu.Unlock() if s.closed { - return status.PublicErrorf(status.ErrFailedPrecondition, "stream writer is closed") + return status.PublicErrorf(streaming.ErrStreamClosed, "stream writer is closed") } s.closed = true @@ -405,7 +405,7 @@ func (s *firestoreStreamInput) Error(ctx context.Context, err error) error { defer s.mu.Unlock() if s.closed { - return status.PublicErrorf(status.ErrFailedPrecondition, "stream writer is closed") + return status.PublicErrorf(streaming.ErrStreamClosed, "stream writer is closed") } s.closed = true diff --git a/go/plugins/googlegenai/gemini.go b/go/plugins/googlegenai/gemini.go index 933f06fee1..29038a33ae 100644 --- a/go/plugins/googlegenai/gemini.go +++ b/go/plugins/googlegenai/gemini.go @@ -78,12 +78,12 @@ func configFromRequest(input *ai.ModelRequest) (*genai.GenerateContentConfig, er var err error result, err = base.MapToStruct[genai.GenerateContentConfig](config) if err != nil { - return nil, status.Errorf(status.ErrInvalidArgument, "The configuration settings are not in the correct format. Check that the names and values match what the model expects: %w", err) + return nil, status.PublicErrorf(status.ErrInvalidArgument, "The configuration settings are not in the correct format. Check that the names and values match what the model expects: %w", err) } case nil: // Empty but valid config default: - return nil, status.Errorf(status.ErrInvalidArgument, "Invalid configuration type: %T. Expected *genai.GenerateContentConfig. Ensure you are using the correct ModelRef helper (e.g., ModelRef) or passing the correct configuration struct.", input.Config) + return nil, status.PublicErrorf(status.ErrInvalidArgument, "Invalid configuration type: %T. Expected *genai.GenerateContentConfig. Ensure you are using the correct ModelRef helper (e.g., ModelRef) or passing the correct configuration struct.", input.Config) } return &result, nil diff --git a/go/plugins/googlegenai/imagen.go b/go/plugins/googlegenai/imagen.go index 9bf8403a70..84fcaa6f74 100644 --- a/go/plugins/googlegenai/imagen.go +++ b/go/plugins/googlegenai/imagen.go @@ -41,12 +41,12 @@ func imagenConfigFromRequest(input *ai.ModelRequest) (*genai.GenerateImagesConfi var err error result, err = base.MapToStruct[genai.GenerateImagesConfig](config) if err != nil { - return nil, status.Errorf(status.ErrInvalidArgument, "The image configuration settings are not in the correct format. Check that the names and values match what the model expects: %w", err) + return nil, status.PublicErrorf(status.ErrInvalidArgument, "The image configuration settings are not in the correct format. Check that the names and values match what the model expects: %w", err) } case nil: // empty but valid config default: - return nil, status.Errorf(status.ErrInvalidArgument, "Invalid configuration type: %T. Expected *genai.GenerateImagesConfig. Ensure you are using the correct ModelRef helper (e.g., ImageModelRef) or passing the correct configuration struct.", input.Config) + return nil, status.PublicErrorf(status.ErrInvalidArgument, "Invalid configuration type: %T. Expected *genai.GenerateImagesConfig. Ensure you are using the correct ModelRef helper (e.g., ImageModelRef) or passing the correct configuration struct.", input.Config) } return &result, nil diff --git a/go/plugins/googlegenai/veo.go b/go/plugins/googlegenai/veo.go index 648a0d15db..afb321bec1 100644 --- a/go/plugins/googlegenai/veo.go +++ b/go/plugins/googlegenai/veo.go @@ -218,11 +218,11 @@ func toVeoParameters(request *ai.ModelRequest) (*genai.GenerateVideosConfig, err var err error result, err = base.MapToStruct[genai.GenerateVideosConfig](config) if err != nil { - return nil, status.Errorf(status.ErrInvalidArgument, "The video configuration settings are not in the correct format. Check that the names and values match what the model expects: %w", err) + return nil, status.PublicErrorf(status.ErrInvalidArgument, "The video configuration settings are not in the correct format. Check that the names and values match what the model expects: %w", err) } return &result, nil default: - return nil, status.Errorf(status.ErrInvalidArgument, "The configuration type %T is not supported. Use the correct configuration for this model (like VideoModelRef) or a configuration struct.", request.Config) + return nil, status.PublicErrorf(status.ErrInvalidArgument, "The configuration type %T is not supported. Use the correct configuration for this model (like VideoModelRef) or a configuration struct.", request.Config) } } diff --git a/go/plugins/middleware/fallback.go b/go/plugins/middleware/fallback.go index 0d32dad997..d15d6f00fb 100644 --- a/go/plugins/middleware/fallback.go +++ b/go/plugins/middleware/fallback.go @@ -63,7 +63,7 @@ type Fallback struct { Models []ai.ModelRef `json:"models,omitempty"` // Statuses is the set of status codes that trigger a fallback. // An error's status comes from [status.Of], so unclassified errors count - // non-GenkitError errors propagate immediately. + // as INTERNAL and trigger a fallback under the defaults. // Defaults to [defaultFallbackStatuses]. Statuses []status.Name `json:"statuses,omitempty"` } @@ -98,7 +98,7 @@ func (f *Fallback) wrapModel(ctx context.Context, params *ai.ModelParams, next a name := ref.Name() m := genkit.LookupModel(genkit.FromContext(ctx), name) if m == nil { - return nil, status.Errorf(status.ErrNotFound, "fallback: model %q not found", name) + return nil, status.Errorf(ai.ErrModelNotFound, "fallback: model %q not found", name) } req := *params.Request req.Config = ref.Config() From b19c66fd636ccc7cf37b321a97dc08a4895fac34 Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Tue, 28 Jul 2026 10:55:14 -0700 Subject: [PATCH 08/14] fix(go): make status.Error safe to inspect through a typed nil Genkit hands out *status.Error in places that are nil in the ordinary case: Convert returns nil for a nil error, and the generated AgentOutput.Error and SessionSnapshot.Error fields are nil whenever nothing failed. Returning one of those from a function whose result is error produces an interface that is non-nil but holds a nil pointer, and the first errors.Is or transport call on it panicked, typically inside a request handler. Error's methods now tolerate a nil receiver, and Of and PublicMessage check the pointer errors.As gave them rather than assuming it is non-nil. The guards have to live on the methods themselves: errors.Is invokes Is through a non-nil interface, so a check at the call site never runs. Error renders a nil receiver as "" rather than "", which would be indistinguishable from a real error carrying an empty message. Field access is necessarily still unguarded, and the type documents that. A typed nil classifies as OK, so clientError would have reported HTTP 200 for a request whose result was never written. It is only reached on a failure path, so an error classifying as OK is itself the bug and now reports INTERNAL. Reported by the review bot on #5829, which flagged the panics but not that the generated agent fields make them reachable rather than hypothetical. --- go/core/status/error.go | 63 ++++++++++++++++-- go/core/status/nil_test.go | 115 +++++++++++++++++++++++++++++++++ go/genkit/servers.go | 10 ++- go/genkit/servers_leak_test.go | 14 ++++ 4 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 go/core/status/nil_test.go diff --git a/go/core/status/error.go b/go/core/status/error.go index 50e095c8fc..bcb7173920 100644 --- a/go/core/status/error.go +++ b/go/core/status/error.go @@ -39,6 +39,21 @@ import ( // // Construct one with [Errorf] or [PublicErrorf]. To add context to an existing // error without reclassifying it, use fmt.Errorf with %w instead. +// +// # Nil receivers +// +// Error's methods, and the package functions that inspect an error, tolerate a +// nil *Error. This matters because Genkit hands out *Error in places that are +// nil in the ordinary case: [Convert] returns nil for a nil error, and the +// generated AgentOutput.Error and SessionSnapshot.Error fields are nil whenever +// nothing failed. Assigning one of those to an error variable produces an +// interface that is non-nil but holds a nil pointer, and without these guards +// the first errors.Is or transport call on it would panic, typically inside a +// request handler. +// +// Field access cannot be guarded the same way: e.Status on a nil *Error panics +// like any other nil dereference. Read fields only after checking for nil, or +// go through [Of] and [PublicMessage], which handle it. type Error struct { // Status is the canonical status name for this failure. Wire field "status". Status Name @@ -138,13 +153,25 @@ func (e *Error) WithCause(err error) *Error { // Error implements error. It returns Message alone: the sentinel is a // classification label, not a message prefix, so callers control the wording. -func (e *Error) Error() string { return e.Message } +// A nil *Error renders as "", matching how fmt prints a nil error, rather +// than as "" which would be indistinguishable from an empty message. +func (e *Error) Error() string { + if e == nil { + return "" + } + return e.Message +} // Unwrap returns the cause recorded via %w or [Error.WithCause], or nil. The // classifying sentinel is deliberately not part of the unwrap chain, so // errors.Unwrap and hand-rolled chain walks behave the way they do for any // fmt.Errorf result; [Error.Is] handles sentinel matching. -func (e *Error) Unwrap() error { return e.cause } +func (e *Error) Unwrap() error { + if e == nil { + return nil + } + return e.cause +} // Is reports whether e was classified by target or by a sentinel derived from // it. errors.Is consults this before walking [Error.Unwrap], so both @@ -153,17 +180,33 @@ func (e *Error) Unwrap() error { return e.cause } // errors.Is(err, ai.ErrMaxTurnsExceeded) // the specific sentinel // errors.Is(err, status.ErrAborted) // the base it derives from func (e *Error) Is(target error) bool { - return e.sentinel != nil && errors.Is(e.sentinel, target) + // errors.Is calls this whenever the interface is non-nil, including when it + // holds a nil *Error, so the nil check has to be here rather than at the + // call site. + if e == nil || e.sentinel == nil { + return false + } + return errors.Is(e.sentinel, target) } // Sentinel returns the sentinel that classified e, or nil if it was decoded // from the wire rather than constructed in this process. -func (e *Error) Sentinel() *Sentinel { return e.sentinel } +func (e *Error) Sentinel() *Sentinel { + if e == nil { + return nil + } + return e.sentinel +} // Stack returns the call stack captured when e was constructed, formatted like // a panic trace, or "" for an error decoded from the wire. It is formatted on // demand: construction only records program counters. -func (e *Error) Stack() string { return formatStack(e.stack) } +func (e *Error) Stack() string { + if e == nil { + return "" + } + return formatStack(e.stack) +} // Of returns the status of err. // @@ -180,6 +223,9 @@ func Of(err error) Name { } var e *Error if errors.As(err, &e) { + if e == nil { + return OK // a non-nil interface holding a nil *Error is not a failure + } return e.Status } var s *Sentinel @@ -197,7 +243,9 @@ func Of(err error) Name { // Convert returns err as an [Error], converting it if it is not one already. // The converted error takes its status from [Of] and is never public. Returns -// nil for a nil err. +// nil for a nil err, and also for an err that is a non-nil interface holding a +// nil *Error, so callers must check the result rather than assume it is +// non-nil. // // Prefer errors.As when you need to know whether err really is an [Error]; this // is for boundaries that must produce one either way. @@ -226,6 +274,9 @@ func PublicMessage(err error) (msg string, public bool) { } var e *Error if errors.As(err, &e) { + if e == nil { + return "", false + } if e.Public { return e.Message, true } diff --git a/go/core/status/nil_test.go b/go/core/status/nil_test.go new file mode 100644 index 0000000000..bc519d7fe8 --- /dev/null +++ b/go/core/status/nil_test.go @@ -0,0 +1,115 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package status + +import ( + "errors" + "fmt" + "testing" +) + +// agentOutput mirrors the generated AgentOutput: an exported *Error field that +// is nil whenever nothing failed. Returning that field from a function whose +// result is `error` is the ordinary way a typed nil escapes into Genkit's error +// handling, so every path below has to survive it. +type agentOutput struct { + Error *Error +} + +// failedRun is the shape of the bug: the field is nil, but the returned +// interface is not, because it carries the *Error type. +func failedRun() error { + out := &agentOutput{} // success: Error is nil + return out.Error +} + +func TestTypedNilIsNotMistakenForAFailure(t *testing.T) { + err := failedRun() + if err == nil { + t.Fatal("test is not exercising a typed nil") + } + + if got := Of(err); got != OK { + t.Errorf("Of = %q, want %q", got, OK) + } + msg, public := PublicMessage(err) + if public || msg != "" { + t.Errorf("PublicMessage = (%q, %v), want (\"\", false)", msg, public) + } + if got := Convert(err); got != nil { + t.Errorf("Convert = %v, want nil", got) + } +} + +// Each method has to tolerate the nil receiver on its own: errors.Is and the +// transports call them through a non-nil interface, so a guard at the call site +// would not help. +func TestNilReceiverMethodsDoNotPanic(t *testing.T) { + var e *Error + + if got := e.Error(); got != "" { + t.Errorf("Error() = %q, want %q", got, "") + } + if got := e.Unwrap(); got != nil { + t.Errorf("Unwrap() = %v, want nil", got) + } + if e.Is(ErrNotFound) { + t.Error("Is() = true on a nil receiver") + } + if got := e.Sentinel(); got != nil { + t.Errorf("Sentinel() = %v, want nil", got) + } + if got := e.Stack(); got != "" { + t.Errorf("Stack() = %q, want empty", got) + } +} + +// errors.Is and errors.As are the two that would panic first in practice, since +// any handler inspecting an error reaches for them before anything else. +func TestErrorsIsAndAsSurviveATypedNil(t *testing.T) { + err := failedRun() + + if errors.Is(err, ErrNotFound) { + t.Error("errors.Is = true for a typed nil") + } + var e *Error + if !errors.As(err, &e) { + t.Fatal("errors.As = false; the typed nil should still match its own type") + } + if e != nil { + t.Errorf("errors.As set e = %v, want nil", e) + } + + // And through a wrapper, which is how it usually arrives. + wrapped := fmt.Errorf("running agent: %w", err) + if errors.Is(wrapped, ErrNotFound) { + t.Error("errors.Is = true through a wrapped typed nil") + } + if got := Of(wrapped); got != OK { + t.Errorf("Of(wrapped) = %q, want %q", got, OK) + } +} + +// A nil *Error must not be confused with a real Error carrying an empty +// message: they are different situations and Error() distinguishes them. +func TestNilRendersDistinctlyFromAnEmptyMessage(t *testing.T) { + var nilErr *Error + empty := &Error{Status: Internal} + if nilErr.Error() == empty.Error() { + t.Errorf("nil and empty-message errors both render as %q", empty.Error()) + } +} diff --git a/go/genkit/servers.go b/go/genkit/servers.go index e5517d3d4b..7d85deb79e 100644 --- a/go/genkit/servers.go +++ b/go/genkit/servers.go @@ -166,11 +166,19 @@ func wrapHandler(h func(http.ResponseWriter, *http.Request) error) http.HandlerF // GENKIT_ENV=dev is exempt: suppressing the message during local development // only hides the failure from the developer causing it. func clientError(err error) (string, int) { + code := status.Of(err) + // Only reached on a failure path, so an error that classifies as OK is + // itself the bug: the usual cause is a non-nil interface holding a nil + // *status.Error, which would otherwise report success on a request whose + // result was never written. + if code == status.OK { + code = status.Internal + } msg, public := status.PublicMessage(err) if !public && api.CurrentEnvironment() == api.EnvironmentDev { msg = err.Error() } - return msg, status.Of(err).HTTPCode() + return msg, code.HTTPCode() } // handler returns an HTTP handler function that serves the action with the provided options. diff --git a/go/genkit/servers_leak_test.go b/go/genkit/servers_leak_test.go index 59e2cd5d24..2a837df108 100644 --- a/go/genkit/servers_leak_test.go +++ b/go/genkit/servers_leak_test.go @@ -147,3 +147,17 @@ func TestPublicErrorKeepsItsStatusCode(t *testing.T) { } } } + +// A non-nil error interface holding a nil *status.Error classifies as OK. The +// boundary is only reached on a failure path, so reporting 200 there would +// claim success for a request whose result was never written. +func TestTypedNilErrorDoesNotReportSuccess(t *testing.T) { + var typedNil *status.Error + msg, code := clientError(error(typedNil)) + if code != http.StatusInternalServerError { + t.Errorf("code = %d, want %d", code, http.StatusInternalServerError) + } + if msg != "" { + t.Errorf("msg = %q, want empty", msg) + } +} From 114b4208e296a28ed03fdb720355efa5e46ca80b Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Tue, 28 Jul 2026 13:29:29 -0700 Subject: [PATCH 09/14] feat(go/samples): add basic-errors sample and error handling in basic-agents basic-errors demonstrates the core/status error story end to end: producing classified errors (public INVALID_ARGUMENT, a custom NOT_FOUND subtype via Subtype, fmt.Errorf context wrapping), consuming them with errors.Is at base and subtype granularity (recovering from a missing recipe, falling back on a bogus model, degrading on provider overload), and the HTTP boundary redacting an unclassified error while public messages pass through. All documented curl paths verified against the live API. basic-agents gains two targeted touches: the banker tool classifies its amount validation with status.ErrInvalidArgument (the runtime wraps tool errors as ai.ErrToolFailed with the cause preserved for errors.Is), and the CLI prints an actionable hint keyed off the failed invocation's wire status instead of parsing message text. --- go/samples/basic-agents/banker.go | 11 ++ go/samples/basic-agents/cli.go | 12 ++ go/samples/basic-errors/main.go | 225 ++++++++++++++++++++++++++++++ 3 files changed, 248 insertions(+) create mode 100644 go/samples/basic-errors/main.go diff --git a/go/samples/basic-agents/banker.go b/go/samples/basic-agents/banker.go index 4b94f4bf77..07c658729a 100644 --- a/go/samples/basic-agents/banker.go +++ b/go/samples/basic-agents/banker.go @@ -37,6 +37,7 @@ import ( "github.com/firebase/genkit/go/ai" aix "github.com/firebase/genkit/go/ai/exp" "github.com/firebase/genkit/go/ai/exp/tool" + "github.com/firebase/genkit/go/core/status" "github.com/firebase/genkit/go/genkit" genkitx "github.com/firebase/genkit/go/genkit/exp" ) @@ -88,6 +89,16 @@ func defineBankerAgent(g *genkit.Genkit) *aix.Agent[any] { genkitx.DefineInterruptibleTool(g, "transferMoney", "Transfers money to another account. Use when the user wants to send money.", func(ctx context.Context, input TransferInput, confirm *Confirmation) (*TransferOutput, error) { + if input.Amount <= 0 { + // A tool error fails the turn. Classify it anyway: the + // runtime wraps it as ai.ErrToolFailed with the cause + // preserved, so server-side code can still branch with + // errors.Is(err, status.ErrInvalidArgument) instead of + // matching message text. + return nil, status.Errorf(status.ErrInvalidArgument, + "transfer amount must be positive, got $%.2f", input.Amount) + } + if confirm != nil { if !confirm.Approved { return &TransferOutput{Status: "cancelled", Message: "Transfer cancelled by user.", NewBalance: accountBalance}, nil diff --git a/go/samples/basic-agents/cli.go b/go/samples/basic-agents/cli.go index 82ac6ca3c2..3514635cfb 100644 --- a/go/samples/basic-agents/cli.go +++ b/go/samples/basic-agents/cli.go @@ -47,6 +47,7 @@ import ( "github.com/firebase/genkit/go/ai" aix "github.com/firebase/genkit/go/ai/exp" + "github.com/firebase/genkit/go/core/status" ) // ANSI styling for the small amount of tasteful color the CLI uses: cyan as @@ -561,6 +562,17 @@ repl: // snapshot to resume from. if out.Error != nil { fmt.Fprintf(os.Stderr, "%s\n", style(fmt.Sprintf("Agent failed (%s): %s", out.Error.Status, out.Error.Message), ansiYellow)) + // The output's error carries the status the failure was + // classified with, so the client can offer the right next step + // without parsing message text. + switch out.Error.Status { + case status.Unavailable, status.ResourceExhausted: + fmt.Println(style("The model looks overloaded. Resume from the snapshot below in a moment and try again.", ansiDim)) + case status.InvalidArgument: + fmt.Println(style("The model or a tool rejected the request. Rephrase and try again.", ansiDim)) + case status.Cancelled, status.DeadlineExceeded: + fmt.Println(style("The turn was cut short. Resume from the snapshot below to continue.", ansiDim)) + } } if out.SnapshotID != "" { fmt.Printf("%s\n", style(fmt.Sprintf("Last-good snapshot: %s. Pick this agent again to resume from it.", shortID(out.SnapshotID)), ansiDim)) diff --git a/go/samples/basic-errors/main.go b/go/samples/basic-errors/main.go new file mode 100644 index 0000000000..fb2c2325ea --- /dev/null +++ b/go/samples/basic-errors/main.go @@ -0,0 +1,225 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// This sample demonstrates how errors are classified, propagated, and +// handled in Genkit Go using the core/status package. +// +// The pattern, in one paragraph: classify a failure once, where its meaning +// is known, with status.Errorf and a sentinel (use status.PublicErrorf when +// the message is safe to show a client); add context up the stack with plain +// fmt.Errorf and %w, which preserves the classification; and branch on +// failures with errors.Is against a sentinel instead of matching message +// text. At the flow HTTP boundary the status picks the response code, and +// only public messages reach the client; everything else is redacted to a +// generic string (and still fully logged on the server). +// +// Three flows exercise the pieces: +// +// - cookbookFlow produces classified errors: a public INVALID_ARGUMENT for +// a bad request, and a custom NOT_FOUND subtype for an unknown dish. +// - improviseFlow consumes them: it branches on the custom sentinel to +// recover (improvise a recipe instead of failing), falls back to the +// default model when the requested model doesn't exist, and degrades +// gracefully when the model is overloaded. +// - leakyFlow fails with an unclassified error so you can see the boundary +// redact it. +// +// To run: +// +// go run . +// +// In another terminal: +// +// # Public INVALID_ARGUMENT: 400, the message reaches the client. +// curl -X POST http://localhost:8080/cookbookFlow \ +// -H "Content-Type: application/json" -d '{"data": ""}' +// +// # Custom NOT_FOUND subtype: 404 with the public message. +// curl -X POST http://localhost:8080/cookbookFlow \ +// -H "Content-Type: application/json" -d '{"data": "lasagna"}' +// +// # A dish in the cookbook: the model rewrites the stored recipe. +// curl -X POST http://localhost:8080/cookbookFlow \ +// -H "Content-Type: application/json" -d '{"data": "pancakes"}' +// +// # Recovery: not in the cookbook, so the flow improvises instead of 404ing. +// curl -X POST http://localhost:8080/improviseFlow \ +// -H "Content-Type: application/json" -d '{"data": {"dish": "lasagna"}}' +// +// # Misconfigured model: the flow catches the NOT_FOUND and retries with +// # the default model instead of failing. +// curl -X POST http://localhost:8080/improviseFlow \ +// -H "Content-Type: application/json" \ +// -d '{"data": {"dish": "pancakes", "model": "googleai/not-a-real-model"}}' +// +// # Unclassified error: 500, and the client sees only a generic message. +// # The real text (with its fake credentials) never leaves the process. +// curl -X POST http://localhost:8080/leakyFlow \ +// -H "Content-Type: application/json" -d '{"data": null}' +package main + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "strings" + + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/core/status" + "github.com/firebase/genkit/go/genkit" + "github.com/firebase/genkit/go/plugins/googlegenai" + "github.com/firebase/genkit/go/plugins/server" +) + +const defaultModel = "googleai/gemini-flash-latest" + +// ErrRecipeNotFound classifies lookups for dishes the cookbook doesn't have. +// Deriving a subtype from a base sentinel keeps the parent's status (and so +// its HTTP code, 404 here), and errors.Is matches it at either granularity: +// errors.Is(err, ErrRecipeNotFound) for this exact failure, or +// errors.Is(err, status.ErrNotFound) for any not-found. +var ErrRecipeNotFound = status.ErrNotFound.Subtype("recipe not found") + +// cookbook is the sample's tiny data store. +var cookbook = map[string]string{ + "pancakes": "Whisk 1 cup flour, 1 tbsp sugar, 1 tsp baking powder, 1 egg, and 3/4 cup milk. Fry ladlefuls in butter until golden on both sides.", + "shakshuka": "Simmer a can of crushed tomatoes with sauteed onion, garlic, and paprika. Crack in 4 eggs, cover, and cook until just set.", +} + +// lookupRecipe classifies the not-found case once, at the source. The +// message is built with PublicErrorf because it only reflects what the +// caller sent, so it is safe (and useful) to return to them. +func lookupRecipe(dish string) (string, error) { + recipe, ok := cookbook[strings.ToLower(dish)] + if !ok { + return "", status.PublicErrorf(ErrRecipeNotFound, "no recipe for %q in the cookbook", dish) + } + return recipe, nil +} + +func main() { + ctx := context.Background() + + g := genkit.Init(ctx, genkit.WithPlugins(&googlegenai.GoogleAI{})) + + // cookbookFlow produces classified errors and lets them propagate. Note + // what the flow does NOT do: it never inspects err.Error() text, and it + // wraps with fmt.Errorf (not a new status) when adding context, so the + // classification chosen at the source survives to the HTTP boundary. + genkit.DefineFlow(g, "cookbookFlow", func(ctx context.Context, dish string) (string, error) { + if strings.TrimSpace(dish) == "" { + // A bad request, described in terms of the caller's input: + // classify it INVALID_ARGUMENT and mark the message public so + // the client sees what to fix. Over HTTP this becomes a 400. + return "", status.PublicErrorf(status.ErrInvalidArgument, "dish must not be empty") + } + + recipe, err := lookupRecipe(dish) + if err != nil { + // Add context without reclassifying: %w keeps the sentinel, the + // status, and the public message reachable, so this still + // surfaces as a 404 rather than turning into a 500. + return "", fmt.Errorf("cookbookFlow: %w", err) + } + + return genkit.GenerateText(ctx, g, + ai.WithModelName(defaultModel), + ai.WithPrompt("Rewrite this recipe as three cheerful numbered steps: %s", recipe), + ) + }) + + // improviseFlow consumes classified errors: instead of letting failures + // propagate, it branches on sentinels with errors.Is and recovers. + type ImproviseInput struct { + Dish string `json:"dish"` + // Model optionally overrides the model name, so you can point the + // flow at a model that doesn't exist and watch the fallback branch. + Model string `json:"model,omitempty"` + } + genkit.DefineFlow(g, "improviseFlow", func(ctx context.Context, input ImproviseInput) (string, error) { + if strings.TrimSpace(input.Dish) == "" { + return "", status.PublicErrorf(status.ErrInvalidArgument, "dish must not be empty") + } + model := input.Model + if model == "" { + model = defaultModel + } + + prompt := "" + recipe, err := lookupRecipe(input.Dish) + switch { + case errors.Is(err, ErrRecipeNotFound): + // The exact failure this flow knows how to recover from: + // improvise a recipe rather than failing the request. + log.Printf("improviseFlow: %q not in the cookbook, improvising", input.Dish) + prompt = fmt.Sprintf("Invent a plausible three-step recipe for %s.", input.Dish) + case err != nil: + // Anything else is unexpected here: add context and propagate. + return "", fmt.Errorf("improviseFlow: %w", err) + default: + prompt = fmt.Sprintf("Rewrite this recipe as three cheerful numbered steps: %s", recipe) + } + + text, err := genkit.GenerateText(ctx, g, + ai.WithModelName(model), + ai.WithPrompt("%s", prompt), + ) + switch { + case errors.Is(err, status.ErrNotFound): + // A misconfigured model name is recoverable: fall back to the + // default model. Matching the base sentinel catches both ways + // the miss can surface: ai.ErrModelNotFound (a subtype of + // ErrNotFound) when the name resolves to no registered model, + // and the provider's own NOT_FOUND when the API rejects a name + // it doesn't recognize. status.Of extracts the status for + // logging. + log.Printf("improviseFlow: model %q not found (status %s), falling back to %s", model, status.Of(err), defaultModel) + text, err = genkit.GenerateText(ctx, g, + ai.WithModelName(defaultModel), + ai.WithPrompt("%s", prompt), + ) + if err != nil { + return "", fmt.Errorf("improviseFlow: fallback model: %w", err) + } + case errors.Is(err, status.ErrUnavailable), errors.Is(err, status.ErrResourceExhausted): + // Transient provider trouble: degrade gracefully instead of + // surfacing a 5xx. (The retry and fallback middleware in + // samples/basic-middleware automate this pattern.) + log.Printf("improviseFlow: model temporarily unavailable (%s), serving the plain recipe", status.Of(err)) + if recipe != "" { + return recipe, nil + } + return "", fmt.Errorf("improviseFlow: %w", err) + case err != nil: + return "", fmt.Errorf("improviseFlow: %w", err) + } + return text, nil + }) + + // leakyFlow shows the boundary protecting you: the error below is + // unclassified, so status.Of reports INTERNAL and the client gets a 500 + // with a generic message. The full text lands in the server log only. + // (Run with GENKIT_ENV=dev to see it unredacted during development.) + genkit.DefineFlow(g, "leakyFlow", func(ctx context.Context, _ any) (string, error) { + return "", errors.New("connecting to db at 10.0.0.3 as admin: password rejected") + }) + + mux := http.NewServeMux() + for _, a := range genkit.ListFlows(g) { + mux.HandleFunc("POST /"+a.Name(), genkit.Handler(a)) + } + log.Fatal(server.Start(ctx, "127.0.0.1:8080", mux)) +} From ee0ee1afbc2329b139834a595bfb29d0e3b3e7c9 Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Mon, 3 Aug 2026 11:11:16 -0700 Subject: [PATCH 10/14] fix(go): close review findings in classification, boundaries, and middleware A typed-nil *Error node in a chain classified the whole non-nil error as success: errors.As stops at the first match by type, so Of reported OK, Convert returned nil, and a mixed chain with a genuinely classified error alongside the nil node was masked too. Of, Convert, and PublicMessage now walk past nil nodes. A bare typed nil still classifies OK, but a wrapper around one is a real error and classifies as the rest of its chain. sendRunActionError also checks Convert's documented nil result instead of dereferencing it, which panicked the Dev UI handler. The SSE error frame derived its status separately from its message: clientError coerced an OK-classifying error to INTERNAL for the HTTP code, but writeSSEError recomputed status.Of and could emit an error frame claiming OK. clientError now returns the status.Name, and the HTTP path and the frame share one derivation. Streaming flow failures are also logged now: the runners write only the redacted message to the wire and return nil, so wrapHandler never saw the error and prod failures were recorded nowhere, contradicting clientError's own comment. Retry and Fallback return to their v1 contracts for unclassified errors: retry's doc promised non-GenkitError errors are always retried regardless of Statuses, and fallback's promised they propagate immediately. Mapping them to INTERNAL silently broke both in a minor: a narrowed Statuses list stopped retrying transient network errors, and a deterministic plugin bug rerouted every request to the billed fallback model. Classified errors still match against the configured list, and cancellation stays non-retryable. The Firestore stream manager persisted Public from the first *status.Error in the chain but Message from err.Error(), so an internal wrapper prefix around a public error was replayed to clients as public; it now persists the message PublicMessage cleared, which also subsumes the UserFacingError special case. failedOutput and the detach finalizer keep the fmt.Errorf context that Convert drops for a buried *status.Error. subscribeToStream matches any NOT_FOUND for the 204 path, per the StreamManager.Subscribe doc, rather than only the in-tree sentinel. Smaller: the generate loop classifies output-schema mismatches with ErrInvalidOutput to match the action layer; NewError restores OK the same way it restores non-canonical names instead of surfacing UNKNOWN/500; and NewError formats the stack Errorf already captured rather than capturing a second one with debug.Stack. --- go/ai/exp/agent.go | 31 ++++++++- go/ai/generate.go | 2 +- go/core/compat_test.go | 22 ++++++- go/core/error.go | 12 ++-- go/core/status/error.go | 79 ++++++++++++++++++----- go/core/status/nil_test.go | 35 +++++++++- go/core/status/sentinel.go | 5 +- go/genkit/reflection_v2.go | 6 ++ go/genkit/servers.go | 33 +++++++--- go/genkit/servers_leak_test.go | 12 ++-- go/plugins/firebase/exp/stream_manager.go | 27 ++++---- go/plugins/middleware/fallback.go | 20 +++--- go/plugins/middleware/fallback_test.go | 25 +++---- go/plugins/middleware/retry.go | 45 +++++++++---- go/plugins/middleware/retry_test.go | 28 ++++++++ 15 files changed, 292 insertions(+), 90 deletions(-) diff --git a/go/ai/exp/agent.go b/go/ai/exp/agent.go index 7841ce3814..2e38a5047d 100644 --- a/go/ai/exp/agent.go +++ b/go/ai/exp/agent.go @@ -1441,6 +1441,31 @@ func (rt *agentRuntime[State]) outboundState(ctx context.Context, state *Session return out, nil } +// convertKeepText returns cause as a *status.Error for a persisted payload +// (AgentOutput.Error, SessionSnapshot.Error), preserving both halves of the +// failure: the classification of a buried *status.Error (a store's own status +// survives, per [status.Convert]) and the full chain text of any context +// wrapped around it with fmt.Errorf, which Convert alone would drop. A public +// error is exempt from the text merge: its message may reach a client and must +// stay exactly what was cleared as public. A cause that is itself a non-nil +// interface holding a nil *status.Error still yields a payload, because a +// failed invocation must carry one. +func convertKeepText(cause error) *status.Error { + e := status.Convert(cause) + if e == nil { + if cause == nil { + return nil + } + return status.Errorf(status.ErrInternal, "%s", cause) + } + if !e.Public && e.Message != cause.Error() { + ne := *e + ne.Message = cause.Error() + return &ne + } + return e +} + // failedOutput assembles the output for an invocation that ended in // failure: [AgentFinishReasonFailed], the error with its original status, // and the last-good resume point: the last turn-end snapshot's ID when @@ -1455,7 +1480,7 @@ func (rt *agentRuntime[State]) failedOutput(ctx context.Context, cause error) *A out := &AgentOutput[State]{ SessionID: rt.session.SessionID(), FinishReason: AgentFinishReasonFailed, - Error: status.Convert(cause), + Error: convertKeepText(cause), } if rt.cfg.store == nil { // This is already the failure path, so a transform that also fails @@ -1714,12 +1739,12 @@ func (rt *agentRuntime[State]) finalizePendingSnapshot( snapStatus = SnapshotStatusAborted finishReason = AgentFinishReasonAborted if fnErr != nil { - snapErr = status.Convert(fnErr) // aborted wins, preserve text + snapErr = convertKeepText(fnErr) // aborted wins, preserve text } case fnErr != nil: snapStatus = SnapshotStatusFailed finishReason = AgentFinishReasonFailed - snapErr = status.Convert(fnErr) + snapErr = convertKeepText(fnErr) } // Preserve the pending row's CreatedAt (so the finalize does not diff --git a/go/ai/generate.go b/go/ai/generate.go index e64eb59f64..b1c256bdbc 100644 --- a/go/ai/generate.go +++ b/go/ai/generate.go @@ -465,7 +465,7 @@ func GenerateWithRequest(ctx context.Context, r api.Registry, opts *GenerateActi resp.Message, err = formatHandler.ParseMessage(resp.Message) if err != nil { logger.FromContext(ctx).Debug("model failed to generate output matching expected schema", "error", err.Error()) - return nil, status.Errorf(status.ErrInternal, "model failed to generate output matching expected schema: %w", err) + return nil, status.Errorf(status.ErrInvalidOutput, "model failed to generate output matching expected schema: %w", err) } } diff --git a/go/core/compat_test.go b/go/core/compat_test.go index 0af5e55094..52280d0f8d 100644 --- a/go/core/compat_test.go +++ b/go/core/compat_test.go @@ -20,6 +20,7 @@ import ( "errors" "fmt" "net/http" + "strings" "testing" "github.com/firebase/genkit/go/core" @@ -125,10 +126,27 @@ func TestV1ConstructorsPreserveV1Behaviour(t *testing.T) { } }) + t.Run("NewError keeps OK on the wire", func(t *testing.T) { + // OK is the one canonical name status.Base has no sentinel for (an + // error cannot classify as success), so like a non-canonical name it + // must be restored rather than surfacing as UNKNOWN/500. + err := core.NewError(core.OK, "done") + if err.Status != core.OK { + t.Errorf("Status = %q, want %q", err.Status, core.OK) + } + if err.HTTPCode != http.StatusOK { + t.Errorf("HTTPCode = %d, want 200", err.HTTPCode) + } + }) + t.Run("NewError records a stack in Details", func(t *testing.T) { err := core.NewError(core.INTERNAL, "boom") - if _, ok := err.Details["stack"]; !ok { - t.Error(`Details["stack"] missing`) + stack, ok := err.Details["stack"].(string) + if !ok || stack == "" { + t.Fatal(`Details["stack"] missing`) + } + if !strings.Contains(stack, "TestV1ConstructorsPreserveV1Behaviour") { + t.Errorf("stack does not reach the caller:\n%s", stack) } }) diff --git a/go/core/error.go b/go/core/error.go index 50a443452d..a2b8355772 100644 --- a/go/core/error.go +++ b/go/core/error.go @@ -27,7 +27,6 @@ package core import ( "fmt" - "runtime/debug" "github.com/firebase/genkit/go/core/status" ) @@ -125,11 +124,12 @@ func (e *UserFacingError) PublicMessage() (string, bool) { return e.Message, tru // last error argument that this function performs. func NewError(name StatusName, message string, args ...any) *GenkitError { ge := status.Errorf(status.Base(name), message, args...) - // status.Base coerces a name outside the canonical set to UNKNOWN, but v1 - // put whatever it was given on the wire. Restore it: this constructor's + // status.Base has no sentinel for names outside the canonical set (they + // coerce to UNKNOWN) or for OK (an error cannot classify as success), but + // v1 put whatever it was given on the wire. Restore it: this constructor's // contract is to behave exactly as it did, and the sentinel it was // classified with stays ErrUnknown, which is the honest classification. - if !name.IsValid() { + if ge.Status != name { ge.Status = name ge.HTTPCode = name.HTTPCode() } @@ -141,7 +141,9 @@ func NewError(name StatusName, message string, args ...any) *GenkitError { break } } - ge.Details = map[string]any{"stack": string(debug.Stack())} + // v1 recorded the stack in Details; format the one Errorf already captured + // rather than capturing a second with debug.Stack. + ge.Details = map[string]any{"stack": ge.Stack()} return ge } diff --git a/go/core/status/error.go b/go/core/status/error.go index bcb7173920..0f9d1dfd01 100644 --- a/go/core/status/error.go +++ b/go/core/status/error.go @@ -216,18 +216,22 @@ func (e *Error) Stack() string { // errors map to Cancelled and DeadlineExceeded. Anything else is Internal: an // unclassified failure is a failure of ours, not of the caller's request. // +// A typed-nil *Error carries no classification: when err itself is one, Of is +// OK (nothing failed, the nil merely escaped through an error variable), and +// when one appears inside a chain it is skipped so it cannot mask the rest of +// the chain. +// // Of(nil) is OK. func Of(err error) Name { if err == nil { return OK } - var e *Error - if errors.As(err, &e) { - if e == nil { - return OK // a non-nil interface holding a nil *Error is not a failure - } + if e := firstError(err); e != nil { return e.Status } + if e, ok := err.(*Error); ok && e == nil { + return OK // a non-nil interface holding a nil *Error is not a failure + } var s *Sentinel if errors.As(err, &s) { return s.status @@ -241,11 +245,55 @@ func Of(err error) Name { return Internal } +// firstError returns the first non-nil [Error] in err's chain, or nil. It is +// errors.As with one refinement: a typed-nil *Error node does not count as a +// match and does not end the search, so a nil that escaped through an error +// variable cannot mask a real classification elsewhere in the chain. +func firstError(err error) *Error { + var e *Error + if !errors.As(err, &e) { + return nil + } + if e != nil { + return e + } + // errors.As stopped at a typed-nil node. Nothing unwraps out of a nil + // *Error, but a multi-error wrapper can hold a real one in a sibling + // branch, so walk the tree skipping nil nodes. + return walkPastNil(err) +} + +func walkPastNil(err error) *Error { + if e, ok := err.(*Error); ok { + if e != nil { + return e + } + return nil + } + switch x := err.(type) { + case interface{ Unwrap() error }: + if u := x.Unwrap(); u != nil { + return walkPastNil(u) + } + case interface{ Unwrap() []error }: + for _, u := range x.Unwrap() { + if u == nil { + continue + } + if e := walkPastNil(u); e != nil { + return e + } + } + } + return nil +} + // Convert returns err as an [Error], converting it if it is not one already. // The converted error takes its status from [Of] and is never public. Returns -// nil for a nil err, and also for an err that is a non-nil interface holding a -// nil *Error, so callers must check the result rather than assume it is -// non-nil. +// nil for a nil err, and also for an err that is itself a non-nil interface +// holding a nil *Error, so callers must check the result rather than assume it +// is non-nil. A typed-nil *Error inside a larger chain is skipped instead: the +// chain is a real error and converts like any other. // // Prefer errors.As when you need to know whether err really is an [Error]; this // is for boundaries that must produce one either way. @@ -253,10 +301,12 @@ func Convert(err error) *Error { if err == nil { return nil } - var e *Error - if errors.As(err, &e) { + if e := firstError(err); e != nil { return e } + if e, ok := err.(*Error); ok && e == nil { + return nil + } n := Of(err) return &Error{Status: n, Message: err.Error(), HTTPCode: n.HTTPCode(), cause: err} } @@ -272,16 +322,15 @@ func PublicMessage(err error) (msg string, public bool) { if err == nil { return "", false } - var e *Error - if errors.As(err, &e) { - if e == nil { - return "", false - } + if e := firstError(err); e != nil { if e.Public { return e.Message, true } return genericMessage(e.Status), false } + if e, ok := err.(*Error); ok && e == nil { + return "", false + } // No Error in the chain: fall back to the interface, which the deprecated // core.UserFacingError implements so its message still reaches clients. var pm publicMessager diff --git a/go/core/status/nil_test.go b/go/core/status/nil_test.go index bc519d7fe8..2677ef645a 100644 --- a/go/core/status/nil_test.go +++ b/go/core/status/nil_test.go @@ -94,13 +94,42 @@ func TestErrorsIsAndAsSurviveATypedNil(t *testing.T) { t.Errorf("errors.As set e = %v, want nil", e) } - // And through a wrapper, which is how it usually arrives. + // And through a wrapper. Unlike the bare typed nil, the wrapper is a real + // error: someone was on a failure path when they built it, so it classifies + // as an unclassified failure rather than vanishing into OK. wrapped := fmt.Errorf("running agent: %w", err) if errors.Is(wrapped, ErrNotFound) { t.Error("errors.Is = true through a wrapped typed nil") } - if got := Of(wrapped); got != OK { - t.Errorf("Of(wrapped) = %q, want %q", got, OK) + if got := Of(wrapped); got != Internal { + t.Errorf("Of(wrapped) = %q, want %q", got, Internal) + } + if got := Convert(wrapped); got == nil { + t.Error("Convert(wrapped) = nil; the wrapper's message is lost") + } else if got.Message != wrapped.Error() { + t.Errorf("Convert(wrapped).Message = %q, want %q", got.Message, wrapped.Error()) + } +} + +// A typed nil next to a real classification must not mask it: errors.As stops +// at whichever node it visits first, but Of and Convert keep looking. +func TestTypedNilDoesNotMaskARealClassification(t *testing.T) { + real := Errorf(ErrNotFound, "model %q not found", "x") + + for name, err := range map[string]error{ + "nil first": fmt.Errorf("a: %w, b: %w", failedRun(), real), + "nil second": fmt.Errorf("a: %w, b: %w", real, failedRun()), + "joined": errors.Join(failedRun(), real), + } { + if got := Of(err); got != NotFound { + t.Errorf("%s: Of = %q, want %q", name, got, NotFound) + } + if got := Convert(err); got != real { + t.Errorf("%s: Convert = %v, want the real error", name, got) + } + if msg, _ := PublicMessage(err); msg != "not found" { + t.Errorf("%s: PublicMessage = %q, want the real error's generic label", name, msg) + } } } diff --git a/go/core/status/sentinel.go b/go/core/status/sentinel.go index 4800239102..f1076b746a 100644 --- a/go/core/status/sentinel.go +++ b/go/core/status/sentinel.go @@ -99,8 +99,9 @@ var ( // schema, e.g. a model produced malformed tool arguments. ErrInvalidInput = ErrInvalidArgument.Subtype("invalid input") - // ErrInvalidOutput means an action produced a value that does not match its - // declared output schema. This is a bug in the action, hence Internal. + // ErrInvalidOutput means an action or model produced a value that does not + // match the declared output schema. The fault is on the producing side, + // not the caller's request, hence Internal. ErrInvalidOutput = ErrInternal.Subtype("invalid output") // ErrActionNotFound means no action is registered under the requested key. diff --git a/go/genkit/reflection_v2.go b/go/genkit/reflection_v2.go index df313e38d6..293a1c750f 100644 --- a/go/genkit/reflection_v2.go +++ b/go/genkit/reflection_v2.go @@ -799,6 +799,12 @@ func (s *reflectionServerV2) sendRunActionError(id string, err error, traceID st // developer causing it; the redaction that matters is at the flow HTTP // boundary (see clientError in servers.go). e := status.Convert(err) + if e == nil { + // err was a non-nil interface holding a nil *status.Error, which + // Convert documents as returning nil. There is no classification or + // stack to mine, but the run did fail, so report it as internal. + e = &status.Error{Status: status.Internal, Message: err.Error()} + } code := e.Status msg := err.Error() if errors.Is(err, context.Canceled) { diff --git a/go/genkit/servers.go b/go/genkit/servers.go index 7d85deb79e..c7ed1faf80 100644 --- a/go/genkit/servers.go +++ b/go/genkit/servers.go @@ -149,23 +149,26 @@ func wrapHandler(h func(http.ResponseWriter, *http.Request) error) http.HandlerF if err = h(w, r); err != nil { msg, code := clientError(err) - http.Error(w, msg, code) + http.Error(w, msg, code.HTTPCode()) } } } -// clientError returns the message and HTTP status to send a client for err. +// clientError returns the message and status to send a client for err. Both +// the HTTP code (via [status.Name.HTTPCode]) and any wire status field must +// come from this one derivation so the two can never disagree. // // The status always comes from the error, so an error deliberately marked // public reaches the client with its own code rather than falling through to // 500. The message only leaves the process when the error was built with // [status.PublicErrorf]; anything else becomes a generic string derived from // the status, so schema dumps, provider text, and internal identifiers stay -// server-side. The full error is still logged by wrapHandler. +// server-side. The full error is still logged server-side: by wrapHandler for +// request failures, and by the streaming runners for mid-stream flow failures. // // GENKIT_ENV=dev is exempt: suppressing the message during local development // only hides the failure from the developer causing it. -func clientError(err error) (string, int) { +func clientError(err error) (string, status.Name) { code := status.Of(err) // Only reached on a failure path, so an error that classifies as OK is // itself the bug: the usual cause is a non-nil interface holding a nil @@ -178,7 +181,7 @@ func clientError(err error) (string, int) { if !public && api.CurrentEnvironment() == api.EnvironmentDev { msg = err.Error() } - return msg, code.HTTPCode() + return msg, code } // handler returns an HTTP handler function that serves the action with the provided options. @@ -304,6 +307,10 @@ func runWithStreaming(ctx context.Context, w http.ResponseWriter, run runJSONFun out, err := run(ctx, input, callback) if err != nil { + // The SSE frame carries only the redacted message and this function + // returns nil, so wrapHandler never sees the error: this log is the + // only server-side record of the real failure. + slog.ErrorContext(ctx, "streaming flow failed", "err", err) if werr := writeSSEError(w, err); werr != nil { return werr } @@ -359,6 +366,10 @@ func runWithDurableStreaming(ctx context.Context, w http.ResponseWriter, run run out, err := run(durableCtx, input, callback) if err != nil { + // As in runWithStreaming: the wire carries only the redacted message + // and wrapHandler never sees the error, so log the real failure here. + // The durable record is no substitute: it expires with the stream. + slog.ErrorContext(durableCtx, "streaming flow failed", "err", err) durableStream.Error(durableCtx, err) select { case <-clientGone: @@ -382,7 +393,11 @@ func runWithDurableStreaming(ctx context.Context, w http.ResponseWriter, run run func subscribeToStream(ctx context.Context, w http.ResponseWriter, sm streaming.StreamManager, streamID string) error { events, unsubscribe, err := sm.Subscribe(ctx, streamID) if err != nil { - if errors.Is(err, streaming.ErrStreamNotFound) { + // Subscribe's contract is any NOT_FOUND error, not the in-tree + // streaming.ErrStreamNotFound sentinel specifically, so match on the + // status: a third-party StreamManager returning a plain NOT_FOUND + // gets the 204 that resuming clients key on, not a 404. + if status.Of(err) == status.NotFound { w.WriteHeader(http.StatusNoContent) return nil } @@ -486,11 +501,13 @@ func writeSSEMessage(w http.ResponseWriter, msg json.RawMessage) error { } // writeSSEError writes an error as a server-sent event for streaming requests. +// Status and message come from the same clientError derivation, so the frame +// gets the identical redaction and OK-to-INTERNAL coercion as the HTTP path. func writeSSEError(w http.ResponseWriter, flowErr error) error { - msg, _ := clientError(flowErr) + msg, code := clientError(flowErr) resp := flowErrorResponse{ Error: &flowError{ - Status: status.Of(flowErr), + Status: code, Message: msg, }, } diff --git a/go/genkit/servers_leak_test.go b/go/genkit/servers_leak_test.go index 2a837df108..8b61cb75d4 100644 --- a/go/genkit/servers_leak_test.go +++ b/go/genkit/servers_leak_test.go @@ -122,8 +122,8 @@ func TestDevEnvironmentKeepsFullMessage(t *testing.T) { if !strings.Contains(msg, "googleai/nope") { t.Errorf("dev message = %q, want the full text", msg) } - if code != http.StatusNotFound { - t.Errorf("code = %d, want %d", code, http.StatusNotFound) + if code.HTTPCode() != http.StatusNotFound { + t.Errorf("code = %v, want %d", code, http.StatusNotFound) } } @@ -139,8 +139,8 @@ func TestPublicErrorKeepsItsStatusCode(t *testing.T) { {status.PublicErrorf(status.ErrInvalidArgument, "field %q is required", "email"), http.StatusBadRequest}, } { msg, code := clientError(tt.err) - if code != tt.code { - t.Errorf("code = %d, want %d", code, tt.code) + if code.HTTPCode() != tt.code { + t.Errorf("code = %v, want %d", code, tt.code) } if msg != tt.err.Error() { t.Errorf("msg = %q, want the public message %q", msg, tt.err.Error()) @@ -154,8 +154,8 @@ func TestPublicErrorKeepsItsStatusCode(t *testing.T) { func TestTypedNilErrorDoesNotReportSuccess(t *testing.T) { var typedNil *status.Error msg, code := clientError(error(typedNil)) - if code != http.StatusInternalServerError { - t.Errorf("code = %d, want %d", code, http.StatusInternalServerError) + if code != status.Internal { + t.Errorf("code = %v, want %v", code, status.Internal) } if msg != "" { t.Errorf("msg = %q, want empty", msg) diff --git a/go/plugins/firebase/exp/stream_manager.go b/go/plugins/firebase/exp/stream_manager.go index 627bf33d21..c4f486dbc4 100644 --- a/go/plugins/firebase/exp/stream_manager.go +++ b/go/plugins/firebase/exp/stream_manager.go @@ -29,7 +29,6 @@ import ( "google.golang.org/grpc/codes" grpcstatus "google.golang.org/grpc/status" - "github.com/firebase/genkit/go/core" "github.com/firebase/genkit/go/core/status" "github.com/firebase/genkit/go/core/x/streaming" "github.com/firebase/genkit/go/genkit" @@ -409,23 +408,23 @@ func (s *firestoreStreamInput) Error(ctx context.Context, err error) error { } s.closed = true - // Persist the full message either way: Firestore is the developer's own - // store and the text is worth having for diagnosis. Public is what governs - // whether a subscriber may pass it on. - _, public := status.PublicMessage(err) + // For a non-public error, persist the full text: Firestore is the + // developer's own store, the message is worth having for diagnosis, and + // Public=false keeps it from subscribers. For a public error, persist + // exactly the message PublicMessage cleared for clients, so an internal + // wrapper prefix around a public error cannot ride along and be replayed + // to a subscriber as public. This also keeps the deprecated + // core.UserFacingError's bare message rather than its "STATUS: message" + // stringification, which the Subscribe rebuild would double-prefix. + msg, public := status.PublicMessage(err) + if !public { + msg = err.Error() + } streamErr := &streamError{ Status: string(status.Of(err)), - Message: err.Error(), + Message: msg, Public: public, } - // A status.Error stringifies as its bare message, but the deprecated - // core.UserFacingError stringifies as "STATUS: message" while the status is - // carried separately here. Keep the bare message so the Subscribe path, which - // rebuilds the error from status + message, does not double-prefix it. - var ufErr *core.UserFacingError - if errors.As(err, &ufErr) { - streamErr.Message = ufErr.Message - } expiresAt := time.Now().Add(s.manager.ttl) _, updateErr := s.docRef.Update(ctx, []firestore.Update{ diff --git a/go/plugins/middleware/fallback.go b/go/plugins/middleware/fallback.go index d15d6f00fb..2432a26213 100644 --- a/go/plugins/middleware/fallback.go +++ b/go/plugins/middleware/fallback.go @@ -61,10 +61,9 @@ type Fallback struct { // Config is used verbatim for that model -- the original request's // Config is not inherited. Use [ai.NewModelRef] to attach config. Models []ai.ModelRef `json:"models,omitempty"` - // Statuses is the set of status codes that trigger a fallback. - // An error's status comes from [status.Of], so unclassified errors count - // as INTERNAL and trigger a fallback under the defaults. - // Defaults to [defaultFallbackStatuses]. + // Statuses is the set of status codes that trigger a fallback for + // classified errors; unclassified errors propagate immediately and never + // trigger one. Defaults to [defaultFallbackStatuses]. Statuses []status.Name `json:"statuses,omitempty"` } @@ -115,9 +114,14 @@ func (f *Fallback) wrapModel(ctx context.Context, params *ai.ModelParams, next a } // isFallbackRetryable reports whether err should trigger trying the next model: -// its status must be in statuses. Unclassified errors report INTERNAL, which is -// in the default set, so a network failure now falls back the same way it -// retries; previously only a *core.GenkitError could trigger fallback at all. +// a classified error's status must be in statuses, and an unclassified error +// propagates immediately, preserving the v1 contract. Failing over to a +// different billed model is a bigger action than retrying the same one, so it +// requires an explicit classification; without this, a deterministic bug in a +// model plugin would silently reroute every request to the fallback. func isFallbackRetryable(err error, statuses []status.Name) bool { - return slices.Contains(statuses, status.Of(err)) + if s, ok := classifiedStatus(err); ok { + return slices.Contains(statuses, s) + } + return false } diff --git a/go/plugins/middleware/fallback_test.go b/go/plugins/middleware/fallback_test.go index 19a27bbbd7..200ae17c9e 100644 --- a/go/plugins/middleware/fallback_test.go +++ b/go/plugins/middleware/fallback_test.go @@ -175,11 +175,12 @@ func TestFallbackDoesNotTriggerOnNonRetryableError(t *testing.T) { } } -// An unclassified error reports INTERNAL, which is in the default set, so it -// triggers fallback. Retry has always treated such errors as retryable; before -// status.Of, fallback ignored them, so the two middlewares disagreed about the -// same failure. -func TestFallbackTriggersOnUnclassifiedError(t *testing.T) { +// An unclassified error propagates immediately, per the v1 contract: failing +// over to a different billed model requires an explicit classification, or a +// deterministic bug in a model plugin would silently reroute every request to +// the fallback. (Retry treats the same error the opposite way, retrying it +// unconditionally; that asymmetry is also v1's.) +func TestFallbackPropagatesUnclassifiedError(t *testing.T) { g := newTestGenkit(t) secondaryCalls := 0 @@ -193,15 +194,15 @@ func TestFallbackTriggersOnUnclassifiedError(t *testing.T) { fb := &Fallback{Models: []ai.ModelRef{ai.NewModelRef(secondary.Name(), nil)}} - resp, err := genkit.Generate(ctx, g, ai.WithModel(primary), ai.WithPrompt("hello"), ai.WithUse(fb)) - if err != nil { - t.Fatalf("Generate() = %v, want fallback to succeed", err) + _, err := genkit.Generate(ctx, g, ai.WithModel(primary), ai.WithPrompt("hello"), ai.WithUse(fb)) + if err == nil { + t.Fatal("expected the unclassified error to propagate, got nil") } - if got := resp.Text(); got != "secondary" { - t.Errorf("text = %q, want %q", got, "secondary") + if !strings.Contains(err.Error(), "plain error") { + t.Errorf("error %q does not contain %q", err.Error(), "plain error") } - if secondaryCalls != 1 { - t.Errorf("secondary called %d times, want 1", secondaryCalls) + if secondaryCalls != 0 { + t.Errorf("secondary called %d times, want 0 (unclassified errors never trigger fallback)", secondaryCalls) } } diff --git a/go/plugins/middleware/retry.go b/go/plugins/middleware/retry.go index b0f2eba35d..4020c1ba39 100644 --- a/go/plugins/middleware/retry.go +++ b/go/plugins/middleware/retry.go @@ -20,6 +20,7 @@ package middleware import ( "context" + "errors" "math" "math/rand" "slices" @@ -56,11 +57,11 @@ var sleepFunc = func(ctx context.Context, d time.Duration) error { // It only hooks the Model stage — individual model API calls are retried, // not the entire generate loop. // -// An error is retried when its status is in Statuses, which defaults to -// UNAVAILABLE, DEADLINE_EXCEEDED, RESOURCE_EXHAUSTED, ABORTED, and INTERNAL. -// An unclassified error reports INTERNAL (see [status.Of]), so network failures -// and the like are retried by default; a cancelled context reports CANCELLED and -// is not. +// A classified error is retried when its status is in Statuses, which defaults +// to UNAVAILABLE, DEADLINE_EXCEEDED, RESOURCE_EXHAUSTED, ABORTED, and INTERNAL. +// An unclassified error (no [status.Error] or sentinel in its chain) is always +// retried, regardless of Statuses. A cancelled context reports CANCELLED and is +// not retried. // // Usage: // @@ -72,8 +73,8 @@ var sleepFunc = func(ctx context.Context, d time.Duration) error { type Retry struct { // MaxRetries is the maximum number of retry attempts. Defaults to 3. MaxRetries int `json:"maxRetries,omitempty"` - // Statuses is the set of status codes that trigger a retry. An error's - // status comes from [status.Of], so unclassified errors count as INTERNAL. + // Statuses is the set of status codes that trigger a retry for classified + // errors; unclassified errors are always retried regardless of this list. // Defaults to [defaultRetryStatuses]. Statuses []status.Name `json:"statuses,omitempty"` // InitialDelayMs is the delay before the first retry, in milliseconds. Defaults to 1000. @@ -168,9 +169,31 @@ func (r *Retry) wrapModel(ctx context.Context, params *ai.ModelParams, next ai.M return nil, lastErr } -// isRetryable reports whether err should trigger a retry: its status must be -// in statuses. Unclassified errors report INTERNAL, which is in the default -// set, so they stay retryable unless a caller narrows Statuses. +// isRetryable reports whether err should trigger a retry: a classified error's +// status must be in statuses, and an unclassified error is always retryable, +// preserving the v1 contract that non-GenkitError errors are retried +// regardless of the Statuses setting. func isRetryable(err error, statuses []status.Name) bool { - return slices.Contains(statuses, status.Of(err)) + if s, ok := classifiedStatus(err); ok { + return slices.Contains(statuses, s) + } + return true +} + +// classifiedStatus returns the status err was explicitly classified with, or +// false when nothing in err's chain carries one. The distinction keeps these +// middlewares matching their v1 contracts: a classified error is checked +// against the configured status list, while an unclassified one (a plain error +// from a provider SDK or the network) keeps its v1 behavior instead of +// silently inheriting INTERNAL's membership in the list. Cancellation and +// deadline expiry count as classified, reporting CANCELLED and +// DEADLINE_EXCEEDED per [status.Of]. +func classifiedStatus(err error) (status.Name, bool) { + var e *status.Error + var s *status.Sentinel + if errors.As(err, &e) || errors.As(err, &s) || + errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return status.Of(err), true + } + return "", false } diff --git a/go/plugins/middleware/retry_test.go b/go/plugins/middleware/retry_test.go index 679a4ccec3..248d8cf36a 100644 --- a/go/plugins/middleware/retry_test.go +++ b/go/plugins/middleware/retry_test.go @@ -162,6 +162,34 @@ func TestRetryRetriesNonGenkitErrors(t *testing.T) { } } +// The v1 contract: unclassified errors are retried regardless of Statuses, so +// narrowing the list must not silently stop retrying transient network errors. +func TestRetryRetriesUnclassifiedErrorWithNarrowedStatuses(t *testing.T) { + r := newTestRegistry(t) + calls := 0 + m := defineModel(t, r, "test/narrowed", func(ctx context.Context, req *ai.ModelRequest, cb ai.ModelStreamCallback) (*ai.ModelResponse, error) { + calls++ + if calls == 1 { + return nil, fmt.Errorf("connection reset") + } + return &ai.ModelResponse{Message: ai.NewModelTextMessage("ok")}, nil + }) + + retry := &Retry{Statuses: []core.StatusName{core.UNAVAILABLE}} + ai.DefineMiddleware(r, "retry", retry) + + resp, err := ai.Generate(ctx, r, ai.WithModel(m), ai.WithPrompt("hello"), ai.WithUse(retry)) + if err != nil { + t.Fatal(err) + } + if resp.Text() != "ok" { + t.Errorf("got %q, want %q", resp.Text(), "ok") + } + if calls != 2 { + t.Errorf("got %d calls, want 2 (unclassified errors retry regardless of Statuses)", calls) + } +} + func TestRetryCustomStatuses(t *testing.T) { r := newTestRegistry(t) calls := 0 From 032f0bea7223b4d08fb2e4ec809d5c4c06b10327 Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Mon, 3 Aug 2026 11:13:44 -0700 Subject: [PATCH 11/14] docs(go): add an Error Handling section to the README Shows the pattern the basic-errors sample demonstrates in full: classify once at the source with a sentinel (public when the message is safe for clients), add context with %w, branch with errors.Is, and let the flow boundary map status to HTTP code while redacting non-public messages. --- go/README.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/go/README.md b/go/README.md index 4b13b3d2f6..da4cf98e3c 100644 --- a/go/README.md +++ b/go/README.md @@ -964,6 +964,43 @@ e.Start(":8080") Any error returned by `genkit.HandlerFunc` will be handled by Echo's middleware stack. +### Error Handling + +Classify a failure once, at its source, with a sentinel from `core/status`; add context up the stack with plain `fmt.Errorf` and `%w`, which preserves the classification; and branch on failures with `errors.Is` instead of matching message text: + +```go +import "github.com/firebase/genkit/go/core/status" + +// Derive a subtype for failures you branch on. It keeps the parent's status +// (NOT_FOUND, so HTTP 404) and errors.Is matches at either granularity: +// this exact failure, or any status.ErrNotFound. +var ErrRecipeNotFound = status.ErrNotFound.Subtype("recipe not found") + +func lookupRecipe(dish string) (string, error) { + recipe, ok := cookbook[dish] + if !ok { + // PublicErrorf marks the message safe to return to clients. + return "", status.PublicErrorf(ErrRecipeNotFound, "no recipe for %q in the cookbook", dish) + } + return recipe, nil +} + +genkit.DefineFlow(g, "recipeFlow", func(ctx context.Context, dish string) (string, error) { + recipe, err := lookupRecipe(dish) + if errors.Is(err, ErrRecipeNotFound) { + return improvise(ctx, dish) // Recover from the one failure you expect. + } + if err != nil { + return "", fmt.Errorf("recipeFlow: %w", err) // Add context, keep the classification. + } + return recipe, nil +}) +``` + +At the flow HTTP boundary the status picks the response code (404 here), and only messages built with `status.PublicErrorf` reach the client. Everything else is redacted to a generic string and logged server-side, so provider text and internal identifiers stay out of responses; set `GENKIT_ENV=dev` to see messages unredacted during development. Framework failures classify the same way: `errors.Is(err, status.ErrNotFound)` catches a misconfigured model name, and `status.ErrUnavailable` or `status.ErrResourceExhausted` an overloaded provider. + +[See full example](samples/basic-errors) + ### Durable Streaming > [!WARNING] From 61d4577b36eaaca118c9e7123240774eca4beeb8 Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Mon, 3 Aug 2026 11:23:26 -0700 Subject: [PATCH 12/14] fix(go/plugins/anthropic): classify part conversion failures with sentinels The Anthropic message converter reported every conversion failure as a bare fmt.Errorf, so status.Of read them as INTERNAL and the flow boundary returned 500 for requests the caller could have fixed. A malformed data URI and an unsupported media type are both faults in the request, not the deployment. The four sites that describe the caller's content now carry the ai sentinels that already exist for them: ErrInvalidPart for a Part that cannot be parsed or has no conversion, and ErrUnsupportedByModel for a content type Anthropic does not accept, both of which derive from INVALID_ARGUMENT and so surface as 400. The remaining errors in the file (tool schema parsing, config type mismatches, request assembly) stay unclassified: they describe our own plumbing and INTERNAL is the honest answer. --- go/plugins/internal/anthropic/anthropic.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/go/plugins/internal/anthropic/anthropic.go b/go/plugins/internal/anthropic/anthropic.go index 1585425273..4faf378b14 100644 --- a/go/plugins/internal/anthropic/anthropic.go +++ b/go/plugins/internal/anthropic/anthropic.go @@ -20,7 +20,6 @@ import ( "context" "encoding/base64" "encoding/json" - "errors" "fmt" "reflect" "regexp" @@ -28,6 +27,7 @@ import ( "github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/core/api" + "github.com/firebase/genkit/go/core/status" "github.com/firebase/genkit/go/internal/base" pluginjsonschema "github.com/firebase/genkit/go/plugins/internal/jsonschema" "github.com/firebase/genkit/go/plugins/internal/uri" @@ -70,7 +70,7 @@ func metadataSignature(metadata map[string]any) []byte { func toAnthropicMediaBlock(p *ai.Part, kind string) (anthropic.ContentBlockParamUnion, error) { contentType, data, err := uri.Data(p) if err != nil { - return anthropic.ContentBlockParamUnion{}, fmt.Errorf("unable to parse %s part: %w", kind, err) + return anthropic.ContentBlockParamUnion{}, status.Errorf(ai.ErrInvalidPart, "unable to parse %s part: %w", kind, err) } switch { @@ -81,7 +81,7 @@ func toAnthropicMediaBlock(p *ai.Part, kind string) (anthropic.ContentBlockParam case contentType == "text/plain": return anthropic.NewDocumentBlock(anthropic.PlainTextSourceParam{Data: string(data)}), nil default: - return anthropic.ContentBlockParamUnion{}, fmt.Errorf( + return anthropic.ContentBlockParamUnion{}, status.Errorf(ai.ErrUnsupportedByModel, "unsupported %s content type %q: Anthropic accepts image/*, application/pdf, and text/plain", kind, contentType) } } @@ -478,7 +478,7 @@ func toAnthropicParts(parts []*ai.Part) ([]anthropic.ContentBlockParamUnion, err case p.IsReasoning(): blocks = append(blocks, anthropic.NewThinkingBlock(string(metadataSignature(p.Metadata)), p.Text)) default: - return nil, errors.New("unknown part type in the request") + return nil, status.Errorf(ai.ErrInvalidPart, "unknown part type in the request") } } @@ -518,7 +518,7 @@ func toGenkitResponse(m *anthropic.Message) (*ai.ModelResponse, error) { Name: part.Name, }) default: - return nil, fmt.Errorf("unknown part: %#v", part) + return nil, status.Errorf(ai.ErrInvalidPart, "unknown part: %#v", part) } msg.Content = append(msg.Content, p) } From 104d26ba99f7b2f78328551d04d12b7eae219997 Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Mon, 3 Aug 2026 11:23:26 -0700 Subject: [PATCH 13/14] docs(go): lead the README error section with framework sentinels The section opened with a user-defined sentinel, which showed the mechanism but not the payoff: the sentinels the framework already raises, which are what a reader gains from the change without writing any classification themselves. Leads with an errors.Is switch over the generation sentinels, then keeps a shorter user-defined example for deriving a subtype and returning a public message from a flow. The README sells capabilities rather than documenting them, so neither the sentinel set nor the status set is enumerated here; the package docs and the basic-errors sample carry the full picture. --- go/README.md | 51 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/go/README.md b/go/README.md index da4cf98e3c..6d1b7c2cc2 100644 --- a/go/README.md +++ b/go/README.md @@ -966,38 +966,49 @@ Any error returned by `genkit.HandlerFunc` will be handled by Echo's middleware ### Error Handling -Classify a failure once, at its source, with a sentinel from `core/status`; add context up the stack with plain `fmt.Errorf` and `%w`, which preserves the classification; and branch on failures with `errors.Is` instead of matching message text: +The framework classifies its own failures with sentinels, so you can tell what went wrong with `errors.Is` instead of matching message text. Each sentinel also matches the base it derives from, so you can branch at whichever granularity you need: ```go -import "github.com/firebase/genkit/go/core/status" +import ( + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/core/status" +) + +_, err := genkit.GenerateText(ctx, g, + ai.WithModelName("googleai/gemini-flash-latest"), + ai.WithPrompt("Summarize this."), +) +switch { +case errors.Is(err, ai.ErrModelNotFound): + // The plugin providing this model isn't registered in genkit.Init. +case errors.Is(err, ai.ErrMaxTurnsExceeded): + // The tool loop hit its limit; raise it with ai.WithMaxTurns. +case errors.Is(err, ai.ErrToolFailed): + // A tool returned an error. It's wrapped, so errors.As reaches yours. +case errors.Is(err, status.ErrResourceExhausted): + // Rate limited or out of quota: back off and retry. +} +``` + +Models, tools, prompts, and provider APIs all report failures this way, so recovery logic reads as a switch rather than a string match. + +Your own failures work the same way. Derive a subtype to keep a parent's status, and use `PublicErrorf` when the message is safe to return to a client: -// Derive a subtype for failures you branch on. It keeps the parent's status -// (NOT_FOUND, so HTTP 404) and errors.Is matches at either granularity: -// this exact failure, or any status.ErrNotFound. +```go +// Keeps NOT_FOUND (so HTTP 404), and matches both ErrRecipeNotFound +// and status.ErrNotFound. var ErrRecipeNotFound = status.ErrNotFound.Subtype("recipe not found") -func lookupRecipe(dish string) (string, error) { +genkit.DefineFlow(g, "recipeFlow", func(ctx context.Context, dish string) (string, error) { recipe, ok := cookbook[dish] if !ok { - // PublicErrorf marks the message safe to return to clients. - return "", status.PublicErrorf(ErrRecipeNotFound, "no recipe for %q in the cookbook", dish) - } - return recipe, nil -} - -genkit.DefineFlow(g, "recipeFlow", func(ctx context.Context, dish string) (string, error) { - recipe, err := lookupRecipe(dish) - if errors.Is(err, ErrRecipeNotFound) { - return improvise(ctx, dish) // Recover from the one failure you expect. - } - if err != nil { - return "", fmt.Errorf("recipeFlow: %w", err) // Add context, keep the classification. + return "", status.PublicErrorf(ErrRecipeNotFound, "no recipe for %q", dish) } return recipe, nil }) ``` -At the flow HTTP boundary the status picks the response code (404 here), and only messages built with `status.PublicErrorf` reach the client. Everything else is redacted to a generic string and logged server-side, so provider text and internal identifiers stay out of responses; set `GENKIT_ENV=dev` to see messages unredacted during development. Framework failures classify the same way: `errors.Is(err, status.ErrNotFound)` catches a misconfigured model name, and `status.ErrUnavailable` or `status.ErrResourceExhausted` an overloaded provider. +Wrapping with `fmt.Errorf` and `%w` preserves the classification, so context added up the stack costs you nothing. Served over HTTP, the status picks the response code and only `PublicErrorf` messages reach the client: everything else is redacted and logged server-side, so provider text and internal identifiers stay out of responses. Set `GENKIT_ENV=dev` to see them unredacted while developing. [See full example](samples/basic-errors) From ed3ece0c141f6ae4bc62ecb37d8456ef5074deea Mon Sep 17 00:00:00 2001 From: Alex Pascal Date: Mon, 3 Aug 2026 11:34:13 -0700 Subject: [PATCH 14/14] refactor(go): rename three sentinels to match what they classify ErrStreamClosed and ErrStreamCompleted were near-indistinguishable: both FAILED_PRECONDITION, both meaning a write cannot proceed, with labels that overlap ("stream writer is closed" against "stream has already completed"). The distinction is real, one is about the writer handle and the other about the stream's terminal state, but nothing in the names carried it, and a first reading of the pair guessed the wrong one for a write after Done. ErrStreamWriterClosed states which of the two it is, and both doc comments now name the other so the axis is visible from either side. ErrStreamExists disagreed with its own label and its parent: it derives from ErrAlreadyExists and reads "stream already exists". Now ErrStreamAlreadyExists. ErrNoSessionStore sat next to ErrSessionIDRequired using the opposite naming style for the same shape of failure. Renaming it Required would have been wrong, since the session ID is caller-supplied per request while the store is configured once at definition time, so the two are not the same kind of missing. ErrSessionStoreNotConfigured names the configuration gap, matches FAILED_PRECONDITION, and its label follows. Every other sentinel this branch adds was checked against its usage sites and left alone. ErrInvalidPart in particular is named for ai.Part, which is what all nine of its sites are about and a type callers construct themselves; a vaguer ErrInvalidContent would name Message.Content, a different and broader thing, and callers wanting the general case already have the parent status.ErrInvalidArgument. --- go/ai/exp/agent.go | 8 ++++---- go/ai/exp/errors.go | 8 +++++--- go/core/x/streaming/streaming.go | 22 ++++++++++++---------- go/core/x/streaming/streaming_test.go | 20 ++++++++++---------- go/plugins/firebase/exp/stream_manager.go | 8 ++++---- 5 files changed, 35 insertions(+), 31 deletions(-) diff --git a/go/ai/exp/agent.go b/go/ai/exp/agent.go index 2e38a5047d..33b411529f 100644 --- a/go/ai/exp/agent.go +++ b/go/ai/exp/agent.go @@ -592,7 +592,7 @@ func (a *Agent[State]) Store() SessionStore[State] { // INVALID_ARGUMENT when snapshotID is empty; a missing snapshot is NOT_FOUND. func (a *Agent[State]) GetSnapshot(ctx context.Context, snapshotID string) (*SessionSnapshot[State], error) { if a.store == nil { - return nil, status.Errorf(ErrNoSessionStore, "agent %q: GetSnapshot requires a session store", a.Name()) + return nil, status.Errorf(ErrSessionStoreNotConfigured, "agent %q: GetSnapshot requires a session store", a.Name()) } if snapshotID == "" { return nil, status.Errorf(status.ErrInvalidArgument, "agent %q: GetSnapshot: snapshotID is required", a.Name()) @@ -609,7 +609,7 @@ func (a *Agent[State]) GetSnapshot(ctx context.Context, snapshotID string) (*Ses // when sessionID is empty; an unknown session is NOT_FOUND. func (a *Agent[State]) GetLatestSnapshot(ctx context.Context, sessionID string) (*SessionSnapshot[State], error) { if a.store == nil { - return nil, status.Errorf(ErrNoSessionStore, "agent %q: GetLatestSnapshot requires a session store", a.Name()) + return nil, status.Errorf(ErrSessionStoreNotConfigured, "agent %q: GetLatestSnapshot requires a session store", a.Name()) } if sessionID == "" { return nil, status.Errorf(status.ErrInvalidArgument, "agent %q: GetLatestSnapshot: sessionID is required", a.Name()) @@ -628,7 +628,7 @@ func (a *Agent[State]) GetLatestSnapshot(ctx context.Context, sessionID string) // when snapshotID is empty. func (a *Agent[State]) Abort(ctx context.Context, snapshotID string) (SnapshotStatus, error) { if a.store == nil { - return "", status.Errorf(ErrNoSessionStore, "agent %q: Abort requires a session store", a.Name()) + return "", status.Errorf(ErrSessionStoreNotConfigured, "agent %q: Abort requires a session store", a.Name()) } if snapshotID == "" { return "", status.Errorf(status.ErrInvalidArgument, "agent %q: Abort: snapshotID is required", a.Name()) @@ -1303,7 +1303,7 @@ func (rt *agentRuntime[State]) handleTransformFailure( // the abort flip and promptly cancel the background work without polling). func (rt *agentRuntime[State]) checkDetachCapabilities() error { if rt.cfg.store == nil { - return status.Errorf(ErrNoSessionStore, + return status.Errorf(ErrSessionStoreNotConfigured, "agent %q: detach requires a session store", rt.name) } if _, ok := rt.cfg.store.(SnapshotSubscriber); !ok { diff --git a/go/ai/exp/errors.go b/go/ai/exp/errors.go index d7572b08cf..228da1398a 100644 --- a/go/ai/exp/errors.go +++ b/go/ai/exp/errors.go @@ -24,9 +24,11 @@ var ( // session has none yet. ErrSnapshotNotFound = status.ErrNotFound.Subtype("snapshot not found") - // ErrNoSessionStore means the operation needs server-managed state but the - // agent was defined without WithSessionStore. - ErrNoSessionStore = status.ErrFailedPrecondition.Subtype("no session store") + // ErrSessionStoreNotConfigured means the operation needs server-managed + // state but the agent was defined without WithSessionStore. It describes a + // gap in how the agent was set up, not anything the caller sent, which is + // why it is a failed precondition rather than an invalid argument. + ErrSessionStoreNotConfigured = status.ErrFailedPrecondition.Subtype("session store not configured") // ErrSessionIDRequired means a snapshot reached a store without a session ID. // Every store implementation rejects this: a snapshot with no session cannot diff --git a/go/core/x/streaming/streaming.go b/go/core/x/streaming/streaming.go index b65f881c0b..7438a6a081 100644 --- a/go/core/x/streaming/streaming.go +++ b/go/core/x/streaming/streaming.go @@ -92,12 +92,14 @@ var ( // ErrStreamNotFound means no stream is open under the given ID. Callers // resuming a stream treat this as "nothing to resume" rather than a failure. ErrStreamNotFound = status.ErrNotFound.Subtype("stream not found") - // ErrStreamExists means a stream is already open under the given ID. - ErrStreamExists = status.ErrAlreadyExists.Subtype("stream already exists") - // ErrStreamClosed means the writer was closed by a prior Done or Error. - ErrStreamClosed = status.ErrFailedPrecondition.Subtype("stream writer is closed") - // ErrStreamCompleted means the stream already reached a terminal state, so - // no further chunk, result, or error can be written to it. + // ErrStreamAlreadyExists means a stream is already open under the given ID. + ErrStreamAlreadyExists = status.ErrAlreadyExists.Subtype("stream already exists") + // ErrStreamWriterClosed means this writer handle is closed, by a prior Done + // or Error on it or by Close. It is about the handle in your hand, whereas + // ErrStreamCompleted is about the stream itself. + ErrStreamWriterClosed = status.ErrFailedPrecondition.Subtype("stream writer is closed") + // ErrStreamCompleted means the stream reached a terminal state, so no + // further chunk, result, or error can be written to it through any writer. ErrStreamCompleted = status.ErrFailedPrecondition.Subtype("stream has already completed") // ErrStreamTimeout means a subscriber gave up waiting for the next event. ErrStreamTimeout = status.ErrDeadlineExceeded.Subtype("stream timed out") @@ -213,7 +215,7 @@ func (m *InMemoryStreamManager) Open(ctx context.Context, streamID string) (Stre defer m.mu.Unlock() if _, exists := m.streams[streamID]; exists { - return nil, status.PublicErrorf(ErrStreamExists, "stream %q already exists", streamID) + return nil, status.PublicErrorf(ErrStreamAlreadyExists, "stream %q already exists", streamID) } state := &streamState{ @@ -300,7 +302,7 @@ func (s *inMemoryStreamInput) Write(_ context.Context, chunk json.RawMessage) er defer s.mu.Unlock() if s.closed { - return status.PublicErrorf(ErrStreamClosed, "stream %q: writer is closed", s.streamID) + return status.PublicErrorf(ErrStreamWriterClosed, "stream %q: writer is closed", s.streamID) } s.state.mu.Lock() @@ -330,7 +332,7 @@ func (s *inMemoryStreamInput) Done(_ context.Context, output json.RawMessage) er defer s.mu.Unlock() if s.closed { - return status.PublicErrorf(ErrStreamClosed, "stream %q: writer is closed", s.streamID) + return status.PublicErrorf(ErrStreamWriterClosed, "stream %q: writer is closed", s.streamID) } s.closed = true @@ -363,7 +365,7 @@ func (s *inMemoryStreamInput) Error(_ context.Context, err error) error { defer s.mu.Unlock() if s.closed { - return status.PublicErrorf(ErrStreamClosed, "stream %q: writer is closed", s.streamID) + return status.PublicErrorf(ErrStreamWriterClosed, "stream %q: writer is closed", s.streamID) } s.closed = true diff --git a/go/core/x/streaming/streaming_test.go b/go/core/x/streaming/streaming_test.go index da0e389870..2caf6ab6bb 100644 --- a/go/core/x/streaming/streaming_test.go +++ b/go/core/x/streaming/streaming_test.go @@ -74,8 +74,8 @@ func TestInMemoryStreamManager_OpenDuplicateFails(t *testing.T) { t.Fatal("Expected error when opening duplicate stream") } - if !errors.Is(err, ErrStreamExists) { - t.Errorf("error = %v, want one matching ErrStreamExists", err) + if !errors.Is(err, ErrStreamAlreadyExists) { + t.Errorf("error = %v, want one matching ErrStreamAlreadyExists", err) } } @@ -254,8 +254,8 @@ func TestInMemoryStreamManager_WriteAfterDone(t *testing.T) { // Done closes the writer, so a later write reports the closed writer rather // than the completed stream. Both are FAILED_PRECONDITION. - if !errors.Is(err, ErrStreamClosed) { - t.Errorf("error = %v, want one matching ErrStreamClosed", err) + if !errors.Is(err, ErrStreamWriterClosed) { + t.Errorf("error = %v, want one matching ErrStreamWriterClosed", err) } } @@ -281,8 +281,8 @@ func TestInMemoryStreamManager_WriteAfterClose(t *testing.T) { t.Fatal("Expected error when writing after close") } - if !errors.Is(err, ErrStreamClosed) { - t.Errorf("error = %v, want one matching ErrStreamClosed", err) + if !errors.Is(err, ErrStreamWriterClosed) { + t.Errorf("error = %v, want one matching ErrStreamWriterClosed", err) } } @@ -730,8 +730,8 @@ func TestInMemoryStreamManager_ErrorAfterClose(t *testing.T) { t.Fatal("Expected error when calling Error after Close") } - if !errors.Is(err, ErrStreamClosed) { - t.Errorf("error = %v, want one matching ErrStreamClosed", err) + if !errors.Is(err, ErrStreamWriterClosed) { + t.Errorf("error = %v, want one matching ErrStreamWriterClosed", err) } } @@ -757,7 +757,7 @@ func TestInMemoryStreamManager_DoneAfterClose(t *testing.T) { t.Fatal("Expected error when calling Done after Close") } - if !errors.Is(err, ErrStreamClosed) { - t.Errorf("error = %v, want one matching ErrStreamClosed", err) + if !errors.Is(err, ErrStreamWriterClosed) { + t.Errorf("error = %v, want one matching ErrStreamWriterClosed", err) } } diff --git a/go/plugins/firebase/exp/stream_manager.go b/go/plugins/firebase/exp/stream_manager.go index c4f486dbc4..9a0a8dbb59 100644 --- a/go/plugins/firebase/exp/stream_manager.go +++ b/go/plugins/firebase/exp/stream_manager.go @@ -135,7 +135,7 @@ func (m *FirestoreStreamManager) Open(ctx context.Context, streamID string) (str }) if err != nil { if grpcstatus.Code(err) == codes.AlreadyExists { - return nil, status.PublicErrorf(streaming.ErrStreamExists, "stream already exists") + return nil, status.PublicErrorf(streaming.ErrStreamAlreadyExists, "stream already exists") } return nil, err } @@ -349,7 +349,7 @@ func (s *firestoreStreamInput) Write(ctx context.Context, chunk json.RawMessage) defer s.mu.Unlock() if s.closed { - return status.PublicErrorf(streaming.ErrStreamClosed, "stream writer is closed") + return status.PublicErrorf(streaming.ErrStreamWriterClosed, "stream writer is closed") } _, err := s.docRef.Update(ctx, []firestore.Update{ @@ -374,7 +374,7 @@ func (s *firestoreStreamInput) Done(ctx context.Context, output json.RawMessage) defer s.mu.Unlock() if s.closed { - return status.PublicErrorf(streaming.ErrStreamClosed, "stream writer is closed") + return status.PublicErrorf(streaming.ErrStreamWriterClosed, "stream writer is closed") } s.closed = true @@ -404,7 +404,7 @@ func (s *firestoreStreamInput) Error(ctx context.Context, err error) error { defer s.mu.Unlock() if s.closed { - return status.PublicErrorf(streaming.ErrStreamClosed, "stream writer is closed") + return status.PublicErrorf(streaming.ErrStreamWriterClosed, "stream writer is closed") } s.closed = true