Skip to content

Commit

Permalink
feat: add extension to support partial errors, response with data and…
Browse files Browse the repository at this point in the history
… errors
  • Loading branch information
bradleyoesch committed Aug 25, 2023
1 parent 6d86d1c commit 2edc713
Show file tree
Hide file tree
Showing 4 changed files with 397 additions and 0 deletions.
147 changes: 147 additions & 0 deletions docs/extensions/partial-results.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
---
title: PartialResultsExtension
summary: Hide error messages from the client.
tags: errors
---

# `PartialResultsExtension`

This extension enables populating an array of exceptions on the `info.context` object
that get mapped to the `errors` array after execution,
enabling responses to contain both `data` and `errors` values.
This also enables returning multiple errors in the response.

1. Add `PartialResultsExtension` to the `extensions` array in `Schema` initialization
1. In execution, add any exceptions to the `info.context.partial_errors` array

## Usage example:

```python
import strawberry
from strawberry.extensions import PartialResultsExtension

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

# ...


@strawberry.field
def query(self, info) -> bool:
info.context.partial_errors.append(Exception("Partial failure"))
return True
```

Results:

```json
{
"data": {
"query": true
},
"errors": [
{
"message": "Partial failure"
}
]
}
```

## API reference:

_No arguments_

## More examples:

<details>
<summary>Add multiple exceptions</summary>

```python
import strawberry
from strawberry.extensions import PartialResultsExtension

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

# ...


@strawberry.field
def query(self, info) -> bool:
info.context.partial_errors.extend(
[
Exception("Failure 0"),
Exception("Failure 1"),
Exception("Failure 2"),
]
)
return True
```

Results:

```json
{
"data": {
"query": true
},
"errors": [
{
"message": "Failure 0"
},
{
"message": "Failure 1"
},
{
"message": "Failure 2"
}
]
}
```

</details>

<details>
<summary>Add GraphQLError with location and path detail</summary>

```python
import strawberry
from strawberry.extensions import PartialResultsExtension

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

# ...


@strawberry.field
def query(self, info) -> bool:
nodes = [next(n for n in info.field_nodes if n.name.value == "query")]
info.context.partial_errors.append(
located_error(
Exception("Error with location and path information"),
nodes=nodes,
path=info.path.as_list(),
),
)
return True
```

Results:

```json
{
"data": {
"query": true
},
"errors": [
{
"message": "Error with location and path information",
"location": {
"line": 1,
"column": 9
},
"path": ["query"]
}
]
}
```

</details>
2 changes: 2 additions & 0 deletions strawberry/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from .max_aliases import MaxAliasesLimiter
from .max_tokens import MaxTokensLimiter
from .parser_cache import ParserCache
from .partial_results import PartialResultsExtension
from .query_depth_limiter import IgnoreContext, QueryDepthLimiter
from .validation_cache import ValidationCache

Expand All @@ -34,6 +35,7 @@ def __getattr__(name: str):
"AddValidationRules",
"DisableValidation",
"ParserCache",
"PartialResultsExtension",
"QueryDepthLimiter",
"IgnoreContext",
"ValidationCache",
Expand Down
85 changes: 85 additions & 0 deletions strawberry/extensions/partial_results.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
from graphql import GraphQLError, located_error

from strawberry.extensions import SchemaExtension
from strawberry.utils.await_maybe import AsyncIteratorOrIterator


class PartialResultsExtension(SchemaExtension):
"""
Allow returning partial results, with both:
- non-null `data` field
- non-empty `errors` array
One powerful feature of GraphQL is returning both data AND errors
The typical way to return an error in python graphql is to simply raise it,
and let the library catch it and plumb through to a singleton `errors` array
But this means we can't return data AND errors
To work around this issue, this extension adds errors to the result after execution
Note:
Because these errors are not caught and formatted by the library,
we lose some extra error metadata like locations and path, which can sometimes
be auto-added for us in the errors array
Users of this extension can optionally wrap their exceptions in a
`GraphQLError` to add those fields themselves
Usage:
```
@strawberry.field
def query(self, info: Info) -> bool:
info.context.partial_errors.append(Exception("Partial failure"))
return True
```
"""

def on_execute(
self,
) -> AsyncIteratorOrIterator[None]:
"""
Before execution:
- Initialize `partial_errors` to an empty list
After execution:
- Pull any errors off `partial_errors` and add them to
the `result` as `GraphQLErrors`
- To mirror existing library code, add to `execution_context` and process them
since partial errors would otherwise get skipped
"""
initial: list[Exception] = []
if self.execution_context.context:
self.execution_context.context.partial_errors = initial

yield

result = self.execution_context.result
context = self.execution_context.context
if result and context and context.partial_errors:
# map all partial errors to `GraphQLError` if not one already
partial_errors: list[GraphQLError] = [
error if isinstance(error, GraphQLError) else located_error(error)
for error in context.partial_errors
]

# add partial errors to result's errors
if not result.errors:
result.errors = []
result.errors.extend(partial_errors)

# below block copied from `strawberry.schema.execute.execute(_sync)`
# to mirror what was skipped in the library's execution
# note that we set the execution context to ALL errors,
# but only process the partial errors,
# because all others were already processed

# === begin block ===
# Also set errors on the execution_context so that it's easier
# to access in extensions
self.execution_context.errors = result.errors
# Run the `Schema.process_errors` function here before
# extensions have a chance to modify them (see the MaskErrors
# extension). That way we can log the original errors but
# only return a sanitised version to the client.
self.execution_context.schema.process_errors(
partial_errors, self.execution_context
)
# === end block ===
Loading

0 comments on commit 2edc713

Please sign in to comment.