Skip to content

Uncaught exceptions lead to on_execute / on_operation lifecycle hooks completing before some resolvers #3414

@kkom

Description

@kkom

TL;DR

Strawberry short-circuits the HTTP response whenever there is an uncaught exception. This reduces the latency, but leads to:

(i) (a) incomplete and (b) nondeterministic responses (edit: established in the comments that it's expected)
(ii) hooks being completed before some resolvers, leading to apparent violation of a contract

I wonder if it would be possible to make Strawberry run all resolves to the end, even if some of them raise uncaught exceptions?

Describe the Bug

  1. Strawberry executes all resolvers, even if there was an uncaught exception which triggered an early HTTP response with errors.
  2. However, it eagerly returns a response with errors, as soon as an (edit: incoercible) exception is raised.
  3. Finally, it completes all lifecycle hooks before return the response – including on_execute and on_operation.

This last point can lead to issues – it violates the invariant that on_execute / on_operation lifecycle hooks wrap around all resolver executions.

This can be problematic when these hooks do state management, like in the example given in Strawberry's docs. As a result, in addition to seeing the original uncaught exception in our observability suite, we have additional noise from knock-on failures – caused by premature completion of hooks.

Is this natural behaviour given how various async tasks are orchestrated, or is possible to tweak this a little? I'm thinking:

  1. cancelling the tasks that won't be used for the response anyway (as it's been already returned)
  2. waiting until all resolvers finish to return the response

In fact, 2 may have other benefits – making the responses more (a) complete and (b) predictable. Currently, the GraphQL responses (i.e. which fields will return data and which won't) are non-deterministic (albeit a little faster thanks to the uncaught exception short-circuit). (edit: established in the comments that the short-circuiting is expected)

Repro code

Schema:

@strawberry.type
class Query:
    @strawberry.field
    @staticmethod
    async def fail() -> str:
        await sleep(0.5)
        raise Exception(f"'fail' resolver has failed ({datetime.now()})")

    @strawberry.field
    @staticmethod
    async def wait() -> str:
        await sleep(2)
        print(f"'wait' resolver is about to return ({datetime.now()})")
        return "foo"

Logging extension:

class MyCustomExtension(SchemaExtension):
    @override
    def on_execute(self) -> Generator[None, None, None]:
        print(f"'on_execute' start ({datetime.now()})")
        yield
        print(f"'on_execute' end ({datetime.now()})")

    @override
    async def resolve(
        self,
        _next: Callable[..., object],
        root: object,
        info: GraphQLResolveInfo,
        *args,
        **kwargs,
    ) -> AwaitableOrValue[object]:
        print(f"'{info.field_name}' resolver start ({datetime.now()})")
        result = await await_maybe(_next(root, info, *args, **kwargs))
        print(f"'{info.field_name}' resolver end ({datetime.now()})")
        return result

Example query:

query {
  fail
  wait
}

Example response:

{
  "data": null,
  "errors": [
    {
      "message": "'fail' resolver has failed (2024-03-19 21:08:12.088337)",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "fail"
      ]
    }
  ]
}

Logs demonstrating that the resolvers continue being executed after hooks complete:

'on_execute' start (2024-03-19 21:08:11.587192)
'fail' resolver start (2024-03-19 21:08:11.587345)
'wait' resolver start (2024-03-19 21:08:11.587378)
'fail' resolver has failed (2024-03-19 21:08:12.088337)

GraphQL request:2:3
1 | query {
2 |   fail
  |   ^
3 |   wait
Traceback (most recent call last):
  File "/Users/kkom/Repos/isometric/python/services/backend/.venv/lib/python3.12/site-packages/graphql/execution/execute.py", line 528, in await_result
    return_type, field_nodes, info, path, await result
                                          ^^^^^^^^^^^^
  File "/Users/kkom/Repos/isometric/python/services/backend/backend/api/graphql/extensions/extensions.py", line 30, in resolve
    result = await await_maybe(_next(root, info, *args, **kwargs))
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/kkom/Repos/isometric/python/services/backend/.venv/lib/python3.12/site-packages/strawberry/utils/await_maybe.py", line 12, in await_maybe
    return await value
           ^^^^^^^^^^^
  File "/Users/kkom/Repos/isometric/python/services/backend/.venv/lib/python3.12/site-packages/strawberry/schema/schema_converter.py", line 682, in _async_resolver
    return await await_maybe(
           ^^^^^^^^^^^^^^^^^^
  File "/Users/kkom/Repos/isometric/python/services/backend/.venv/lib/python3.12/site-packages/strawberry/utils/await_maybe.py", line 12, in await_maybe
    return await value
           ^^^^^^^^^^^
  File "/Users/kkom/Repos/isometric/python/services/backend/backend/api/graphql/schemas/public.py", line 55, in fail
    raise Exception(f"'fail' resolver has failed ({datetime.now()})")
Exception: 'fail' resolver has failed (2024-03-19 21:08:12.088337)
'on_execute' end (2024-03-19 21:08:12.096968)
INFO:     127.0.0.1:58138 - "POST /graphql HTTP/1.1" 200 OK
'wait' resolver is about to return (2024-03-19 21:08:13.588281)
'wait' resolver end (2024-03-19 21:08:13.588422)

System Information

  • Strawberry version (if applicable): 0.220.0

Additional Context

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions