Skip to content

Fix #3680: MaskErrors does not mask errors for subscriptions#4301

Open
Ladol wants to merge 2 commits intostrawberry-graphql:mainfrom
Ladol:issue3680
Open

Fix #3680: MaskErrors does not mask errors for subscriptions#4301
Ladol wants to merge 2 commits intostrawberry-graphql:mainfrom
Ladol:issue3680

Conversation

@Ladol
Copy link

@Ladol Ladol commented Mar 10, 2026

Release type: patch

Fixes an issue where schema extensions (like MaskErrors) were bypassed during WebSocket subscriptions. The extensions' _process_result hooks are now properly triggered for each yielded result in both graphql-transport-ws and graphql-ws protocols, ensuring errors are correctly formatted before being sent to the client.

Description

Fixes an issue where schema extensions (such as MaskErrors) were being bypassed when streaming data over WebSockets.

Previously, standard Queries and Mutations would pass their results through the extension pipeline, but Subscriptions would send raw ExecutionResult objects directly over the WebSocket. This caused internal/unmasked errors to leak to the client. This PR manually triggers _process_result on active extensions right before send_next and send_data_message dispatch the payload.

Migration guide

No migration required.

Types of Changes

  • Core
  • Bugfix
  • New feature
  • Enhancement/optimization
  • Documentation

Checklist

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • I have tested the changes and verified that they work and don't break anything (as well as I can manage).

Summary by Sourcery

Ensure WebSocket GraphQL subscriptions pass execution results through schema extensions so errors are consistently processed before being sent to clients.

Bug Fixes:

  • Route subscription execution results through active schema extensions for both graphql-transport-ws and graphql-ws protocols so masking and formatting are applied to errors.

Documentation:

  • Add a release note describing the subscription error masking fix and marking the release as a patch update.

Tests:

  • Add websocket subscription tests asserting that schema extension _process_result hooks are invoked when a subscription yields errors.

…bscriptions

Fixes an issue where schema extensions (like `MaskErrors`) were
bypassed during WebSocket subscriptions. The extensions'
`_process_result` hooks are now properly triggered for each
yielded result in both `graphql-transport-ws` and `graphql-ws`
protocols, ensuring errors are correctly formatted before being
sent to the client.
@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Mar 10, 2026

Reviewer's Guide

Ensures WebSocket subscription results are passed through schema extensions (e.g., MaskErrors) before being sent to clients, by invoking extension _process_result hooks for graphql-ws and graphql-transport-ws subscription payloads and adding regression tests and release notes.

File-Level Changes

Change Details Files
Invoke schema extensions for graphql-transport-ws subscription results before sending next messages.
  • Introduce a _process_extensions helper on the GraphQL transport WS operation handler to process ExecutionResult via active schema extensions if errors are present.
  • Resolve extension instances from handler.schema.extensions, supporting both extension classes and pre-instantiated extensions, and call _process_result when available.
  • Call _process_extensions from send_next so every subscription payload is processed before constructing and sending the NextMessage.
strawberry/subscriptions/protocols/graphql_transport_ws/handlers.py
Invoke schema extensions for graphql-ws subscription results before sending data messages.
  • Before building the DataMessage in send_data_message, iterate over self.schema.extensions and invoke _process_result on each extension instance when the ExecutionResult contains errors.
  • Support both extension classes and instances when resolving extension objects in the graphql-ws handler.
strawberry/subscriptions/protocols/graphql_ws/handlers.py
Add regression tests ensuring subscription errors trigger extension processing for both WebSocket protocols.
  • For graphql-ws, patch MyExtension._process_result, start a subscription that throws, assert the error is present in the data message and that _process_result was called exactly once, and verify normal completion on stop.
  • For graphql-transport-ws, similarly patch MyExtension._process_result, start a failing subscription via subscribe, and assert that the next message includes errors and that _process_result was called exactly once.
tests/websockets/test_graphql_ws.py
tests/websockets/test_graphql_transport_ws.py
Document the patch release and bugfix behavior around schema extensions and WebSocket subscriptions.
  • Add RELEASE.md describing the bug where schema extensions were bypassed for WebSocket subscriptions, the fix using _process_result hooks, and that no migration is required.
RELEASE.md

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@botberry
Copy link
Member

Thanks for adding the RELEASE.md file!

Here's a preview of the changelog:


Fixes an issue where schema extensions (like MaskErrors) were bypassed during WebSocket subscriptions. The extensions' _process_result hooks are now properly triggered for each yielded result in both graphql-transport-ws and graphql-ws protocols, ensuring errors are correctly formatted before being sent to the client.

Description

Fixes an issue where schema extensions (such as MaskErrors) were being bypassed when streaming data over WebSockets.

Previously, standard Queries and Mutations would pass their results through the extension pipeline, but Subscriptions would send raw ExecutionResult objects directly over the WebSocket. This caused internal/unmasked errors to leak to the client. This PR manually triggers _process_result on active extensions right before send_next and send_data_message dispatch the payload.

Migration guide

No migration required.

Types of Changes

  • Core
  • Bugfix
  • New feature
  • Enhancement/optimization
  • Documentation

Checklist

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • I have tested the changes and verified that they work and don't break anything (as well as I can manage).

Here's the tweet text:

🆕 Release (next) is out! Thanks to Ladol for the PR 👏

Get it here 👉 https://strawberry.rocks/release/(next)

@botberry
Copy link
Member

Thanks for adding the RELEASE.md file!

Here's a preview of the changelog:


Fixes an issue where schema extensions (like MaskErrors) were bypassed during WebSocket subscriptions. The extensions' _process_result hooks are now properly triggered for each yielded result in both graphql-transport-ws and graphql-ws protocols, ensuring errors are correctly formatted before being sent to the client.

Description

Fixes an issue where schema extensions (such as MaskErrors) were being bypassed when streaming data over WebSockets.

Previously, standard Queries and Mutations would pass their results through the extension pipeline, but Subscriptions would send raw ExecutionResult objects directly over the WebSocket. This caused internal/unmasked errors to leak to the client. This PR manually triggers _process_result on active extensions right before send_next and send_data_message dispatch the payload.

Migration guide

No migration required.

Types of Changes

  • Core
  • Bugfix
  • New feature
  • Enhancement/optimization
  • Documentation

Checklist

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • I have tested the changes and verified that they work and don't break anything (as well as I can manage).

Here's the tweet text:

🆕 Release (next) is out! Thanks to Ladol for the PR 👏

Get it here 👉 https://strawberry.rocks/release/(next)

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • The extension processing logic is duplicated between graphql_transport_ws (_process_extensions) and graphql_ws.send_data_message; consider extracting a shared helper or aligning them through a common abstraction so the two protocols stay in sync as behavior evolves.
  • The current approach instantiates extensions (ext() when isinstance(ext, type)) on each result, which may diverge from how extensions are normally constructed and maintain state in the HTTP pipeline; it would be safer to reuse the same extension instances and lifecycle that are used for non-subscription operations.
  • In send_next you only run extensions when execution_result.errors is truthy, but other code paths may run _process_result for all results; consider matching the existing extension pipeline behavior so subscriptions are processed consistently with queries/mutations.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The extension processing logic is duplicated between `graphql_transport_ws` (`_process_extensions`) and `graphql_ws.send_data_message`; consider extracting a shared helper or aligning them through a common abstraction so the two protocols stay in sync as behavior evolves.
- The current approach instantiates extensions (`ext()` when `isinstance(ext, type)`) on each result, which may diverge from how extensions are normally constructed and maintain state in the HTTP pipeline; it would be safer to reuse the same extension instances and lifecycle that are used for non-subscription operations.
- In `send_next` you only run extensions when `execution_result.errors` is truthy, but other code paths may run `_process_result` for all results; consider matching the existing extension pipeline behavior so subscriptions are processed consistently with queries/mutations.

## Individual Comments

### Comment 1
<location path="strawberry/subscriptions/protocols/graphql_transport_ws/handlers.py" line_range="375-377" />
<code_context>
         self.completed = False
         self.task: asyncio.Task | None = None

+    def _process_extensions(self, execution_result: ExecutionResult) -> None:
+        """Run the execution result through any active schema extensions."""
+        if not execution_result.errors:
+            return
+
</code_context>
<issue_to_address>
**suggestion (bug_risk):** The early-return on `execution_result.errors` doesn’t match the docstring and may skip useful extension hooks for successful results.

Right now, extensions won’t run for successful results, which contradicts the stated behavior and may break extensions that rely on seeing all results (e.g., logging/metrics/tracing). Consider either removing the `if not execution_result.errors` check or updating the docstring/contract to clarify that extensions only run on error cases.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +375 to +377
def _process_extensions(self, execution_result: ExecutionResult) -> None:
"""Run the execution result through any active schema extensions."""
if not execution_result.errors:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): The early-return on execution_result.errors doesn’t match the docstring and may skip useful extension hooks for successful results.

Right now, extensions won’t run for successful results, which contradicts the stated behavior and may break extensions that rely on seeing all results (e.g., logging/metrics/tracing). Consider either removing the if not execution_result.errors check or updating the docstring/contract to clarify that extensions only run on error cases.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 10, 2026

Greptile Summary

This PR fixes a real bug (issue #3680) where MaskErrors and other schema extensions were never applied to subscription results sent over WebSocket, causing raw internal errors to leak to clients. The fix manually calls _process_result on each extension before send_next (graphql-transport-ws) and send_data_message (graphql-ws) dispatch the payload.

The approach works for MaskErrors as it stands today, but has two structural problems that make it fragile for the general case:

  • Missing execution_context: The normal execution path sets extension.execution_context = execution_context on every instance before calling any lifecycle method (see schema.py:591). The new code skips this step entirely — any extension whose _process_result accesses self.execution_context will raise an AttributeError at runtime. Both handlers are affected.
  • Early error guard changes semantics: The outer if not execution_result.errors: return guard prevents _process_result from being called for non-error results, which is inconsistent with the normal path and silently breaks extensions that need to process all results.
  • Code duplication: The graphql_ws handler duplicates the extension-processing logic inline instead of reusing the _process_extensions helper added to the graphql_transport_ws handler.
  • Shallow test coverage: The new tests verify only that _process_result is called, not that errors are actually masked in the final response payload. An end-to-end test using a real MaskErrors instance would provide stronger guarantees.

Confidence Score: 2/5

  • The fix works for MaskErrors but is structurally fragile — freshly created extension instances never receive execution_context, which will break any extension that accesses it in _process_result.
  • The core idea is correct and the targeted MaskErrors use-case is fixed, but the implementation bypasses the established extension instantiation contract (execution_context assignment), duplicates code between handlers, and has an overly narrow error-only guard. These issues make it unsafe to merge as-is for projects using custom extensions that rely on execution_context inside _process_result.
  • strawberry/subscriptions/protocols/graphql_transport_ws/handlers.py and strawberry/subscriptions/protocols/graphql_ws/handlers.py both need the execution_context assignment fixed before merging.

Important Files Changed

Filename Overview
strawberry/subscriptions/protocols/graphql_transport_ws/handlers.py Adds _process_extensions helper and calls it from send_next; however, fresh extension instances are created without execution_context, and an outer error-only guard changes semantics for other extensions.
strawberry/subscriptions/protocols/graphql_ws/handlers.py Inline extension processing added to send_data_message with the same execution_context omission; logic is duplicated rather than reusing the shared helper from the other handler.
tests/websockets/test_graphql_transport_ws.py Adds a test verifying _process_result is invoked for subscription errors; test is limited to checking the call count and does not validate actual error masking output or end-to-end behaviour.
tests/websockets/test_graphql_ws.py Mirrors the graphql_transport_ws test for the legacy graphql-ws protocol; same coverage limitations apply.
RELEASE.md New release notes file describing the bug fix and its scope; no code issues.

Sequence Diagram

sequenceDiagram
    participant Client
    participant WSHandler
    participant ExecutionEngine
    participant ExtensionsRunner
    participant MaskErrors

    Note over Client,MaskErrors: Normal Query/Mutation path (working correctly)
    Client->>WSHandler: HTTP POST /graphql
    WSHandler->>ExtensionsRunner: on_operation() enter
    WSHandler->>ExecutionEngine: execute()
    ExecutionEngine-->>WSHandler: ExecutionResult
    WSHandler->>ExtensionsRunner: on_operation() exit
    ExtensionsRunner->>MaskErrors: _process_result(result) [via on_operation yield, with execution_context set]
    MaskErrors-->>ExtensionsRunner: errors masked
    WSHandler-->>Client: masked response

    Note over Client,MaskErrors: Subscription path — this PR's fix
    Client->>WSHandler: WebSocket subscribe
    WSHandler->>ExecutionEngine: subscribe()
    loop each yielded result
        ExecutionEngine-->>WSHandler: ExecutionResult (raw)
        WSHandler->>WSHandler: _process_extensions(result)
        WSHandler->>MaskErrors: ext()._process_result(result) [NEW instance, no execution_context]
        MaskErrors-->>WSHandler: errors masked (works for MaskErrors only)
        WSHandler-->>Client: send_next / send_data_message
    end
Loading

Last reviewed commit: 279388a

Comment on lines +375 to +384
def _process_extensions(self, execution_result: ExecutionResult) -> None:
"""Run the execution result through any active schema extensions."""
if not execution_result.errors:
return

extensions = getattr(self.handler.schema, "extensions", [])
for ext in extensions:
extension_instance = ext() if isinstance(ext, type) else ext
if hasattr(extension_instance, "_process_result"):
extension_instance._process_result(execution_result)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Freshly created instances never receive execution_context

The ext() call creates a brand-new extension instance, but the normal execution path (see schema.py line 591) explicitly sets extension.execution_context = execution_context on every instance after creation. This step is skipped here, so any extension whose _process_result accesses self.execution_context will raise an AttributeError at runtime.

MaskErrors._process_result happens to not use self.execution_context, which is why it works in the tests, but this is fragile for other extensions.

The correct approach is to reuse the already-configured extension instances from the current request's SchemaExtensionsRunner, or — at minimum — assign execution_context to each freshly created instance before calling _process_result.

def _process_extensions(self, execution_result: ExecutionResult) -> None:
    """Run the execution result through any active schema extensions."""
    if not execution_result.errors:
        return

    extensions = getattr(self.handler.schema, "_async_extensions", [])
    for extension_instance in extensions:
        if hasattr(extension_instance, "_process_result"):
            extension_instance._process_result(execution_result)

Comment on lines +219 to +224
if execution_result.errors:
extensions = getattr(self.schema, "extensions", [])
for ext in extensions:
extension_instance = ext() if isinstance(ext, type) else ext
if hasattr(extension_instance, "_process_result"):
extension_instance._process_result(execution_result)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same execution_context omission as in graphql_transport_ws

This inline block has the same problem: ext() creates a fresh extension instance with no execution_context, while the standard path sets extension.execution_context = execution_context before any lifecycle method is called. Extensions that access self.execution_context inside _process_result will fail with AttributeError.

Additionally, the logic is duplicated inline here rather than extracted to a shared helper (as was done in the graphql_transport_ws handler with _process_extensions), making future maintenance harder.

Comment on lines +377 to +378
if not execution_result.errors:
return
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Early return silently skips non-error results for all extensions

The guard if not execution_result.errors: return prevents _process_result from ever being called on results that have no errors. While MaskErrors._process_result itself has the same guard internally, this outer early exit means any other extension whose _process_result needs to inspect or transform non-error results will be silently skipped.

The normal execution path (via on_operation) calls _process_result unconditionally and lets each extension decide what to do. Consider removing the guard here and letting each extension handle its own filtering, for consistency.

Comment on lines +1220 to +1242

@patch.object(MyExtension, "_process_result", create=True)
async def test_subscription_errors_trigger_extension_process_result(
mock: Mock, ws: WebSocketClient
):
"""Test that schema extensions are called to process results when a subscription yields an error."""
await ws.send_message(
{
"id": "sub1",
"type": "subscribe",
"payload": {
"query": 'subscription { exception(message: "TEST EXC") }',
},
}
)

next_message: NextMessage = await ws.receive_json()

assert next_message["type"] == "next"
assert next_message["id"] == "sub1"
assert "errors" in next_message["payload"]

# Error intercepted and extension called
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test validates call count but not actual masking behaviour

The test patches _process_result with a no-op mock and asserts it was called once. This confirms the hook fires, but does not verify that error masking actually works end-to-end (e.g., that the error message is replaced with "Unexpected error." and that original exception details are not leaked to the client).

Consider adding a complementary integration test that uses a real MaskErrors extension and asserts the response contains the masked message rather than the raw exception text. This would catch regressions like the configuration-loss issue described in the handler comment.

Also note there is a missing blank line before the @patch.object decorator (PEP 8 E302).

Suggested change
@patch.object(MyExtension, "_process_result", create=True)
async def test_subscription_errors_trigger_extension_process_result(
mock: Mock, ws: WebSocketClient
):
"""Test that schema extensions are called to process results when a subscription yields an error."""
await ws.send_message(
{
"id": "sub1",
"type": "subscribe",
"payload": {
"query": 'subscription { exception(message: "TEST EXC") }',
},
}
)
next_message: NextMessage = await ws.receive_json()
assert next_message["type"] == "next"
assert next_message["id"] == "sub1"
assert "errors" in next_message["payload"]
# Error intercepted and extension called
@patch.object(MyExtension, "_process_result", create=True)

needs two blank lines after the previous test function body.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants