Skip to content

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

Closed
Ladol wants to merge 6 commits intostrawberry-graphql:mainfrom
Ladol:issue3680
Closed

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

Conversation

@Ladol
Copy link
Copy Markdown

@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.

@sourcery-ai
Copy link
Copy Markdown
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
Copy Markdown
Member

botberry commented Mar 10, 2026

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
Copy Markdown
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
Copy Markdown
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.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 10, 2026

Greptile Summary

This PR fixes a long-standing bug where schema extensions such as MaskErrors were bypassed for WebSocket subscription results in both the graphql-transport-ws and graphql-ws protocols. Previously, each yielded ExecutionResult was sent directly to the client without passing through the extension pipeline, causing raw/unmasked errors to leak. The fix introduces a new build_operation_extensions helper and calls _process_result on each extension before dispatching the payload.

What changed:

  • strawberry/subscriptions/utils.py — new build_operation_extensions function instantiates per-operation extension objects using inspect.signature to handle constructors that do/don't accept execution_context.
  • graphql_transport_ws/handlers.pyOperation.__init__ builds operation_extensions once; send_next loops over them before serialising.
  • graphql_ws/handlers.pyhandle_start builds operation_extensions keyed by operation_id; send_data_message iterates them before serialising; cleanup_operation removes the entry.
  • Three new tests per protocol: a mock-based hook-invocation test, an end-to-end real-MaskErrors masking test, and a class-as-extension masking test.

Remaining concerns (not fully resolved):

  • Instance-based extensions are double-processed on the last event: the same object is held by both operation_extensions and the SchemaExtensionsRunner inside schema.subscribe(). _process_result is called once by the new code (correct, before send) and again by on_operation's post-yield after the generator is exhausted (harmless for MaskErrors but incorrect for non-idempotent extensions).
  • Class-based extension constructor arguments are silently dropped: build_operation_extensions calls ext() or ext(execution_context=None) with no other arguments, discarding any configuration that the class's __init__ might accept.
  • schema.extensions vs get_extensions(): dynamically injected extensions (e.g. DirectivesExtension) added inside get_extensions() are not present in schema.extensions, so they are never processed in the subscription path.

Confidence Score: 3/5

  • Core bug is fixed for the primary use case (MaskErrors), but two structural issues in build_operation_extensions — dropped constructor args for class-based extensions and double _process_result calls for instance-based extensions — create correctness traps for non-trivial extensions.
  • The fix correctly patches the primary reported bug and is well-tested for MaskErrors. However, the build_operation_extensions approach introduces two real regressions for less common but valid extension patterns: (1) class-based extensions with non-execution_context constructor parameters receive silently misconfigured instances, and (2) instance-based extensions have _process_result called twice on the last event. These are P1 logic issues for anyone using custom stateful or configurable extensions, which warrants a score below 4.
  • strawberry/subscriptions/utils.py — the build_operation_extensions function has both issues; consider replacing with reuse of the per-request SchemaExtensionsRunner that schema.subscribe() already builds.

Important Files Changed

Filename Overview
strawberry/subscriptions/utils.py New utility that builds per-operation extension instances. Uses inspect.signature to detect whether a class-based extension accepts execution_context, then sets it to None. Instance-based extensions are returned as-is (shared across concurrent subscriptions), which is consistent with the non-subscription path but still means stateful instance-based extensions share state. Class-based instances receive execution_context=None permanently since they are disconnected from SchemaExtensionsRunner.
strawberry/subscriptions/protocols/graphql_transport_ws/handlers.py Extensions are built once in Operation.__init__ (not per-event), which is the correct granularity. send_next iterates self.operation_extensions and calls _process_result unconditionally before serialising, correctly allowing each extension to decide whether to act.
strawberry/subscriptions/protocols/graphql_ws/handlers.py Extensions are populated in handle_start and cleaned up in cleanup_operation. send_data_message correctly uses .get(operation_id, []) to tolerate any timing edge. Logic is consistent with the graphql_transport_ws handler.
tests/websockets/test_graphql_transport_ws.py Three new tests added: a mock-based hook-invocation test, an end-to-end masking test with a real MaskErrors instance, and a class-extension masking test. The end-to-end tests properly validate the masked error message, directly addressing a gap raised in prior review rounds.
tests/websockets/test_graphql_ws.py Parallel set of three new tests for the graphql-ws protocol. These include proper cleanup (stop + consuming complete) and mirror the transport-ws tests well.

Sequence Diagram

sequenceDiagram
    participant Client
    participant WSHandler as WS Handler<br/>(graphql_transport_ws / graphql_ws)
    participant Schema as schema.subscribe()
    participant ExtRunner as SchemaExtensionsRunner
    participant OpExt as operation_extensions<br/>(build_operation_extensions)
    participant MaskErrors

    Client->>WSHandler: subscribe / start message
    WSHandler->>OpExt: build_operation_extensions(schema.extensions)
    Note over OpExt: Class-based: new instance (execution_context=None)<br/>Instance-based: same object as ExtRunner uses

    WSHandler->>Schema: schema.subscribe(query, ...)
    Schema->>ExtRunner: extensions_runner.operation() begin
    loop Each subscription event
        Schema-->>WSHandler: yield ExecutionResult
        WSHandler->>OpExt: ext._process_result(result) [NEW]
        OpExt->>MaskErrors: _process_result(result) — masks errors ✓
        WSHandler->>Client: send next/data message (masked)
    end
    Schema->>ExtRunner: extensions_runner.operation() end (post-yield)
    ExtRunner->>MaskErrors: on_operation post-yield → _process_result(last result) [DUPLICATE for instance-based]
    Note over MaskErrors: Harmless for MaskErrors (idempotent),<br/>but a trap for stateful extensions

    Client->>WSHandler: stop / close
    WSHandler->>OpExt: cleanup — pop operation_extensions
Loading

Reviews (6): Last reviewed commit: "refactor: scope extension instantiation ..." | Re-trigger Greptile

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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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.

@Ladol
Copy link
Copy Markdown
Author

Ladol commented Mar 16, 2026

I have updated the PR, addressing some of the issues pointed out by the automatic reviews.
Would appreciate it if someone could take a look at it, thanks.

…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.
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq bot commented Mar 16, 2026

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing Ladol:issue3680 (b4078b8) with main (f43d967)

Open in CodSpeed

@Ladol
Copy link
Copy Markdown
Author

Ladol commented Mar 23, 2026

@sourcery-ai review
@greptileai

) -> None:
"""Run the execution result through active schema extensions."""
for ext in extensions:
extension_instance = ext() if isinstance(ext, type) else ext
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 ext() call raises TypeError for extensions with required constructor args

process_extensions calls ext() with no arguments when the extension is a class. However, schema.get_extensions() (the authoritative path) calls ext(execution_context=None) to account for extensions like ApolloTracingExtension that declare execution_context as a required positional parameter:

class ApolloTracingExtension(SchemaExtension):
    def __init__(self, execution_context: ExecutionContext) -> None:
        ...

Calling ext() on such an extension raises TypeError: __init__() missing 1 required positional argument: 'execution_context', crashing the subscription handler entirely. This is a regression: before this PR, subscriptions simply bypassed extensions; now they can hard-crash for any user with ApolloTracingExtension (or a custom extension with a required constructor arg) in their schema.

The fix is to align with schema.get_extensions() and pass execution_context=None:

Suggested change
extension_instance = ext() if isinstance(ext, type) else ext
extension_instance = ext(execution_context=None) if isinstance(ext, type) else ext

@Ladol
Copy link
Copy Markdown
Author

Ladol commented Mar 23, 2026

@greptileai

Comment on lines +10 to +16
for ext in extensions:
extension_instance = (
ext(execution_context=None) if isinstance(ext, type) else ext
)

if hasattr(extension_instance, "_process_result"):
extension_instance._process_result(execution_result)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Class-type extensions with custom __init__ will crash at runtime

When an extension is provided as a class (rather than an instance), the code calls ext(execution_context=None). This silently fails for any extension that overrides __init__ without an execution_context parameter. A concrete example is MaskErrors itself:

class MaskErrors(SchemaExtension):
    def __init__(self, should_mask_error=..., error_message=...) -> None: ...

Calling MaskErrors(execution_context=None) raises:

TypeError: __init__() got an unexpected keyword argument 'execution_context'

The end-to-end test avoids this by always passing a pre-constructed instance (extensions=[MaskErrors(...)]), so the bug path (isinstance(ext, type) → True) is never exercised by the current tests. Any user who passes extensions=[MaskErrors] (i.e. the class, without parentheses) will get an unhandled exception on the first subscription event.

The proper fix is to skip class-type extensions entirely here and only process instance-type extensions — or, better, reuse the already-instantiated extensions from the SchemaExtensionsRunner for the current request.

@Ladol
Copy link
Copy Markdown
Author

Ladol commented Mar 23, 2026

@greptileai

Comment on lines +10 to +22
for ext in extensions:
if isinstance(ext, type):
try:
# Try passing the context for extensions like ApolloTracing
extension_instance = ext(execution_context=None)
except TypeError:
# Fallback for extensions like MaskErrors that don't want it
extension_instance = ext()

# Explicitly set this ONLY for newly constructed instances
extension_instance.execution_context = None
else:
extension_instance = ext
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Instance-based extensions are double-processed on the last subscription event

When an extension is stored as an instance (the else branch, e.g. extensions=[MaskErrors(error_message="Custom")]), process_extensions reuses the same object that SchemaExtensionsRunner also holds, because schema.get_extensions() returns instances as-is.

Tracing the subscription lifecycle in schema.py:

async with extensions_runner.operation():      # ← wraps the whole subscription
    async for result in aiter_or_result:
        yield await self._handle_execution_result(...)   # sets context.result = result

extensions_runner.operation() does not exit between yield calls — it only exits after the generator is exhausted. At that point, each extension's on_operation post-yield runs. For MaskErrors that means:

def on_operation(self):
    yield
    result = self.execution_context.result   # ← the last result
    self._process_result(result)             # ← 2nd call on the last result

The sequence for the final subscription event is therefore:

  1. send_next / send_data_messageprocess_extensions_process_result(last_result) (first call)
  2. Subscription ends → operation() exits → on_operation post-yield → _process_result(context.result) (second call on the same object)

For MaskErrors the double-call is idempotent (re-masking an already-masked error is a no-op). But any extension that has observable side-effects in _process_result — writing to a log, incrementing a counter, appending audit data — will fire twice for the final event and once for every preceding event, producing an inconsistent per-event record.

The safest fix is to call _process_result directly through the existing extensions_runner (which already has properly initialised instances) rather than re-reading schema.extensions and constructing separate instances.

Comment on lines +11 to +17
if isinstance(ext, type):
try:
# Try passing the context for extensions like ApolloTracing
extension_instance = ext(execution_context=None)
except TypeError:
# Fallback for extensions like MaskErrors that don't want it
extension_instance = ext()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Broad except TypeError can swallow unrelated constructor failures

The outer try/except TypeError is intended only to detect extensions whose __init__ does not accept an execution_context keyword argument. However, it also silently catches any TypeError raised inside the constructor body (e.g. a type mismatch in dependency injection logic, a wrong internal call, etc.).

When that happens the code falls back to ext(), which may also raise — or worse, may succeed but silently produce a misconfigured instance. The real constructor error is never surfaced.

A tighter guard would inspect the signature before calling, rather than catching the exception after:

import inspect

sig = inspect.signature(ext.__init__)
if "execution_context" in sig.parameters:
    extension_instance = ext(execution_context=None)
else:
    extension_instance = ext()

This keeps the intent explicit and lets any other TypeError propagate normally.

@Ladol
Copy link
Copy Markdown
Author

Ladol commented Mar 24, 2026

@greptileai

Comment on lines +12 to +21
if isinstance(ext, type):
# Inspect the constructor to see if it requires execution_context
sig = inspect.signature(ext.__init__)
if "execution_context" in sig.parameters:
extension_instance = ext(execution_context=None)
else:
extension_instance = ext()

# Explicitly set this ONLY for newly constructed instances
extension_instance.execution_context = None
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Extension instantiated on every subscription event

For class-based extensions, inspect.signature(ext.__init__) is called and a fresh instance is constructed on every invocation of process_extensions — i.e., for every individual subscription result. This means:

  1. inspect.signature introspection runs on each subscription event, which is non-trivial for high-frequency streams.
  2. Any class-based extension that accumulates mutable state (e.g., a counter, a timer, a deduplication set) will silently lose that state between events because a fresh object is handed each event.

The resolved extension instances (with proper execution_context) are already available inside schema._subscribe via the extensions_runner. A more robust and efficient fix would be to call _process_result on those already-configured instances rather than re-resolving and re-constructing from schema.extensions on every event.

If the current approach is kept, consider at minimum caching the per-schema extension list once (not per event) to avoid repeated signature inspection:

# Computed once per subscription start, not once per event
extension_instances = _build_extension_instances(schema.extensions)

Comment on lines +397 to +399
async def send_next(self, execution_result: ExecutionResult) -> None:
extensions = getattr(self.handler.schema, "extensions", [])
process_extensions(execution_result, extensions)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 schema.extensions does not reflect the per-request extension runner

getattr(self.handler.schema, "extensions", []) returns the raw list that was originally passed to strawberry.Schema(extensions=[...]). However, schema.subscribe() builds its extensions_runner via schema._async_extensions, which:

  1. Instantiates class-based extensions fresh and sets execution_context on them.
  2. Also adds DirectivesExtension when schema.directives is non-empty — an extension that is completely absent from schema.extensions.

Because process_extensions bypasses the per-request runner, any extension added only inside get_extensions() (like DirectivesExtension) will never have its _process_result called, and class-based extensions receive a freshly constructed instance rather than the one already wired to the current ExecutionContext.

The structurally correct fix is to thread the SchemaExtensionsRunner that _subscribe already has through to send_next, so that _process_result is called on the same, already-configured instances that the rest of the operation lifecycle uses.

@Ladol
Copy link
Copy Markdown
Author

Ladol commented Mar 24, 2026

@greptileai

Comment on lines +13 to +14
else:
extension_instance = ext()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Class-based extensions with constructor args silently drop configuration

When a class-based extension is instantiated here via ext() (the branch where execution_context is absent from its signature), any constructor parameters that the extension normally accepts are silently omitted.

A concrete case: suppose a user registers a custom extension as a class with a default-valued argument that controls behaviour:

class RateLimitExtension(SchemaExtension):
    def __init__(self, max_errors: int = 10) -> None:
        self.max_errors = max_errors

build_operation_extensions will call RateLimitExtension() and get an instance configured with max_errors=10, regardless of what the original entry in schema.extensions was. More critically, schema.extensions stores the class itself — not a configured instance — so there is no way to recover the user-intended configuration from the class alone.

The safest fix for the general case is to avoid re-instantiation entirely: run schema.get_extensions() once per operation (as the normal query/mutation path does) so that Strawberry's own instantiation logic is reused, and then call _process_result on those instances instead of creating a separate set. If per-operation instantiation must stay, a clarifying note that only zero-argument (or execution_context-only) class-based extensions are supported would prevent silent misconfiguration.

Comment on lines +18 to +19
else:
instances.append(ext)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Instance-based extensions get _process_result called from two places

When an extension is registered as a pre-constructed instance (e.g. extensions=[MaskErrors(error_message="Custom")]), build_operation_extensions returns the same object that schema._async_extensions also returns. That same object is passed to SchemaExtensionsRunner inside schema.subscribe(), whose operation() context manager's post-yield code calls _process_result again (via MaskErrors.on_operation) once the subscription generator is exhausted.

The result:

  • For every subscription event except the last, only the new code path calls _process_result — correct.
  • For the last event, _process_result is called once here (before the result is sent — correct) and then a second time by on_operation's post-yield after the result has already been transmitted — harmless for MaskErrors (idempotent masking), but incorrect for any extension that performs a non-idempotent transformation or side-effect (e.g. incrementing a counter, writing to a log with the original message).

Because MaskErrors is stateless this passes today's tests, but it creates a latent correctness trap for custom extensions. The cleanest resolution is to reuse the SchemaExtensionsRunner that schema.subscribe() already holds, so there is a single, authoritative call site for _process_result.

…erations

Extensions are now built once per operation and stored on the operation
itself.
@Ladol
Copy link
Copy Markdown
Author

Ladol commented Mar 24, 2026

After working through the bot's feedback, I've updated the code to set up extensions for each individual WebSocket operation.

The bot pointed out two problems with this PR:

  1. It drops custom constructor arguments for class-based extensions.
  2. It processes the final message twice when the subscription closes.

I believe fixing these problems is beyond the scope of this PR, as it would require changes to the core schema.subscribe generator.

Instead of hardcoding a special rule just for MaskErrors (as the framework shouldn't care about what extensions exist), I added a comment in utils.py documenting these limitations.

@Ladol
Copy link
Copy Markdown
Author

Ladol commented Mar 26, 2026

As pointed out in my previous comment, this issue occurs for all extensions for subscriptions, and its not particular to mask errors.
As such, I've opened a new issue (#4329), and created the respective PR (#4330) where i proposed a fix.

@Ladol
Copy link
Copy Markdown
Author

Ladol commented Mar 30, 2026

Subsumed by #4330

@Ladol Ladol closed this Mar 30, 2026
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