Skip to content

GraphQL errors

async-graphql Result, extensions, codes — and how a ServiceError lands on the wire as a typed GraphQL error.

A resolver returns T or async_graphql::Result<T>; an Err short-circuits with a GraphQL error envelope alongside data. The message goes on errors[].message; structured data goes on errors[].extensions. The framework owns the mapping for its shared error types — ServiceError from nest-rs-seaorm, CredentialError and TokenError from nest-rs-authn — so a feature returns its service result and the wire shape just works.

crates/features/src/posts/graphql/resolver.rs
use async_graphql::{Error, Result};
use nest_rs::graphql::{operations, resolver};
#[resolver]
pub struct PostsResolver;
#[operations]
impl PostsResolver {
#[query]
#[public]
async fn post(&self, id: String) -> Result<String> {
if id.is_empty() {
return Err(Error::new("id must not be empty"));
}
Ok(format!("post {id}"))
}
}

On the wire:

{
"data": null,
"errors": [
{
"message": "id must not be empty",
"path": ["post"],
"locations": [{ "line": 1, "column": 3 }]
}
]
}

extensions carry structured data — a stable code clients can branch on without parsing the message:

crates/features/src/posts/graphql/resolver.rs
use async_graphql::{Error, Result};
#[query]
#[public]
async fn post(&self, id: String) -> Result<String> {
if id.is_empty() {
return Err(Error::new("id must not be empty").extend_with(|_, e| {
e.set("code", "INVALID_ARGUMENT");
}));
}
Ok(format!("post {id}"))
}
{
"errors": [
{
"message": "id must not be empty",
"extensions": { "code": "INVALID_ARGUMENT" },
"path": ["post"]
}
]
}

Codes are free-form strings — pick a vocabulary and stick to it. A typical set: UNAUTHENTICATED, FORBIDDEN, NOT_FOUND, INVALID_ARGUMENT, INTERNAL. The framework’s own by-id binding uses the same vocabulary: a bind argument that is not a UUID v7 — malformed, or well-formed but a different version — answers "id must be a UUID v7" with extensions.code = "INVALID_ARGUMENT", the GraphQL twin of the HTTP path’s 400. The Apollo spec calls out these names and clients converge on them.

A CrudService method returns Result<T, ServiceError> — the shared framework error covering the two failure modes every service has:

crates/nest-rs-seaorm/src/error.rs
pub enum ServiceError {
Validation(ValidationErrors),
Db(DbErr),
}

A resolver propagates that into a GraphQL Result<T> with ?:

crates/features/src/posts/graphql/resolver.rs
use async_graphql::Result;
use nest_rs::authz::Read;
use nest_rs::graphql::{operations, resolver};
#[operations]
impl PostsResolver {
#[query]
#[authorize(Read, posts::Entity)]
async fn post(&self, id: String) -> Result<Post> {
Ok(self.svc.find_by_id(&id).await?)
}
}

ServiceError already implements Into<async_graphql::Error> through async-graphql’s From<E: Display> blanket — the Display side stays wire-safe ("database error" for the Db variant) so a SQL fragment never leaks. The Validation variant forwards through validator’s structured error.

For a richer envelope, wrap the conversion to attach a code:

crates/features/src/posts/graphql/resolver.rs
use async_graphql::{Error, ErrorExtensions, Result};
use nest_rs::seaorm::ServiceError;
fn graphql_from_service(err: ServiceError) -> Error {
let code = match &err {
ServiceError::Validation(_) => "INVALID_ARGUMENT",
ServiceError::Db(_) => "INTERNAL",
};
Error::new(err.to_string()).extend_with(|_, e| e.set("code", code))
}
#[query]
#[authorize(Read, posts::Entity)]
async fn post(&self, id: String) -> Result<Post> {
self.svc.find_by_id(&id).await.map_err(graphql_from_service)
}

Same shape for CredentialError (always UNAUTHENTICATED) and TokenError (UNAUTHENTICATED with the RFC 6749 error code in a nested field if you need it).

ServiceError is wire-safe by construction — its Db variant displays as "database error", never the DbErr underneath. A feature’s own error type has no such discipline imposed on it, and a Display that wraps a driver error carries SQL, column names and sometimes row values into the error frame.

Opaque is the seam for that, and it reads the same on every client-facing transport — HTTP, GraphQL, WebSockets and MCP:

crates/features/src/posts/graphql/resolver.rs
use nest_rs::graphql::Opaque;
#[query]
#[authorize(Read, posts::Entity)]
async fn post(&self, id: String) -> Result<Post> {
Ok(self.svc.find_by_id(&id).await.opaque()?)
}

.opaque()? logs the real error at error on nest_rs::graphql and puts a constant message on the wire, under the same INTERNAL code an internal denial carries — so a client cannot tell an unexpected failure from a refusal it was owed no explanation for.

Reach for it on a failure that is none of the caller’s business. A validation rejection or an authorization denial is the opposite case: those exist to be read, and travel as themselves.

The GraphqlAbilityBridge from nest-rs-authz’s graphql feature turns an Ability refusal into a FORBIDDEN-coded error before the The same bridge answers a missing or invalid principal with UNAUTHENTICATED, since it runs authentication in band before the operation.

The handler doesn’t write the mapping; it just declares #[authorize(Action, Entity)]. See Security for the full chain.

Option<T> is the right return when an absent value is a normal outcome (looked something up, found nothing). Reserve errors for abnormal outcomes (validation failed, DB unreachable, principal refused). On the wire:

  • Option<T> returning None → "data": { "post": null }, no errors block.
  • Result<T> returning Err → "errors": [...], the field’s value is null.

The bind helper follows this rule: bind::<Read, UsersService>(ctx, &id).await? returns Result<Option<User>> — None means absent (404 equivalent), Err means denied or broken.

crates/features/src/users/graphql/resolver.rs
#[query]
#[authorize(Read, UserEntity)]
async fn user(&self, ctx: &Context<'_>, id: String) -> Result<Option<User>> {
Ok(bind::<Read, UsersService>(ctx, &id)
.await?
.as_ref()
.map(User::from))
}

GraphQL allows several errors per response. A query selecting two fields, each failing, gets two errors[] entries — async-graphql collects them automatically across resolver calls. There is no “first error wins” semantic; clients see the full set with path pointing at each failed field.

This matters most for partial-success shapes (one ok, one denied) where the client wants the available data plus a clear refusal on the rest. The framework does not need any code for this — return the Result from each #[field_resolver] and async-graphql does the gathering.

  • Queries and mutations — the Result<T> return shape at the top level.
  • Security — the guard chain that decides UNAUTHENTICATED / FORBIDDEN before a resolver runs.
  • Database — the service contract that emits ServiceError.