feat(go): add core/status with sentinel classification and close error boundary leaks - #5829
Open
apascal07 wants to merge 9 commits into
Open
feat(go): add core/status with sentinel classification and close error boundary leaks#5829apascal07 wants to merge 9 commits into
apascal07 wants to merge 9 commits into
Conversation
…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.
Contributor
There was a problem hiding this comment.
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.
…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.
…-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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Backports the error rework from the Go V2 branch (#5814, section 8) to
mainas a minor release: one error type instead of two, sentinel classification so callers branch witherrors.Isinstead 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/statuspackagecoresplit error handling across two unrelated types:GenkitError(internal, stack-capturing) andUserFacingError(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.
After: one type, where publicness is a property of the error rather than a second type.
Every status has a base sentinel (
status.ErrNotFound,status.ErrAborted, ...), and packages derive domain sentinels withSubtype, which inherits the status and still matches the parent, so callers branch at either granularity: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:
status.Errorfhas realfmt.Errorfsemantics (%wrecords a cause alongside the sentinel), andgo vettreats it as a printf wrapper so format mistakes are machine-checked. The usage pattern the package documents and the framework follows:Error.Unwrapreturns a single cause (the sentinel is matched throughIs), so stdlib helpers and hand-rolled chain walks in logging and telemetry keep working.status.Ofandstatus.Converttreat a non-nil interface holding a nil*Erroras success rather than panicking, since a typed nil is what afunc() *status.Errorreturning no error produces at anerror-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, becausewrapHandler'serrors.Ascould never match*UserFacingError; Firebase auth'sUNAUTHENTICATEDsurfaced 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 fromstatus.PublicMessage, so a message only leaves the process when the error was built withPublicErrorf. Everything else becomes a generic string derived from the status, and the full error is still logged.GENKIT_ENV=devis 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.GenkitErrorfirst. They drew opposite conclusions when the assertion failed:Retrytreated anything that was not aGenkitErroras retryable unconditionally, andFallbackrefused to consider it at all.Both now take the status from
status.Of, which reportsINTERNALfor an unclassified error andCANCELLEDfor 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, sinceCANCELLEDis in neither default set.Compatibility
Every existing symbol survives, and the load-bearing decision is that
GenkitErrorandStatusNameare type aliases, not wrapper types:An
errors.Asfor*core.GenkitErrortherefore still matches errors the framework now raises as*status.Error, and[]core.StatusNameconfig (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, includingNewError's implicit wrapping of a trailing error argument and itsDetails["stack"].core)core/status)GenkitErrorstatus.Error(alias)UserFacingError,NewPublicErrorstatus.PublicErrorfNewErrorstatus.ErrorfSchemaValidationErrorstatus.ErrInvalidInputStatusName+SCREAMING_SNAKEstatus.Name(alias) + Go-cased constantsHTTPStatusCodestatus.Name.HTTPCodeStatusFromHTTPCodestatus.FromHTTPCodeAsGenkitErrorstatus.Convert,status.OfTwo exceptions:
GenkitError.ToReflectionError()method is gone (Go cannot declare methods on another package's type through an alias). The package-levelcore.ToReflectionError(err)still works and is what call sites actually used.core/x/streamingmoves offUserFacingErrorto the stream sentinels, soerrors.Asfor*core.UserFacingErrorno 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.details: <full error text>FallbackRetryThe sample
go/samples/basic-errorsdemonstrates the whole pattern end to end against a live flow server, with thecurlinvocations in the package doc: a publicINVALID_ARGUMENTthat reaches the client, a customNOT_FOUNDsubtype that a second flow catches witherrors.Isand 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-agentspicks up the same discipline in two places. The banker'stransferMoneytool classifies a non-positive amount withstatus.Errorfrather than a barefmt.Errorf, so the cause survives the runtime wrapping it asai.ErrToolFailedand 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 ./...andgo test ./...ingo/: 38 packages with tests pass, no failures.core/statusalone carries 40 top-level tests, 86 including subtests.New coverage worth naming:
core/compat_test.gopins the alias decision:errors.Asfor*core.GenkitErrorstill matches an error raised as*status.Error, the constants keep their wire values, and the deprecated constructors keep their old behavior includingDetails["stack"].genkit/servers_leak_test.godrives 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.gopins that a typed nil*status.Erroris inspectable through theerrorinterface rather than panicking.