Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
release type: minor
---

Add `DjangoExceptionHandler`, a schema-level Strawberry exception handler for
converting Django `ValidationError`, `PermissionDenied`, and `ObjectDoesNotExist`
exceptions into `OperationInfo` results. Combined with `handle_django_errors=True`,
it also handles exceptions raised during input conversion or by sync and async field
extensions.
28 changes: 28 additions & 0 deletions docs/guide/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,34 @@ def some_mutation(self, data: str) -> Result:
pass
```

## Handling Errors Outside the Resolver

`handle_django_errors=True` handles exceptions raised by the mutation resolver and
adds `OperationInfo` to its return union. To handle the same Django exceptions from
the rest of Strawberry's field execution pipeline, register
`DjangoExceptionHandler` on the schema:

```python title="schema.py"
import strawberry
import strawberry_django

from .mutations import Mutation
from .queries import Query


schema = strawberry.Schema(
query=Query,
mutation=Mutation,
exception_handlers=[strawberry_django.DjangoExceptionHandler()],
)
```

The schema-level handler also converts supported exceptions raised while building
input objects or running field extensions, including async extensions. It is only
selected for fields whose return union contains `OperationInfo`, so unrelated fields
continue to return normal GraphQL errors. Using `handle_django_errors=True` adds that
union member automatically.

## Custom Error Handling

### Custom Exception Classes
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ classifiers = [
"Framework :: Django :: 6.0",
]
requires-python = ">=3.10,<4.0"
dependencies = ["django>=5.2", "asgiref>=3.8", "strawberry-graphql>=0.310.1"]
dependencies = ["django>=5.2", "asgiref>=3.8", "strawberry-graphql>=0.321.0"]

[project.urls]
homepage = "https://strawberry.rocks/docs/django"
Expand Down
2 changes: 2 additions & 0 deletions strawberry_django/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from typing import TYPE_CHECKING, Any

from . import auth, federation, filters, mutations, ordering, pagination, relay
from .exception_handlers import DjangoExceptionHandler
from .fields.field import connection, field, node, offset_paginated
from .fields.filter_order import filter_field, order_field
from .fields.filter_types import (
Expand Down Expand Up @@ -40,6 +41,7 @@
"ComparisonFilterLookup",
"DateFilterLookup",
"DatetimeFilterLookup",
"DjangoExceptionHandler",
"DjangoFileType",
"DjangoImageType",
"DjangoModelType",
Expand Down
84 changes: 84 additions & 0 deletions strawberry_django/exception_handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
from __future__ import annotations

from typing import TYPE_CHECKING, TypeAlias

import strawberry
from django.core.exceptions import (
NON_FIELD_ERRORS,
ObjectDoesNotExist,
PermissionDenied,
ValidationError,
)
from strawberry.utils.str_converters import to_camel_case

from strawberry_django.fields.types import OperationInfo, OperationMessage

if TYPE_CHECKING:
from collections.abc import Iterator

from strawberry.types import Info
from strawberry.types.field import StrawberryField

DjangoError: TypeAlias = ValidationError | PermissionDenied | ObjectDoesNotExist


def _get_validation_error_message(error: ValidationError) -> str:
if not error.message:
return "Unknown error"

return error.message % error.params if error.params else error.message


def _get_validation_errors(error: DjangoError) -> Iterator[OperationMessage]:
if isinstance(error, PermissionDenied):
kind = OperationMessage.Kind.PERMISSION
elif isinstance(error, ValidationError):
kind = OperationMessage.Kind.VALIDATION
else:
kind = OperationMessage.Kind.ERROR

if isinstance(error, ValidationError) and hasattr(error, "error_dict"):
for field, field_errors in (error.error_dict or {}).items():
for field_error in field_errors:
yield OperationMessage(
kind=kind,
field=(to_camel_case(field) if field != NON_FIELD_ERRORS else None),
message=_get_validation_error_message(field_error),
code=getattr(field_error, "code", None),
)
elif isinstance(error, ValidationError) and hasattr(error, "error_list"):
for list_error in error.error_list or []:
yield OperationMessage(
kind=kind,
message=_get_validation_error_message(list_error),
code=getattr(error, "code", None),
)
else:
message = getattr(error, "msg", None)
if message is None:
message = str(error)

yield OperationMessage(
kind=kind,
message=message,
code=getattr(error, "code", None),
)


def _operation_info_from_exception(error: DjangoError) -> OperationInfo:
return OperationInfo(messages=list(_get_validation_errors(error)))


class DjangoExceptionHandler(
strawberry.ExceptionHandler[DjangoError, OperationInfo],
):
"""Convert expected Django exceptions into ``OperationInfo`` results."""

def handle(
self,
exception: DjangoError,
*,
field: StrawberryField,
info: Info,
) -> OperationInfo:
return _operation_info_from_exception(exception)
69 changes: 6 additions & 63 deletions strawberry_django/mutations/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

import strawberry
from django.core.exceptions import (
NON_FIELD_ERRORS,
ObjectDoesNotExist,
PermissionDenied,
ValidationError,
Expand All @@ -17,11 +16,12 @@
from strawberry.utils.str_converters import capitalize_first, to_camel_case

from strawberry_django.arguments import argument
from strawberry_django.exception_handlers import _operation_info_from_exception
from strawberry_django.fields.field import (
StrawberryDjangoFieldBase,
StrawberryDjangoFieldFilters,
)
from strawberry_django.fields.types import OperationInfo, OperationMessage
from strawberry_django.fields.types import OperationInfo
from strawberry_django.optimizer import DjangoOptimizerExtension, optimize
from strawberry_django.permissions import filter_with_perms, get_with_perms
from strawberry_django.resolvers import django_resolver
Expand Down Expand Up @@ -49,62 +49,6 @@
_T = TypeVar("_T", bound="models.Model | list[models.Model]")


def _get_validaton_error_message(error: ValidationError):
if not error.message:
return "Unknown error"

return error.message % error.params if error.params else error.message


def _get_validation_errors(error: Exception):
if isinstance(error, PermissionDenied):
kind = OperationMessage.Kind.PERMISSION
elif isinstance(error, ValidationError):
kind = OperationMessage.Kind.VALIDATION
elif isinstance(error, ObjectDoesNotExist):
kind = OperationMessage.Kind.ERROR
else:
kind = OperationMessage.Kind.ERROR

if isinstance(error, ValidationError) and hasattr(error, "error_dict"):
# convert field errors
for field, field_errors in (error.error_dict or {}).items():
for e in field_errors:
yield OperationMessage(
kind=kind,
field=to_camel_case(field) if field != NON_FIELD_ERRORS else None,
message=_get_validaton_error_message(e),
code=getattr(e, "code", None),
)
elif isinstance(error, ValidationError) and hasattr(error, "error_list"):
# convert non-field errors
for e in error.error_list or []:
yield OperationMessage(
kind=kind,
message=_get_validaton_error_message(e),
code=getattr(error, "code", None),
)
else:
msg = getattr(error, "msg", None)
if msg is None:
msg = str(error)

yield OperationMessage(
kind=kind,
message=msg,
code=getattr(error, "code", None),
)


def _handle_exception(error: Exception):
if isinstance(error, (ValidationError, PermissionDenied, ObjectDoesNotExist)):
return OperationInfo(
messages=list(_get_validation_errors(error)),
)

raise error


class DjangoMutationBase(StrawberryDjangoFieldBase):
def __init__(
self,
Expand Down Expand Up @@ -166,19 +110,18 @@ def get_result(
if not self.handle_errors:
return self.resolver(source, info, args, kwargs)

# TODO: Any other exception types that we should capture here?
try:
resolved = self.resolver(source, info, args, kwargs)
except Exception as e: # noqa: BLE001
return _handle_exception(e)
except (ValidationError, PermissionDenied, ObjectDoesNotExist) as error:
return _operation_info_from_exception(error)

if inspect.isawaitable(resolved):

async def async_resolver():
try:
return await resolved
except Exception as e: # noqa: BLE001
return _handle_exception(e)
except (ValidationError, PermissionDenied, ObjectDoesNotExist) as error:
return _operation_info_from_exception(error)

return async_resolver()

Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ class Query:
tags: list[types.Tag] = strawberry_django.field()

if request.param == "optimizer_enabled":
extensions = [DjangoOptimizerExtension()]
extensions = [DjangoOptimizerExtension]
elif request.param == "optimizer_disabled":
extensions = []
else:
Expand Down
2 changes: 1 addition & 1 deletion tests/extensions/test_validation_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def hello(self) -> str:
def ping(self) -> str:
return "pong"

schema = strawberry.Schema(query=Query, extensions=[DjangoValidationCache()])
schema = strawberry.Schema(query=Query, extensions=[DjangoValidationCache])

query = "query { hello }"

Expand Down
2 changes: 1 addition & 1 deletion tests/federation/test_resolve_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ def hello(self) -> str:
schema = FederationSchema(
query=Query,
types=[FruitType],
extensions=[RecordingOptimizerExtension()],
extensions=[RecordingOptimizerExtension],
)

fruit = models.Fruit.objects.create(name="optimized-fruit")
Expand Down
4 changes: 2 additions & 2 deletions tests/federation/test_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -682,7 +682,7 @@ def hello(self) -> str:
schema = FederationSchema(
query=Query,
types=[FruitType],
extensions=[DjangoOptimizerExtension()],
extensions=[DjangoOptimizerExtension],
)

fruit = models.Fruit.objects.create(name="optimized")
Expand Down Expand Up @@ -732,7 +732,7 @@ def hello(self) -> str:
schema = FederationSchema(
query=Query,
types=[FruitType, ColorType],
extensions=[DjangoOptimizerExtension()],
extensions=[DjangoOptimizerExtension],
)

color = models.Color.objects.create(name="yellow")
Expand Down
6 changes: 3 additions & 3 deletions tests/relay/test_cursor_pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ def projects_with_resolver() -> list[ProjectType]:
return cast("list[ProjectType]", Project.objects.all().order_by("-pk"))


schema = strawberry.Schema(query=Query, extensions=[DjangoOptimizerExtension()])
schema = strawberry.Schema(query=Query, extensions=[DjangoOptimizerExtension])


@pytest.fixture
Expand Down Expand Up @@ -1447,7 +1447,7 @@ class FruitQuery:
fruits: DjangoCursorConnection[FruitGQL] = strawberry_django.connection()

fruit_schema = strawberry.Schema(
query=FruitQuery, extensions=[DjangoOptimizerExtension()]
query=FruitQuery, extensions=[DjangoOptimizerExtension]
)

ft1 = models.FruitType.objects.create(name="tropical")
Expand Down Expand Up @@ -1520,7 +1520,7 @@ class FruitQuery:
fruits: DjangoCursorConnection[FruitGQL] = strawberry_django.connection()

fruit_schema = strawberry.Schema(
query=FruitQuery, extensions=[DjangoOptimizerExtension()]
query=FruitQuery, extensions=[DjangoOptimizerExtension]
)

ft1 = models.FruitType.objects.create(name="tropical")
Expand Down
8 changes: 6 additions & 2 deletions tests/test_custom_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,9 @@ class StandardQuery:
standard_schema = strawberry.Schema(
query=StandardQuery,
extensions=[
DjangoOptimizerExtension(enable_only_optimization=enable_only_optimization)
lambda: DjangoOptimizerExtension(
enable_only_optimization=enable_only_optimization,
),
],
)

Expand Down Expand Up @@ -243,7 +245,9 @@ def test_fragment_with_custom_connection_no_n1(enable_only_optimization: bool):
schema = strawberry.Schema(
query=Query,
extensions=[
DjangoOptimizerExtension(enable_only_optimization=enable_only_optimization)
lambda: DjangoOptimizerExtension(
enable_only_optimization=enable_only_optimization,
),
],
)

Expand Down
Loading
Loading