Skip to content

Commit 028fa4d

Browse files
committed
Add generic exception handlers
Shortcake-Parent: main
1 parent ef20f65 commit 028fa4d

14 files changed

Lines changed: 2633 additions & 97 deletions

File tree

CONTRIBUTING.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,10 +161,10 @@ release type: patch
161161
social_messages:
162162
x: >-
163163
{project_name} {version} is out! This release fixes schema printing for
164-
nullable input defaults. https://strawberry.rocks/release/{version}
164+
nullable input defaults. 🍓 https://strawberry.rocks/release/{version}
165165
linkedin: >-
166166
{project_name} {version} is out. This release fixes schema printing for
167-
nullable input defaults so generated SDL now keeps explicit null values.
167+
nullable input defaults, so generated SDL now keeps explicit null values.
168168
---
169169

170170
This release fixes schema printing for nullable input defaults.
@@ -180,5 +180,7 @@ doubt feel free to ask.
180180
Release notes should start with `This release adds ...` or
181181
`This release fixes ...` and should explain the user-visible behavior first.
182182
Include social messages for X and LinkedIn so the release announcement reads
183-
well on each platform. X messages must include the website release URL template:
183+
well on each platform. They should read like natural release announcements,
184+
start with `{project_name} {version} is out`, and explain what changed and who
185+
benefits. X messages must include the website release URL template:
184186
`https://strawberry.rocks/release/{version}`.

RELEASE.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
---
2+
release type: minor
3+
social_messages:
4+
x: >-
5+
{project_name} {version} is out! This release adds exception handlers, a building block for integrations that want to turn expected Python errors into typed GraphQL results. 🍓 https://strawberry.rocks/release/{version}
6+
linkedin: >-
7+
{project_name} {version} is out. This release adds exception handlers, a schema-level hook for integrations that need to turn framework validation errors, such as Pydantic errors, into typed GraphQL union results. Most applications will not need to use this directly, but it gives framework integrations a cleaner path without adding try/except blocks to every resolver.
8+
---
9+
10+
This release adds configurable exception handlers that map Python exceptions to
11+
typed GraphQL union results.
12+
13+
Most applications do not need to adopt this directly. It is primarily useful for
14+
integrations and framework-level helpers: for example, catching validation
15+
exceptions from a library such as Pydantic and exposing them as an explicit
16+
GraphQL error type, without requiring every resolver to catch and convert those
17+
exceptions manually.
18+
19+
Handlers are passed to `strawberry.Schema` (and `strawberry.federation.Schema`):
20+
21+
```python
22+
import strawberry
23+
from strawberry.types.field import StrawberryField
24+
25+
26+
class ValidationProblem(Exception):
27+
pass
28+
29+
30+
@strawberry.type
31+
class ValidationError:
32+
message: str
33+
34+
35+
class ValidationErrorHandler(
36+
strawberry.ExceptionHandler[ValidationProblem, ValidationError]
37+
):
38+
def handle(
39+
self,
40+
exception: ValidationProblem,
41+
*,
42+
field: StrawberryField,
43+
info: strawberry.Info,
44+
) -> ValidationError:
45+
return ValidationError(message=str(exception))
46+
47+
48+
schema = strawberry.Schema(
49+
query=Query,
50+
mutation=Mutation,
51+
exception_handlers=[ValidationErrorHandler()],
52+
)
53+
```
54+
55+
Handlers can alternatively declare `exception_type` and `error_type` class
56+
attributes instead of type parameters, which also covers types that are only
57+
known at runtime. Declaring both a type parameter and a conflicting attribute
58+
for the same slot raises an error at schema creation.
59+
60+
Strawberry only converts the exception when the field return type includes the
61+
handler's GraphQL error type. Other fields continue to raise normal GraphQL
62+
errors, so applications can opt in one field at a time. Exceptions raised by
63+
the resolver, during argument conversion, or by field extensions are all
64+
covered.
65+
66+
Exception handlers apply to query and mutation fields. Subscriptions are not
67+
covered: exceptions raised while establishing a subscription are not converted
68+
into union results.

docs/guides/errors.md

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,152 @@ This approach allows you to express the possible error states in the schema and
311311
so provide a robust interface for your client to account for all the potential
312312
outcomes from a mutation.
313313

314+
### Mapping expected exceptions to union results
315+
316+
If your application or integration already raises a specific exception for an
317+
expected failure, you can map that exception to one of the GraphQL error types
318+
in the field's return union by passing `exception_handlers` to
319+
`strawberry.Schema`.
320+
321+
```python
322+
import strawberry
323+
from strawberry.types import Info
324+
from strawberry.types.field import StrawberryField
325+
326+
327+
class UsernameAlreadyExists(Exception):
328+
def __init__(self, username: str):
329+
self.username = username
330+
331+
332+
@strawberry.type
333+
class RegisterUserSuccess:
334+
user: User
335+
336+
337+
@strawberry.type
338+
class UsernameAlreadyExistsError:
339+
username: str
340+
341+
342+
class UsernameAlreadyExistsHandler(
343+
strawberry.ExceptionHandler[UsernameAlreadyExists, UsernameAlreadyExistsError]
344+
):
345+
def handle(
346+
self,
347+
exception: UsernameAlreadyExists,
348+
*,
349+
field: StrawberryField,
350+
info: Info,
351+
) -> UsernameAlreadyExistsError:
352+
return UsernameAlreadyExistsError(username=exception.username)
353+
354+
355+
@strawberry.type
356+
class Mutation:
357+
@strawberry.mutation
358+
def register_user(
359+
self, username: str, password: str
360+
) -> RegisterUserSuccess | UsernameAlreadyExistsError:
361+
# create_user may raise UsernameAlreadyExists
362+
user = create_user(username, password)
363+
return RegisterUserSuccess(user=user)
364+
365+
366+
schema = strawberry.Schema(
367+
query=Query,
368+
mutation=Mutation,
369+
exception_handlers=[UsernameAlreadyExistsHandler()],
370+
)
371+
```
372+
373+
Strawberry only converts exceptions when both of these are true:
374+
375+
- the exception is an instance of the handler's declared exception type
376+
- the field return type is a union, or nullable union, containing the handler's
377+
declared error type
378+
379+
The two type parameters — the exception type the handler receives and the
380+
GraphQL error type it returns — are the single source of truth: they drive the
381+
matching at runtime, and type checkers use them to verify the signature of
382+
`handle` against the declared types.
383+
384+
To map several Python exception classes to the same GraphQL error type,
385+
parameterize the handler with their union, e.g.
386+
`strawberry.ExceptionHandler[ErrorA | ErrorB, MyErrorType]`.
387+
388+
If you prefer explicit class attributes — or you are not using a type checker —
389+
the same handler can declare its types with `exception_type` and `error_type`
390+
attributes instead of type parameters:
391+
392+
```python
393+
class UsernameAlreadyExistsHandler(strawberry.ExceptionHandler):
394+
exception_type = UsernameAlreadyExists
395+
error_type = UsernameAlreadyExistsError
396+
397+
def handle(self, exception, *, field, info):
398+
return UsernameAlreadyExistsError(username=exception.username)
399+
```
400+
401+
With this style `exception_type` accepts a single exception type or a tuple of
402+
them, and the attributes also cover types that are only known at runtime. You
403+
can mix the styles — for example parameterize the exception and supply a
404+
runtime-only `error_type` as an attribute — but declaring both a type parameter
405+
and a conflicting attribute for the same slot raises a `TypeError` at schema
406+
creation. Note that type checkers will not verify the signature of `handle` when
407+
you use attributes.
408+
409+
Handlers cover exceptions raised by the resolver, during argument conversion,
410+
and by field extensions (for example a permission or validation extension
411+
wrapping the field). Argument conversion runs before the field-extension chain,
412+
so a conversion error is mapped directly and bypasses the field extensions —
413+
matching how conversion errors have always been raised before permissions run.
414+
Field extensions may catch or transform resolver exceptions before the handler
415+
sees the final exception leaving the extension chain.
416+
417+
If multiple handlers match, Strawberry uses the first matching handler from the
418+
`exception_handlers` list. Handlers do not apply to subscription fields or to
419+
list fields such as `list[Success | UsernameAlreadyExistsError]`.
420+
421+
A handler can decline an individual exception by returning `None` (or an
422+
awaitable resolving to `None`). Declining re-raises the original exception, so
423+
it propagates as a normal GraphQL error as if no handler had matched. This lets
424+
`handle` act as a per-instance filter — match a broad exception type, but only
425+
convert the instances you recognize:
426+
427+
```python
428+
class ValidationErrorHandler(
429+
strawberry.ExceptionHandler[ValidationProblem, ValidationError]
430+
):
431+
def handle(
432+
self,
433+
exception: ValidationProblem,
434+
*,
435+
field: StrawberryField,
436+
info: Info,
437+
) -> ValidationError | None:
438+
if exception.is_user_facing:
439+
return ValidationError(message=str(exception))
440+
# Not one we want to expose — let it propagate as a normal error.
441+
return None
442+
```
443+
444+
When the error type is generic, parameterize the handler with the concrete
445+
instantiation that appears in the union (for example
446+
`strawberry.ExceptionHandler[MyError, ValidationError[int]]`), rather than the
447+
bare generic (`ValidationError`), so it matches the correct member of the union.
448+
449+
Converted exceptions are treated as expected GraphQL results. They are not added
450+
to the response's top-level `errors` list and are not passed to
451+
`Schema.process_errors`, so avoid using broad exception types such as
452+
`Exception` unless every matching error is safe to expose as a typed result.
453+
454+
On a synchronously executed field, `handle` must return its result
455+
synchronously. An `async` handler returns a coroutine, which fails the same way
456+
an `async` resolver does on a sync field: `execute_sync` returns an
457+
`ExecutionResult` whose `errors` contain a `GraphQLError` stating that execution
458+
could not complete synchronously.
459+
314460
---
315461

316462
## Additional resources:

docs/types/schema.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,12 @@ schema = strawberry.Schema(Query, types=[Individual, Company])
108108

109109
List of [extensions](/docs/extensions) to add to your Schema.
110110

111+
#### `exception_handlers: Iterable[ExceptionHandler] = ()`
112+
113+
List of handlers that convert expected Python exceptions into typed GraphQL
114+
union results. See
115+
[dealing with expected errors](/docs/guides/errors#mapping-expected-exceptions-to-union-results).
116+
111117
#### `scalar_overrides: Optional[Dict[object, ScalarWrapper]] = None`
112118

113119
Override the implementation of the built in scalars.

strawberry/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from .parent import Parent
1010
from .permission import BasePermission
1111
from .scalars import ID
12-
from .schema import Schema
12+
from .schema import ExceptionHandler, Schema
1313
from .schema_directive import schema_directive
1414
from .streamable import Streamable
1515
from .types.arguments import argument
@@ -31,6 +31,7 @@
3131
"ID",
3232
"UNSET",
3333
"BasePermission",
34+
"ExceptionHandler",
3435
"Info",
3536
"LazyType",
3637
"Maybe",

strawberry/exceptions/handler.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
original_threading_exception_hook = threading.excepthook
1111

1212

13-
ExceptionHandler = Callable[
13+
# Signature of ``sys.excepthook`` / ``threading.excepthook`` handlers. Named
14+
# ``ExcepthookHandler`` (not ``ExceptionHandler``) to avoid colliding with the
15+
# public ``strawberry.ExceptionHandler`` schema protocol.
16+
ExcepthookHandler = Callable[
1417
[type[BaseException], BaseException, TracebackType | None], None
1518
]
1619

@@ -21,7 +24,7 @@ def should_use_rich_exceptions() -> bool:
2124
return errors_disabled.lower() not in ["true", "1", "yes"]
2225

2326

24-
def _get_handler(exception_type: type[BaseException]) -> ExceptionHandler:
27+
def _get_handler(exception_type: type[BaseException]) -> ExcepthookHandler:
2528
if issubclass(exception_type, StrawberryException):
2629
try:
2730
import rich

strawberry/federation/schema.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from strawberry.extensions import SchemaExtension
3737
from strawberry.federation.schema_directives import ComposeDirective
3838
from strawberry.schema.config import StrawberryConfig
39+
from strawberry.schema.exception_handlers import ExceptionHandler
3940
from strawberry.schema_directive import StrawberrySchemaDirective
4041
from strawberry.types.enum import StrawberryEnumDefinition
4142

@@ -75,6 +76,7 @@ def __init__(
7576
"2.10",
7677
"2.11",
7778
] = "2.11",
79+
exception_handlers: Iterable["ExceptionHandler[Any]"] = (),
7880
) -> None:
7981
# Convert version string (e.g., "2.5") to version tuple (e.g., (2, 5))
8082
self.federation_version = parse_version(federation_version)
@@ -110,6 +112,7 @@ def __init__(
110112
config=config,
111113
scalar_overrides=federation_scalar_overrides,
112114
schema_directives=schema_directives,
115+
exception_handlers=exception_handlers,
113116
)
114117

115118
self.schema_directives = list(schema_directives)

strawberry/schema/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .base import BaseSchema
2+
from .exception_handlers import ExceptionHandler
23
from .schema import Schema
34

4-
__all__ = ["BaseSchema", "Schema"]
5+
__all__ = ["BaseSchema", "ExceptionHandler", "Schema"]

strawberry/schema/base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from graphql import GraphQLError
1313

1414
from strawberry.directive import StrawberryDirective
15+
from strawberry.schema.exception_handlers import ExceptionHandler
1516
from strawberry.schema.schema import StreamResult, SubscriptionResult
1617
from strawberry.schema.schema_converter import GraphQLCoreConverter
1718
from strawberry.types import (
@@ -37,6 +38,7 @@ class BaseSchema(Protocol):
3738
mutation: type[WithStrawberryObjectDefinition] | None
3839
subscription: type[WithStrawberryObjectDefinition] | None
3940
schema_directives: list[object]
41+
exception_handlers: tuple[ExceptionHandler[Any], ...]
4042

4143
@abstractmethod
4244
async def execute(

0 commit comments

Comments
 (0)