Skip to content

feat(go): add core/status with sentinel classification and close error boundary leaks - #5829

Open
apascal07 wants to merge 9 commits into
mainfrom
ap/go-status-errors
Open

feat(go): add core/status with sentinel classification and close error boundary leaks#5829
apascal07 wants to merge 9 commits into
mainfrom
ap/go-status-errors

Conversation

@apascal07

@apascal07 apascal07 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Backports the error rework from the Go V2 branch (#5814, section 8) to main as a minor release: one error type instead of two, sentinel classification so callers branch with errors.Is instead of matching message text, and a flow HTTP boundary that no longer returns internal failure detail to clients. All existing code keeps compiling and behaving the same, except where noted in Compatibility and Behavior changes.

The core/status package

core split error handling across two unrelated types: GenkitError (internal, stack-capturing) and UserFacingError (safe to return over HTTP). They shared no interface, neither wrapped a cause, and the only way to distinguish failure modes was matching message text.

Before: two unrelated types, and no way to tell one failure from another except by its text.

core.NewError(core.INVALID_ARGUMENT, "model %q not found", name)
core.NewPublicError(core.INVALID_ARGUMENT, "unsupported content type", nil)

After: one type, where publicness is a property of the error rather than a second type.

status.Errorf(status.ErrInvalidArgument, "model %q not found", name)
status.PublicErrorf(status.ErrInvalidArgument, "unsupported content type %q", ct)

Every status has a base sentinel (status.ErrNotFound, status.ErrAborted, ...), and packages derive domain sentinels with Subtype, which inherits the status and still matches the parent, so callers branch at either granularity:

var ErrMaxTurnsExceeded = status.ErrAborted.Subtype("max turns exceeded")

if errors.Is(err, ai.ErrMaxTurnsExceeded) { ... } // specific
if errors.Is(err, status.ErrAborted)      { ... } // any abort

The framework ships domain sentinels for the failures callers plausibly branch on, and 187 internal call sites across 39 files now classify against a sentinel rather than raising an unclassified error:

ai.ErrModelNotFound, ai.ErrToolNotFound, ai.ErrMaxTurnsExceeded, ai.ErrToolFailed,
ai.ErrUnsupportedByModel, ai.ErrInvalidPart, ai.ErrUnresolvedToolRequest,
core.ErrConnectionClosed, core.ErrActionCompleted,
streaming.ErrStreamNotFound, streaming.ErrStreamExists, streaming.ErrStreamClosed,
streaming.ErrStreamCompleted, streaming.ErrStreamTimeout,
exp.ErrSnapshotNotFound, exp.ErrNoSessionStore, exp.ErrSessionIDRequired,
status.ErrInvalidSchema, status.ErrInvalidInput, status.ErrInvalidOutput,
status.ErrActionNotFound, status.ErrPanic

status.Errorf has real fmt.Errorf semantics (%w records a cause alongside the sentinel), and go vet treats it as a printf wrapper so format mistakes are machine-checked. The usage pattern the package documents and the framework follows:

// Classify once, where the failure mode is known:
return status.Errorf(ai.ErrToolFailed, "tool %q failed: %w", name, err)

// Add context up the stack with fmt.Errorf; the classification survives:
return fmt.Errorf("agent %q: %w", agentName, err)

// Reclassify only at a boundary where the meaning changes; status.Of
// reports the outermost classification, so the override wins:
return status.Errorf(status.ErrInternal, "store violated the snapshot contract: %w", err)

Error.Unwrap returns a single cause (the sentinel is matched through Is), so stdlib helpers and hand-rolled chain walks in logging and telemetry keep working. status.Of and status.Convert treat a non-nil interface holding a nil *Error as success rather than panicking, since a typed nil is what a func() *status.Error returning no error produces at an error-typed call site.

The HTTP boundary

Flow servers wrote err.Error() verbatim to clients, which leaked raw provider SDK text (project and model resource paths, quota detail), field-by-field schema dumps, and full internal messages on 500s. Meanwhile deliberately public errors always came back as HTTP 500, because wrapHandler's errors.As could never match *UserFacingError; Firebase auth's UNAUTHENTICATED surfaced as 500 instead of 401.

Both the JSON and SSE paths now route through one helper: the HTTP code comes from status.Of, and the message from status.PublicMessage, so a message only leaves the process when the error was built with PublicErrorf. Everything else becomes a generic string derived from the status, and the full error is still logged. GENKIT_ENV=dev is exempt (redacting there only hides the failure from the developer causing it), and the reflection API is untouched, so the Dev UI keeps full messages and stacks.

The rule applied across the framework: a message is public when it describes the caller's request and they can act on it (a stream ID they sent, a malformed body, a bad config value they passed). It is internal when it describes the deployment, even when that would be helpful, because the developer sees it in dev and in the log.

Retry and fallback stop disagreeing about unclassified errors

Both middlewares gate on a status set, and both used to reach that status by asserting for *core.GenkitError first. They drew opposite conclusions when the assertion failed: Retry treated anything that was not a GenkitError as retryable unconditionally, and Fallback refused to consider it at all.

Both now take the status from status.Of, which reports INTERNAL for an unclassified error and CANCELLED for a cancelled context. The default sets are unchanged. Two consequences: a network failure now falls back the same way it already retried, and a cancelled context stops being retried, since CANCELLED is in neither default set.

Compatibility

Every existing symbol survives, and the load-bearing decision is that GenkitError and StatusName are type aliases, not wrapper types:

type StatusName = status.Name
type GenkitError = status.Error

const INVALID_ARGUMENT = status.InvalidArgument // same type, same wire value

An errors.As for *core.GenkitError therefore still matches errors the framework now raises as *status.Error, and []core.StatusName config (Retry.Statuses, Fallback.Statuses) is interchangeable with []status.Name. A wrapper type would have compiled fine and then silently stopped matching. The deprecated constructors preserve their exact old behavior, including NewError's implicit wrapping of a trailing error argument and its Details["stack"].

Before (core) After (core/status)
GenkitError status.Error (alias)
UserFacingError, NewPublicError status.PublicErrorf
NewError status.Errorf
SchemaValidationError status.ErrInvalidInput
StatusName + SCREAMING_SNAKE status.Name (alias) + Go-cased constants
HTTPStatusCode status.Name.HTTPCode
StatusFromHTTPCode status.FromHTTPCode
AsGenkitError status.Convert, status.Of

Two exceptions:

  • The GenkitError.ToReflectionError() method is gone (Go cannot declare methods on another package's type through an alias). The package-level core.ToReflectionError(err) still works and is what call sites actually used.
  • core/x/streaming moves off UserFacingError to the stream sentinels, so errors.As for *core.UserFacingError no longer matches there. That package documents itself as changing in any minor release.

Behavior changes

All observable differences on main; each is a bug fix or a deliberate tightening.

Before After
Public errors over HTTP always 500 correct code (401, 400, ...)
Unclassified / provider / schema errors over HTTP full text generic message, status unchanged
SSE error payload details: <full error text> field removed
Unclassified error under Fallback propagated, no fallback falls back (INTERNAL)
Cancelled context under Retry retried to exhaustion not retried (CANCELLED)

The sample

go/samples/basic-errors demonstrates the whole pattern end to end against a live flow server, with the curl invocations in the package doc: a public INVALID_ARGUMENT that reaches the client, a custom NOT_FOUND subtype that a second flow catches with errors.Is and recovers from rather than propagating, a model-not-found that falls back to the default model, and an unclassified failure whose text the boundary redacts.

basic-agents picks up the same discipline in two places. The banker's transferMoney tool classifies a non-positive amount with status.Errorf rather than a bare fmt.Errorf, so the cause survives the runtime wrapping it as ai.ErrToolFailed and server-side code can still branch on it. The CLI switches on the status carried on the failed turn's output to suggest the right next step (resume, rephrase, wait) instead of parsing the message.

Verification

Go 1.26.3, full go build ./... and go test ./... in go/: 38 packages with tests pass, no failures. core/status alone carries 40 top-level tests, 86 including subtests.

New coverage worth naming:

  • core/compat_test.go pins the alias decision: errors.As for *core.GenkitError still matches an error raised as *status.Error, the constants keep their wire values, and the deprecated constructors keep their old behavior including Details["stack"].
  • genkit/servers_leak_test.go drives one case per path that used to put internal detail on the wire (relayed provider text, an output-schema dump, an unclassified error from user code) and asserts the client sees only the generic message while the status stays correct.
  • core/status/nil_test.go pins that a typed nil *status.Error is inspectable through the error interface rather than panicking.

…er it

v1 split error handling across core: GenkitError (internal, stack-capturing)
and UserFacingError (safe to return over HTTP) shared no interface, so handling
code checked for both with separate errors.As calls. Neither wrapped a cause,
so callers could only distinguish failure modes by matching on message text.

Add core/status, which collapses the two into a single status.Error with a
Public field gating whether its message is safe to return to clients, and adds
Sentinel classification so callers branch with errors.Is. A base sentinel
exists per status; packages derive domain sentinels with Subtype, which
inherits the parent's status and still matches it.

Everything in core becomes an alias or a thin deprecated wrapper over it.
StatusName aliases status.Name and GenkitError aliases status.Error, so the
two are the same type: an errors.As for either finds errors raised by any part
of Genkit, and []core.StatusName config (retry, fallback) is interchangeable
with []status.Name. UserFacingError keeps its exact shape, fields, and Error()
text, and gains an Unwrap returning its base sentinel so status.Of reports its
status instead of defaulting to INTERNAL.

status.Error keeps a single-cause Unwrap() error rather than the Unwrap()
[]error form, matching sentinels through an Is method instead. The stdlib
errors.Unwrap returns nil for a multi-cause unwrapper, which would silently
truncate hand-rolled chain walks in logging and telemetry middleware.

The deprecated NewError shim preserves v1 behaviour exactly: it still wraps the
last error argument implicitly and still records Details["stack"], so anything
built through it behaves as before while internals move to the lean path.

RuntimeError codegen moves from core.genkitErrorWire to status.errorWire, and
AgentOutput.Error and SessionSnapshot.Error are regenerated as *status.Error.
Both are the same type as *core.GenkitError under the alias, so the exp surface
is unchanged.

Also fixes five call sites that passed a non-constant string as a format
argument to NewError, including three that passed err.Error() directly, where
a % in the message produced a mangled error.

The one v1 symbol that cannot survive the alias is the GenkitError method
ToReflectionError; the package-level ToReflectionError function still works.
Declare the failure modes each package raises as status sentinels derived from
the base ones, so callers branch with errors.Is at either granularity:

	errors.Is(err, ai.ErrMaxTurnsExceeded) // specific
	errors.Is(err, status.ErrAborted)      // broad

core.ErrConnectionClosed and core.ErrActionCompleted become FailedPrecondition
subtypes rather than bare errors.New values, so they now carry a status; they
still match errors.Is exactly as before.

Migrate core/x/streaming off core.NewPublicError to status.PublicErrorf with
the new stream sentinels. That package documents itself as changing in any
minor release, so errors.As for *core.UserFacingError no longer matching there
is in scope. genkit's subscribeToStream now matches ErrStreamNotFound directly
instead of testing for a public error that happens to be NOT_FOUND.
Move every internal error construction off core.NewError and core.NewPublicError
onto status.Errorf and status.PublicErrorf, classified by sentinel. Failures
that callers plausibly branch on get a domain sentinel (ai.ErrModelNotFound,
ai.ErrToolFailed, ai.ErrMaxTurnsExceeded, ai.ErrUnsupportedByModel,
exp.ErrSnapshotNotFound, exp.ErrNoSessionStore, exp.ErrSessionIDRequired,
status.ErrInvalidSchema, status.ErrInvalidOutput, status.ErrActionNotFound,
status.ErrPanic); the rest take the base sentinel for the status they already
carried, so no status changes.

core.NewError implicitly wrapped its last error argument, which status.Errorf
does not, so 35 sites that formatted a cause with %v now use %w and keep their
chain reachable through errors.Is and errors.As. bidi's send-after-close paths
formatted a sentinel with %v, which never wrapped it either; they are now
classified by that sentinel directly.

retry and fallback both take an error's status from status.Of instead of
requiring a *core.GenkitError. Retry is unaffected in practice, since an
unclassified error reports INTERNAL and INTERNAL was already in its default
set. Fallback previously ignored unclassified errors entirely, so a network
failure was retried but never fell back; the two now agree. A cancelled context
reports CANCELLED and is excluded from both, where before it was retried.

googlegenai classifies provider failures through status.Name.IsValid and
status.FromHTTPCode, and wraps the SDK error with %w so errors.As still reaches
the underlying genai.APIError.
wrapHandler wrote err.Error() verbatim on both of its branches, so whatever an
error happened to say reached the client: raw provider text (project and model
resource paths, quota and permission detail), output-schema violations naming
the action and the shape it failed to produce, and any unclassified error from
user code or a third-party library. writeSSEError additionally put the whole
err.Error() in a details field with no status gating at all.

Route both through clientError, which takes the status from status.Of and the
message from status.PublicMessage: a message only leaves the process when the
error was built with PublicErrorf, and everything else becomes a generic string
derived from the status. The full error is still logged. GENKIT_ENV=dev is
exempt, since redacting there only hides the failure from the developer causing
it, and the reflection API is untouched: the Dev UI keeps full messages and
stacks.

This also fixes public errors returning the wrong status code. NewPublicError
returns a *core.UserFacingError, which is structurally unrelated to
*core.GenkitError, so wrapHandler's errors.As could never match it and every
deliberately-public error fell through to 500. Firebase auth's UNAUTHENTICATED
errors, for instance, came back as 500 instead of 401.

Errors that describe the shape of the caller's own request and name only public
API concepts are marked public so the guidance still reaches them: the one-shot
input requirement, init rejection, the agent state/session mismatches, and
snapshot-not-found. Schema validation dumps stay internal.

The SSE error payload loses its details field, which existed only to carry that
text. reflection_v2 now takes its code from status.Of rather than hardcoding
INTERNAL, and reads stacks from status.Error.Stack as well as Details.

Adds regression tests for each confirmed leak path and a core compatibility
suite covering errors.As for *core.GenkitError against errors raised as
*status.Error, stdlib errors.Unwrap chain walking, and the v1 constructors'
preserved behaviour.
Ports the status package's own tests and adds the invariants specific to this
implementation: that Unwrap stays walkable by the stdlib errors.Unwrap, that Is
matches a sentinel at every level of a Subtype chain without matching siblings,
that sentinel and cause matching are independent, and that an Error decoded
from the wire (no sentinel) is safe to match against. 90.7% statement coverage.
Audit of the preceding commits, now that they are all in.

Three paths were still marked public that should not have been. The agent's
three store-mode mismatches describe how the agent was wired, not what the
caller sent, so an anonymous client could learn from them whether state is
server- or client-managed. Firebase's ID token failure wrapped the Admin SDK's
message, which quotes the expected project ID on an audience-claim mismatch.
googlegenai's config-binding failures describe the config the developer set,
which the caller cannot act on. All three now stay internal; the text still
reaches the developer through the log and GENKIT_ENV=dev.

The Firestore stream manager leaked around the boundary entirely: it persisted
err.Error() for any error and rehydrated it with PublicErrorf, so an internal
failure from the producing process came back out of Firestore flagged safe to
return. streamError now records whether the message was public and the
subscribe path rebuilds with that flag. The field is absent on existing
documents, which decodes to false, the safe default.

ai.ErrInvalidPart and streaming.ErrStreamTimeout were declared but never
raised, which is worse than not declaring them: errors.Is against a dead
sentinel silently never matches. Both are now wired to the sites they were
written for.

Adds tests pinning every sentinel's status, so a domain sentinel cannot drift
from the status its call sites sent before classification, and fixes two
handler tests that passed only because the flow's message happened to equal the
generic label for its status, leaving them unable to tell redaction from a
leak.

core.NewError also restores a non-canonical StatusName verbatim rather than
letting status.Base coerce it to UNKNOWN, since the shim's contract is to
behave exactly as v1 did.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new canonical error handling package under go/core/status to replace the deprecated core.GenkitError and core.NewError mechanisms, unifying error classification across the codebase using sentinels. The review feedback correctly identifies several potential nil pointer dereference panics that can occur when methods on status.Error (such as Error(), Unwrap(), Is(), Sentinel(), and Stack()) or utility functions like status.Of() and status.PublicMessage() are invoked with typed nil pointers. Implementing the suggested nil checks will make the new error package more robust.

Comment thread go/core/status/error.go Outdated
Comment thread go/core/status/error.go Outdated
Comment thread go/core/status/error.go
Comment thread go/core/status/error.go Outdated
Comment thread go/core/status/error.go Outdated
Comment thread go/core/status/error.go
Comment thread go/core/status/error.go
…ries

Code-review fixes on top of the status classification work:

- firebase/exp: the Firestore stream manager now returns the
  streaming.ErrStream* subtype sentinels (not the base sentinels) so
  transport-level errors.Is branches, like the 204 resume path, behave
  the same as the in-memory manager
- googlegenai: restore publicness on the six config-validation errors
  so deployed clients keep the authored remediation guidance
- ai/exp: stop re-stamping session-store errors as INTERNAL; add
  context with fmt.Errorf so the store's own classification (and ctx
  cancellation) survives to the boundary; deliberate reclassifications
  are untouched
- ai/exp: make the session-path snapshot miss public like its sibling
  not-found paths; classify detach's nil-store check with
  ErrNoSessionStore like the other capability checks
- genkit: reflection v2 forces CANCELLED for wrapped cancellations,
  matching v1, and derives code/stack from a single status.Convert
- middleware: fallback's model-lookup miss uses ai.ErrModelNotFound,
  and the Fallback.Statuses doc no longer contradicts the code
Genkit hands out *status.Error in places that are nil in the ordinary case:
Convert returns nil for a nil error, and the generated AgentOutput.Error and
SessionSnapshot.Error fields are nil whenever nothing failed. Returning one of
those from a function whose result is error produces an interface that is
non-nil but holds a nil pointer, and the first errors.Is or transport call on it
panicked, typically inside a request handler.

Error's methods now tolerate a nil receiver, and Of and PublicMessage check the
pointer errors.As gave them rather than assuming it is non-nil. The guards have
to live on the methods themselves: errors.Is invokes Is through a non-nil
interface, so a check at the call site never runs. Error renders a nil receiver
as "<nil>" rather than "", which would be indistinguishable from a real error
carrying an empty message. Field access is necessarily still unguarded, and the
type documents that.

A typed nil classifies as OK, so clientError would have reported HTTP 200 for a
request whose result was never written. It is only reached on a failure path, so
an error classifying as OK is itself the bug and now reports INTERNAL.

Reported by the review bot on #5829, which flagged the panics but not that the
generated agent fields make them reachable rather than hypothetical.
@apascal07
apascal07 requested review from huangjeff5 and pavelgj July 28, 2026 18:04
…-agents

basic-errors demonstrates the core/status error story end to end:
producing classified errors (public INVALID_ARGUMENT, a custom NOT_FOUND
subtype via Subtype, fmt.Errorf context wrapping), consuming them with
errors.Is at base and subtype granularity (recovering from a missing
recipe, falling back on a bogus model, degrading on provider overload),
and the HTTP boundary redacting an unclassified error while public
messages pass through. All documented curl paths verified against the
live API.

basic-agents gains two targeted touches: the banker tool classifies its
amount validation with status.ErrInvalidArgument (the runtime wraps tool
errors as ai.ErrToolFailed with the cause preserved for errors.Is), and
the CLI prints an actionable hint keyed off the failed invocation's wire
status instead of parsing message text.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant