diff --git a/go/README.md b/go/README.md index 4b13b3d2f6..6d1b7c2cc2 100644 --- a/go/README.md +++ b/go/README.md @@ -964,6 +964,54 @@ e.Start(":8080") Any error returned by `genkit.HandlerFunc` will be handled by Echo's middleware stack. +### Error Handling + +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/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: + +```go +// Keeps NOT_FOUND (so HTTP 404), and matches both ErrRecipeNotFound +// and status.ErrNotFound. +var ErrRecipeNotFound = status.ErrNotFound.Subtype("recipe not found") + +genkit.DefineFlow(g, "recipeFlow", func(ctx context.Context, dish string) (string, error) { + recipe, ok := cookbook[dish] + if !ok { + return "", status.PublicErrorf(ErrRecipeNotFound, "no recipe for %q", dish) + } + return recipe, nil +}) +``` + +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) + ### Durable Streaming > [!WARNING] 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/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/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/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/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/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..33b411529f 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(ErrSessionStoreNotConfigured, "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(ErrSessionStoreNotConfigured, "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(ErrSessionStoreNotConfigured, "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(ErrSessionStoreNotConfigured, "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 @@ -1439,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 @@ -1453,7 +1480,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: convertKeepText(cause), } if rt.cfg.store == nil { // This is already the failure path, so a transform that also fails @@ -1532,8 +1559,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, 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 @@ -1702,23 +1728,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 = convertKeepText(fnErr) // aborted wins, preserve text } case fnErr != nil: - status = SnapshotStatusFailed + snapStatus = SnapshotStatusFailed finishReason = AgentFinishReasonFailed - snapErr = core.AsGenkitError(fnErr) + snapErr = convertKeepText(fnErr) } // Preserve the pending row's CreatedAt (so the finalize does not @@ -1727,7 +1753,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 +1788,19 @@ 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)") } + // 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, 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 +1813,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, fmt.Errorf("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.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 // 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, 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 @@ -1819,7 +1850,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 +1872,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 +2482,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 +2490,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 +2551,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 +2569,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 +2598,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 +2810,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/errors.go b/go/ai/exp/errors.go new file mode 100644 index 0000000000..228da1398a --- /dev/null +++ b/go/ai/exp/errors.go @@ -0,0 +1,37 @@ +// 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") + + // 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 + // be resolved back to a conversation. + ErrSessionIDRequired = status.ErrInvalidArgument.Subtype("session ID is required") +) 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/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..c7cd3f6747 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, fmt.Errorf("getSnapshot: %w", err) } if snap == nil { - return nil, core.NewError(core.NOT_FOUND, "getSnapshot: snapshot %q not found", snapshotID) + return nil, status.PublicErrorf(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, fmt.Errorf("getSnapshot: %w", err) } if snap == nil { - return nil, core.NewError(core.NOT_FOUND, "getSnapshot: no snapshot found for session %q", sessionID) + return nil, status.PublicErrorf(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, fmt.Errorf("abort: %w", err) } - if status == "" { - return nil, core.NewError(core.NOT_FOUND, "abort: snapshot %q not found", req.SnapshotID) + if snapStatus == "" { + return nil, status.PublicErrorf(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 8db90fbfea..b1c256bdbc 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.ErrInvalidOutput, "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(ErrInvalidPart, "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, 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, 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(ErrInvalidPart, "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, fmt.Sprintf("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..6c70052415 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(ErrInvalidPart, "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(ErrInvalidPart, "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 648d22a449..fa5629de73 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" ) @@ -227,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.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 { @@ -269,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. @@ -314,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 } @@ -341,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 } @@ -414,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) @@ -485,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() @@ -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 @@ -542,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") } }() @@ -554,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 { @@ -569,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") } } @@ -694,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/compat_test.go b/go/core/compat_test.go new file mode 100644 index 0000000000..52280d0f8d --- /dev/null +++ b/go/core/compat_test.go @@ -0,0 +1,234 @@ +// 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" + "strings" + "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 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 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") + 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) + } + }) + + 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/doc.go b/go/core/doc.go index aca636df1e..1445dcd7f2 100644 --- a/go/core/doc.go +++ b/go/core/doc.go @@ -197,10 +197,17 @@ 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 { - 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 +215,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/core/error.go b/go/core/error.go index e38ac09992..a2b8355772 100644 --- a/go/core/error.go +++ b/go/core/error.go @@ -15,25 +15,35 @@ // 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 +52,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 +86,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 +101,104 @@ func (e *UserFacingError) Error() string { return fmt.Sprintf("%s: %s", e.Status, e.Message) } -// NewError creates a new GenkitError with a stack trace. -func NewError(status StatusName, message string, args ...any) *GenkitError { - msg := 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) } - ge := &GenkitError{ - Status: status, - Message: fmt.Sprintf(msg, args...), - } +// 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 } - // scan args for the last error to wrap it (Iterate backwards) +// 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(name StatusName, message string, args ...any) *GenkitError { + ge := status.Errorf(status.Base(name), message, args...) + // 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 ge.Status != name { + 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-- { 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 - } + // 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 } -// 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..0f9d1dfd01 --- /dev/null +++ b/go/core/status/error.go @@ -0,0 +1,446 @@ +// 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. +// +// # 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 + // 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. +// 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 { + 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 +// 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 { + // 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 { + 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 { + if e == nil { + return "" + } + 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. +// +// 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 + } + 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 + } + switch { + case errors.Is(err, context.Canceled): + return Cancelled + case errors.Is(err, context.DeadlineExceeded): + return DeadlineExceeded + } + 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 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. +func Convert(err error) *Error { + if err == nil { + return nil + } + 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} +} + +// 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 + } + 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 + 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 + } + 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/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/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/nil_test.go b/go/core/status/nil_test.go new file mode 100644 index 0000000000..2677ef645a --- /dev/null +++ b/go/core/status/nil_test.go @@ -0,0 +1,144 @@ +// 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. 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 != 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) + } + } +} + +// 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/core/status/sentinel.go b/go/core/status/sentinel.go new file mode 100644 index 0000000000..f1076b746a --- /dev/null +++ b/go/core/status/sentinel.go @@ -0,0 +1,146 @@ +// 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 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. + 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/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/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/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) + } +} 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/core/x/streaming/streaming.go b/go/core/x/streaming/streaming.go index 3fb51a3341..7438a6a081 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,25 @@ 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") + // 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") +) + // streamState holds the internal state of a single stream. type streamState struct { status streamStatus @@ -196,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, core.NewPublicError(core.ALREADY_EXISTS, "stream already exists", nil) + return nil, status.PublicErrorf(ErrStreamAlreadyExists, "stream %q already exists", streamID) } state := &streamState{ @@ -221,7 +240,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 +302,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(ErrStreamWriterClosed, "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 +332,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(ErrStreamWriterClosed, "stream %q: writer is closed", s.streamID) } s.closed = true @@ -321,7 +340,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 +365,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(ErrStreamWriterClosed, "stream %q: writer is closed", s.streamID) } s.closed = true @@ -354,7 +373,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..2caf6ab6bb 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, ErrStreamAlreadyExists) { + t.Errorf("error = %v, want one matching ErrStreamAlreadyExists", 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, ErrStreamWriterClosed) { + t.Errorf("error = %v, want one matching ErrStreamWriterClosed", 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, ErrStreamWriterClosed) { + t.Errorf("error = %v, want one matching ErrStreamWriterClosed", 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, ErrStreamWriterClosed) { + t.Errorf("error = %v, want one matching ErrStreamWriterClosed", 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, ErrStreamWriterClosed) { + t.Errorf("error = %v, want one matching ErrStreamWriterClosed", err) } } diff --git a/go/genkit/reflection.go b/go/genkit/reflection.go index 53aca964bc..1627a252a2 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, err.Error()) + 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, err.Error()) + 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, err.Error()) + 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.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..293a1c750f 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,24 @@ 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). + 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) { - code = core.CANCELLED + // 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" } @@ -803,15 +819,16 @@ 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 { - 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{ - "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 1eb34f4a4c..c7ed1faf80 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. @@ -146,16 +148,42 @@ 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.HTTPCode()) } } } +// 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 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, 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 + // *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, code +} + // 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 { @@ -171,7 +199,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) } } @@ -279,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 } @@ -334,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: @@ -357,8 +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 { - var ufErr *core.UserFacingError - if errors.As(err, &ufErr) && ufErr.Status == core.NOT_FOUND { + // 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 } @@ -416,10 +455,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. @@ -460,21 +501,14 @@ 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 { - 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, code := clientError(flowErr) resp := flowErrorResponse{ Error: &flowError{ - Status: status, - Message: "stream flow error", - Details: flowErr.Error(), + Status: code, + Message: msg, }, } data, err := json.Marshal(resp) @@ -491,7 +525,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/genkit/servers_leak_test.go b/go/genkit/servers_leak_test.go new file mode 100644 index 0000000000..8b61cb75d4 --- /dev/null +++ b/go/genkit/servers_leak_test.go @@ -0,0 +1,163 @@ +// 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.HTTPCode() != http.StatusNotFound { + t.Errorf("code = %v, 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.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()) + } + } +} + +// 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 != status.Internal { + t.Errorf("code = %v, want %v", code, status.Internal) + } + if msg != "" { + t.Errorf("msg = %q, want empty", msg) + } +} diff --git a/go/genkit/servers_test.go b/go/genkit/servers_test.go index 6500d40f22..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) { @@ -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)) } }) @@ -122,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) } }) @@ -143,8 +146,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)) } }) @@ -164,12 +167,18 @@ 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 error message in response body, 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)) } }) - 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 +190,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 +432,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 { diff --git a/go/plugins/firebase/auth.go b/go/plugins/firebase/auth.go index 9280973232..c229840cb1 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,22 @@ 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) + // 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/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..9a0a8dbb59 100644 --- a/go/plugins/firebase/exp/stream_manager.go +++ b/go/plugins/firebase/exp/stream_manager.go @@ -25,12 +25,13 @@ import ( "time" "cloud.google.com/go/firestore" - "github.com/firebase/genkit/go/core" - "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" + grpcstatus "google.golang.org/grpc/status" + + "github.com/firebase/genkit/go/core/status" + "github.com/firebase/genkit/go/core/x/streaming" + "github.com/firebase/genkit/go/genkit" ) const ( @@ -77,6 +78,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. @@ -126,8 +134,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(streaming.ErrStreamAlreadyExists, "stream already exists") } return nil, err } @@ -145,12 +153,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(streaming.ErrStreamNotFound, "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(streaming.ErrStreamNotFound, "stream not found") } ch := make(chan streaming.StreamEvent, streamBufferSize) @@ -175,7 +183,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(streaming.ErrStreamTimeout, "stream %q timed out", streamID), } close(ch) cancelSnapshot() @@ -274,18 +282,27 @@ 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 + var errPublic bool if entry.Err != nil { errMsg = entry.Err.Message + errPublic = entry.Err.Public if entry.Err.Status != "" { - errStatus = core.StatusName(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: core.NewPublicError(errStatus, errMsg, nil), + Err: rebuild(status.Base(errStatus), "%s", errMsg), }: default: } @@ -312,7 +329,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 +349,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(streaming.ErrStreamWriterClosed, "stream writer is closed") } _, err := s.docRef.Update(ctx, []firestore.Update{ @@ -357,7 +374,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(streaming.ErrStreamWriterClosed, "stream writer is closed") } s.closed = true @@ -387,22 +404,26 @@ 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(streaming.ErrStreamWriterClosed, "stream writer is closed") } s.closed = true - streamErr := &streamError{ - Status: string(core.UNKNOWN), - Message: err.Error(), + // 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() } - 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 + streamErr := &streamError{ + Status: string(status.Of(err)), + Message: msg, + Public: public, } expiresAt := time.Now().Add(s.manager.ttl) 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/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) } diff --git a/go/plugins/middleware/fallback.go b/go/plugins/middleware/fallback.go index a22c4bfc07..2432a26213 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 @@ -62,11 +61,10 @@ 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. - // Only [core.GenkitError] errors with a matching status will trigger fallback; - // non-GenkitError errors propagate immediately. - // Defaults to [defaultFallbackStatuses]. - Statuses []core.StatusName `json:"statuses,omitempty"` + // 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"` } func (f *Fallback) Name() string { return provider + "/fallback" } @@ -77,7 +75,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 +97,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(ai.ErrModelNotFound, "fallback: model %q not found", name) } req := *params.Request req.Config = ref.Config() @@ -115,12 +113,15 @@ 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 +// isFallbackRetryable reports whether err should trigger trying the next model: +// 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 { + if s, ok := classifiedStatus(err); ok { + return slices.Contains(statuses, s) } - return slices.Contains(statuses, ge.Status) + return false } diff --git a/go/plugins/middleware/fallback_test.go b/go/plugins/middleware/fallback_test.go index 27c8b7b949..200ae17c9e 100644 --- a/go/plugins/middleware/fallback_test.go +++ b/go/plugins/middleware/fallback_test.go @@ -175,7 +175,12 @@ func TestFallbackDoesNotTriggerOnNonRetryableError(t *testing.T) { } } -func TestFallbackDoesNotTriggerOnNonGenkitError(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 @@ -191,10 +196,37 @@ func TestFallbackDoesNotTriggerOnNonGenkitError(t *testing.T) { _, 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 !strings.Contains(err.Error(), "plain error") { + t.Errorf("error %q does not contain %q", err.Error(), "plain error") + } + if secondaryCalls != 0 { + t.Errorf("secondary called %d times, want 0 (unclassified errors never trigger fallback)", 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..4020c1ba39 100644 --- a/go/plugins/middleware/retry.go +++ b/go/plugins/middleware/retry.go @@ -27,16 +27,16 @@ import ( "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 +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. // -// 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. +// 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: // @@ -71,10 +73,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 for classified + // errors; unclassified errors are always retried regardless of this list. // 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 +103,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 +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. -// 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 +// 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 { + if s, ok := classifiedStatus(err); ok { + return slices.Contains(statuses, s) } - return slices.Contains(statuses, ge.Status) + 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 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)) +}