Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
5 changes: 3 additions & 2 deletions go/ai/background_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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{
Expand All @@ -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")
}
}

Expand Down
7 changes: 4 additions & 3 deletions go/ai/document.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down
3 changes: 2 additions & 1 deletion go/ai/embedder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
60 changes: 60 additions & 0 deletions go/ai/errors.go
Original file line number Diff line number Diff line change
@@ -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")
)
46 changes: 46 additions & 0 deletions go/ai/errors_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
8 changes: 5 additions & 3 deletions go/ai/evaluator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading