Skip to content

Error types

Genkit classifies failures with a status, and that classification decides what the framework does with the error: which HTTP code a flow server answers with, whether retry and fallback middleware act on it, and what the Dev UI shows. The github.com/firebase/genkit/go/core/status package holds one error type, status.Error, and one vocabulary of statuses shared with the other Genkit SDKs.

The pattern is three steps: classify once at the source, add context as the error travels, and branch with errors.Is. The basic-errors sample is a runnable tour of all three, plus the boundary behavior described below.

status.Errorf builds a classified error from a sentinel and a fmt-style message. status.PublicErrorf is the same call for a message that is safe to return to a client; anything built with status.Errorf stays server-side.

Sentinels are ordinary values, so you can declare your own with Subtype. A subtype keeps its parent’s status and still matches the parent under errors.Is, which lets each caller branch at whichever granularity it cares about.

import (
"strings"
"github.com/firebase/genkit/go/core/status"
)
// ErrRecipeNotFound classifies lookups for dishes the cookbook doesn't have.
// A subtype keeps its parent's status, so this is still a NOT_FOUND.
var ErrRecipeNotFound = status.ErrNotFound.Subtype("recipe not found")
func lookupRecipe(dish string) (string, error) {
recipe, ok := cookbook[strings.ToLower(dish)]
if !ok {
// The message only reflects what the caller sent, so PublicErrorf
// returns it to them.
return "", status.PublicErrorf(ErrRecipeNotFound, "no recipe for %q in the cookbook", dish)
}
return recipe, nil
}

Classify where the failure mode is known, and only there. Code further up the stack should not reclassify, because it has less information than the site that raised the error, not more.

Wrap with fmt.Errorf and %w. Wrapping does not reclassify: the sentinel, the status, and the public message all stay reachable through the wrapper.

recipe, err := lookupRecipe(dish)
if err != nil {
// %w keeps the sentinel, the status, and the public message reachable,
// so this still answers 404 with the message lookupRecipe wrote.
return "", fmt.Errorf("could not look up the recipe: %w", err)
}

Use %w and not %v. %v flattens the error to text and throws the classification away, which is more than a cosmetic loss: an INVALID_ARGUMENT that retry middleware would have left alone becomes an unclassified failure that gets retried through the whole backoff schedule.

Match on sentinels, never on message text.

switch {
case errors.Is(err, ErrRecipeNotFound):
// This exact failure: improvise a recipe instead.
case errors.Is(err, status.ErrNotFound):
// Any not-found, including ai.ErrModelNotFound.
case errors.Is(err, ai.ErrMaxTurnsExceeded):
// The tool loop hit its limit; raise it with ai.WithMaxTurns.
case errors.Is(err, status.ErrResourceExhausted):
// Rate limited or out of quota: back off and try again.
}

The framework packages ship sentinels for the failures they raise, each a subtype of a base sentinel.

From ai, for generation:

SentinelBaseRaised when
ErrModelNotFoundstatus.ErrNotFoundThe named model is not registered. Usually the providing plugin is missing from genkit.Init.
ErrToolNotFoundstatus.ErrNotFoundThe model called a tool that is on neither the request nor the registry.
ErrToolFailedstatus.ErrInternalA tool returned an error, or its output did not match its declared schema. The loop’s partial response rides alongside it.
ErrMaxTurnsExceededstatus.ErrAbortedThe tool loop hit its turn limit before the model produced a final response. Raise the limit with ai.WithMaxTurns, or continue from the partial response’s History().
ErrUnsupportedByModelstatus.ErrInvalidArgumentThe request used a capability the model does not advertise: media, tools, tool choice, a system role.
ErrInvalidPartstatus.ErrInvalidArgumentA Part is malformed for the operation: the wrong kind, missing a required field, or carrying a field its kind does not allow.
ErrInputTypeMismatchstatus.ErrInvalidArgumentA prompt’s input could not be interpreted as the type a content function declared, so the function never ran.
ErrUnresolvedToolRequeststatus.ErrInvalidArgumentA resumed generation left an interrupted tool request without a Respond or Restart directive.
ErrGenerationBlockedstatus.ErrFailedPreconditionThe provider refused to generate, usually a safety filter. Only the typed helpers report it; ai.Generate hands the response back so you can read FinishReason.

ErrToolFailed names the failing tool in its message (tool "getWeather" failed: ...) and wraps the tool’s own error with %w, so errors.Is and errors.As still reach it. There is no structured tool-name field. To tell one tool’s failure from another’s, define a sentinel inside the tool and branch on that rather than on ErrToolFailed.

Generation errors carry the partial response

Section titled “Generation errors carry the partial response”

Once a generate request has resolved, an error arrives beside the partial *ai.ModelResponse rather than a nil one. The response reports ai.FinishReasonFailed when something broke and ai.FinishReasonAborted when the caller stopped the loop, carries the same failure classified in resp.Error, and its History() ends at the last completed tool round, so a caller can send it again without repeating work. Errors raised before the request was sent, such as ErrModelNotFound, still come with a nil response. See Failed and stopped generations.

From core/status, for the action machinery:

SentinelBaseRaised when
ErrInvalidSchemastatus.ErrInvalidArgumentA declared input or output schema could not be resolved or compiled. The schema is wrong, not the value, so a retry will not help; fix the Go type or the schema.
ErrInvalidInputstatus.ErrInvalidArgumentA value failed validation against an action’s input schema, for example a model producing malformed tool arguments.
ErrInvalidOutputstatus.ErrInternalAn action or model produced a value that does not match the declared output schema: unparseable JSON, an enum reply outside the set, an action output failing validation.
ErrActionNotFoundstatus.ErrNotFoundNo action is registered under the requested key.
ErrPanicstatus.ErrInternalThe framework recovered a panic at one of the two boundaries that recover. See Panics.

ErrInvalidOutput is the one to branch on when a model’s structured output is unusable and the generation is worth retrying:

recipe, resp, err := genkit.GenerateData[Recipe](ctx, g, ai.WithPrompt("Suggest a recipe."))
if errors.Is(err, status.ErrInvalidOutput) {
// The model answered, but not in the shape we asked for. resp.Text()
// holds what it wrote, so the retry can show it what to fix.
}

One gap: ModelResponse.Output(v) on a response with no format handler falls back to a plain json.Unmarshal, whose error carries no sentinel. Request structured output through ai.WithOutputType or genkit.GenerateData and you get the classified error.

From core, for bidirectional actions:

SentinelBaseRaised when
ErrConnectionClosedstatus.ErrFailedPreconditionSend on a connection whose input side was closed with BidiConnection.Close.
ErrActionCompletedstatus.ErrFailedPreconditionSend on a connection whose action already returned. Its result is available from BidiConnection.Output.

Three functions inspect an error without unwrapping it by hand. Together they are everything a transport needs (logger here is github.com/firebase/genkit/go/core/logger):

// PublicMessage returns the error's own message when it is public, and a
// generic string derived from the status otherwise.
msg, public := status.PublicMessage(err)
if !public {
logger.Error(ctx, "request failed", "error", err)
}
// Of picks the response code, whether or not the message was public.
http.Error(w, msg, status.Of(err).HTTPCode())
// Classified answers the stronger question: did anything in the chain
// actually classify this, or is INTERNAL only the fallback?
if s, ok := status.Classified(err); ok && s == status.Unavailable {
// A failure the provider told us to retry.
}

status.Of reports the status of the outermost status.Error in the chain, so a deliberate reclassification at a boundary wins. It maps a cancelled context to CANCELLED, an expired one to DEADLINE_EXCEEDED, and anything unclassified to INTERNAL. status.Classified returns the same status plus whether anything in the chain really carried one. That second bit is what middleware needs, so an unclassified failure is not mistaken for a deliberate INTERNAL.

The first-party model plugins classify provider HTTP failures into canonical statuses, so a 429 reaches you as status.ErrResourceExhausted rather than as an opaque SDK error: googlegenai, compat_oai and the providers built on it, and the Anthropic plugins all do this. googlegenai also records the delay the service asked for, readable with googlegenai.RetryDelay(err).

The same wrapping covers embedders. An input longer than the embedder’s token limit comes back as HTTP 400, so errors.Is(err, status.ErrInvalidArgument). Batching is handled for you (100 inputs per call on the Gemini API, up to 250 on the Gemini Enterprise API), so an invalid-argument failure from an embed call points at one oversized document, not at the batch size.

A plugin that does not classify leaves its errors unclassified, and an unclassified error reports INTERNAL. Check which you have with status.Classified(err): the second result is false when nothing in the chain carried a status.

Genkit does not recover panics at the ordinary action boundary. A panic in a flow, a tool, or middleware unwinds normally; under net/http the connection is dropped with no response body. status.ErrPanic is raised only where the framework does recover: bidirectional actions and the experimental agent runtime, which turn the recovered value into a classified INTERNAL error such as panic in bidi action "name": ....

If you want a panic in your own code to reach a client as a status, recover it yourself and return a classified error, or wrap your mux in a recovery middleware.

status.Name is the wire status. Each error status has a matching base sentinel named Err plus the name, so status.InvalidArgument pairs with status.ErrInvalidArgument. The ones you will reach for most:

NameBase sentinelHTTP
status.InvalidArgumentstatus.ErrInvalidArgument400
status.Unauthenticatedstatus.ErrUnauthenticated401
status.PermissionDeniedstatus.ErrPermissionDenied403
status.NotFoundstatus.ErrNotFound404
status.ResourceExhaustedstatus.ErrResourceExhausted429
status.Unavailablestatus.ErrUnavailable503
status.Internalstatus.ErrInternal500

The package declares seventeen names in total, following the Google API error model; see the core/status reference for the full set with its gRPC codes and HTTP mappings. When you only know the status at run time, status.Base(name) returns its base sentinel.

genkit.Handler derives the whole response from the classification, so the code and the message can never disagree:

  • The response code is status.Of(err).HTTPCode(), always.
  • The body is status.PublicMessage(err): the message verbatim when the error was built with status.PublicErrorf, otherwise a generic string derived from the status.
  • The full error is logged server-side either way, which is the only complete record.

A failure body is plain text, not JSON. Only success is JSON, as {"result": ...}.

What the flow returnedCodeBody
errors.New("connecting to db at 10.0.0.3 as admin: password rejected")500internal
status.Errorf(status.ErrInvalidArgument, "dish %q is not on the menu", dish)400invalid argument
status.PublicErrorf(status.ErrInvalidArgument, "dish must not be empty")400dish must not be empty
genkit.DefineFlow(g, "cookbookFlow", func(ctx context.Context, input CookbookRequest) (string, error) {
if strings.TrimSpace(input.Dish) == "" {
// 400, body: dish must not be empty
return "", status.PublicErrorf(status.ErrInvalidArgument, "dish must not be empty")
}
// 404, body: no recipe for "lasagna" in the cookbook
return lookupRecipe(input.Dish)
})
genkit.DefineFlow(g, "leakyFlow", func(ctx context.Context, _ any) (string, error) {
// 500, body: internal. The real text is logged server-side only.
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))
}

genkit.Handler always writes plain text. To return machine-readable failure detail, mount genkit.HandlerFunc instead: it runs the action and hands the error back rather than writing a response, so you render the body.

func errorJSON(h func(http.ResponseWriter, *http.Request) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
err := h(w, r)
if err == nil {
return
}
// Only a PublicErrorf message is safe to echo. Anything else gets the
// generic string PublicMessage derives from the status.
msg, public := status.PublicMessage(err)
if !public {
logger.Error(r.Context(), "flow failed", "error", err)
}
// status.Name is a string type, so it encodes as "INVALID_ARGUMENT".
body := map[string]any{"status": status.Of(err), "message": msg}
// Classified reports whether anything in the chain really carried a
// status, so an unclassified failure is not read as a deliberate INTERNAL.
if _, ok := status.Classified(err); !ok {
body["classified"] = false
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status.Of(err).HTTPCode())
_ = json.NewEncoder(w).Encode(map[string]any{"error": body})
}
}
mux.Handle("POST /cookbookFlow", errorJSON(genkit.HandlerFunc(flow)))

Write the response before the handler streams, or not at all: on a streaming request the headers are already sent, and a failure has to arrive in the body as described in Errors on a streaming request.

The redaction is environment-gated. With GENKIT_ENV=dev the real err.Error() text is returned instead of the generic string, so the developer causing the failure can see it. The response code is the same either way, and a PublicErrorf response is byte for byte the same either way, since its message already escapes. GENKIT_ENV is read per request and defaults to production when unset, so treat what you see locally as a debugging aid and never as the contract your clients get.

A streaming request has already answered 200 with a text/event-stream content type before the flow runs, so a failure cannot change the status line. It arrives as a final event in the body instead, after whatever chunks were already sent, and like every event it is terminated by a blank line:

data: {"error":{"status":"INVALID_ARGUMENT","message":"dish must not be empty"}}

The frame carries exactly two fields, status and message. There is no details field, and redaction works the same as on the non-streaming path, so an unclassified failure arrives as {"error":{"status":"INTERNAL","message":"internal"}} and the full error reaches the server log only. A client has to read the body to notice a streaming failure, because the HTTP status is 200 either way.

Upgrading from GenkitError and UserFacingError

Section titled “Upgrading from GenkitError and UserFacingError”

Older code split error handling between two unrelated types, core.GenkitError and core.UserFacingError. Those names fall into three groups.

Aliases: nothing to change. core.GenkitError is a type alias of status.Error, and core.StatusName is a type alias of status.Name. They are the same types, not wrappers, so existing detection code keeps matching every error the framework raises, and a []core.StatusName is interchangeable with a []status.Name.

err := status.Errorf(status.ErrNotFound, "no such order")
var ge *core.GenkitError
if errors.As(err, &ge) {
// *core.GenkitError is *status.Error, so this still matches.
_ = ge.Status // status.NotFound
}

Deprecated but working. These keep their behavior, with one difference noted below the table. UserFacingError’s message still reaches clients and its status still picks the response code. Move at your own pace.

DeprecatedUse instead
core.NewError(name, msg, args...)status.Errorf(sentinel, msg, args...)
core.NewPublicError(name, msg, details)status.PublicErrorf(sentinel, msg, args...)
core.UserFacingErrora status.Error from status.PublicErrorf
core.AsGenkitError(err)status.Convert(err), or status.Of(err) for the status only
core.HTTPStatusCode(name)name.HTTPCode()
core.StatusFromHTTPCode(code)status.FromHTTPCode(code)
core.StatusNameToCode[name]name.Code()
core.Status, core.NewStatusstatus.Error, status.Errorf
core.INVALID_ARGUMENT and the other SCREAMING_SNAKE constantsstatus.InvalidArgument and the other Go-cased names

The one difference is the details payload: status.PublicErrorf takes a format string and arguments, and has no details parameter. Nothing is lost on the wire, because UserFacingError.Details was never serialized by genkit.Handler, which writes a plain-text body. If a custom handler was reading Details through errors.As, move that payload into the flow’s own output type, or fold it into the public message.

On status.Error itself, the HTTPCode and Source fields are deprecated as well. Use Status.HTTPCode() instead of the first; the second is never populated.

Removed. core.ReflectionError, core.ReflectionErrorDetails, core.ToReflectionError, the (*core.GenkitError).ToReflectionError method, core.SchemaValidationError, and core.NewSchemaValidationError are not part of core and have no replacement there. They were framework internals: the reflection error envelope is private to the reflection server, and input validation failures raise status.ErrInvalidInput.