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:

  • ai: ErrModelNotFound, ErrToolNotFound, ErrToolFailed, ErrMaxTurnsExceeded, ErrUnsupportedByModel, ErrInvalidPart, ErrInputTypeMismatch, ErrUnresolvedToolRequest
  • core/status: ErrInvalidSchema, ErrInvalidInput, ErrInvalidOutput, ErrActionNotFound, ErrPanic
  • core: ErrConnectionClosed, ErrActionCompleted

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.

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.HandlerFunc returns the error to you instead of writing a response, for frameworks that handle errors centrally. Apply the same two rules there yourself, with status.Of for the code and status.PublicMessage for the body, as in the snippet above.

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, including UserFacingError, whose message still reaches clients and whose 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

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.