Documentation
¶
Overview ¶
Package luimaerr @notice luima's error contract: the one place that decides what a resolver error is allowed to tell a client.
@dev It is named luimaerr rather than errors because a package called errors shadows the standard library in every file that imports both — and this file calls into it twice (errors.AsType, in PresentError and in SQLState).
It imports nothing else in luima, so a package that must not pull in Fiber or gqlgen's handler can still return a *CustomError.
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func PresentError ¶
PresentError @notice The server's error contract, and Config.ErrorPresenter's default.
@dev gqlgen's default presenter forwards err.Error() verbatim. That would hand an unauthenticated client raw driver strings ("... (SQLSTATE=23505)") and with them the table's column and constraint names. luima ships no auth, so this redaction is the only thing between a caller and the schema.
Resolvers have to opt in to being heard: return a bare errors.New("user already exists") and the client sees "internal server error". That is the design, and it is most of why the CRUD helpers in the crud package exist — they do the classification so a resolver cannot forget it. A *CustomError is the only way in for anything reported while a field resolves: a resolver's error, a directive's, graphql.AddError and graphql.AddErrorf, an argument's unmarshalling, a recovered panic.
err is almost never the error the resolver produced. graphql.ResolveField passes a returned error through graphql.AddFieldLocationToError, and graphql.AddError passes whatever it is given through graphql.ErrorOnPath. Both use errors.As, so an error with a *gqlerror.Error anywhere in its chain comes back as it went in, and any other is wrapped with gqlerror.WrapPath: Message is err.Error() verbatim, and Unwrap returns err. So a resolver's plain error arrives with the same type as a parse error, and the pass-through below cannot go by type alone.
Nor does everything on the wire come through here, and the boundary is not the obvious one. A request no transport accepts, such as one with an unsupported content type, is answered "transport not supported" by handler.Server.ServeHTTP and never presented, and transport.GET writes its own refusals. A malformed JSON body is presented: transport.POST reports it through Executor.DispatchError as a cause-less *gqlerror.Error quoting the body back, so it passes through. That text is the caller's own bytes.
@param ctx the request context, read for the field context and the path @param err the error gqlgen reports; a resolver's arrives already wrapped (see above) @return *gqlerror.Error the message the client receives, with the field path attached
func SQLState ¶
SQLState @notice Returns the Postgres SQLSTATE of err, or "" if err is not a driver error.
if luima.SQLState(err) == "23505" { // unique_violation
@dev Codes worth classifying: 23505 unique_violation, 23503 foreign_key_violation, 23502 not_null_violation, 23514 check_violation, 57014 query_canceled. pgdriver.Error also has IntegrityViolation() bool, a switch over the class-23 codes, if one branch for the whole class is enough, and StatementTimeout() bool for 57014.
pgdriver.Error is a struct *value*: readError builds it as Error{m: m}, nothing in pgdriver takes its address, and Field, IntegrityViolation, StatementTimeout and Error all have value receivers. So the type argument here is pgdriver.Error with no `*`, and the pre-1.26 errors.As spelling of the same thing declares `var pgErr pgdriver.Error` and passes `&pgErr` — errors.As always takes a pointer, and passing pgErr itself panics on the first non-nil error.
The `*` is the trap, and it is silent. *pgdriver.Error has the value's methods too, so it is an error: errors.AsType[*pgdriver.Error](err) compiles, and never matches, because the chain holds the value and never a pointer to it — every SQLSTATE branch behind it is dead code that reads as correct. That is the inverse of the trap an interface error type sets, where the same `*` fails to compile, and it is exactly the spelling someone porting from pgx arrives with, because pgx's *pgconn.PgError is a pointer.
@param err any error, including nil and wrapped chains @return string the five-character SQLSTATE, or "" when no pgdriver.Error is in the chain
Types ¶
type CustomError ¶
type CustomError struct {
// UserMessage @notice The text the client receives verbatim. Assume it is public, and
// assume it is untrusted.
//
// @dev Public, so never build it from another error: &CustomError{UserMessage: err.Error()}
// undoes the redaction in one line that reads like careful error handling, because
// PresentError returns this field as-is. Untrusted, because the usual way to build it is
// from client input — crud.Create's label is documented as "user "+id. The response is
// JSON, so there is no injection at luima's layer; a client that renders error messages
// into the DOM inherits the sink.
UserMessage string
// InternalError @notice The cause, kept for the log and for errors.Is/As.
//
// @dev gqlgen's own reference server stores a string here; keeping the error means the
// underlying pgdriver.Error stays reachable, which is what makes SQLState work on a wrapped
// error.
InternalError error
// Code @notice A machine-readable code for the client, e.g. "CONFLICT". Optional.
//
// @dev Clients should branch on this, never on UserMessage — the message is built from
// caller-supplied text (see crud.Create's label) and is not a stable contract. Empty means
// no extensions object is emitted, so a zero CustomError is unchanged on the wire.
//
// Nothing here is auth-shaped on purpose. CONFLICT and NOT_FOUND describe rows; a library
// that ships no auth has no business defining UNAUTHENTICATED.
Code string
}
CustomError @notice Carries a message the client is allowed to see.
@dev Any resolver error that is not a *CustomError is infrastructure detail as far as PresentError is concerned, and is redacted.
func (*CustomError) Error ¶
func (e *CustomError) Error() string
Error @notice Renders the message and, when there is one, the cause.
@dev The cause is included because this string goes to the log, never to the client — PresentError reads UserMessage directly.
@return string "user X already exists: <cause>", or just the message when there is no cause
func (*CustomError) Unwrap ¶
func (e *CustomError) Unwrap() error
Unwrap @notice Exposes the cause to errors.Is and errors.As.
@return error InternalError, which may be nil