Skip to content

feat: add mask_pre_execution_errors option to MaskErrors - #4577

Open
alimony wants to merge 3 commits into
strawberry-graphql:mainfrom
alimony:mask-pre-execution-errors
Open

feat: add mask_pre_execution_errors option to MaskErrors#4577
alimony wants to merge 3 commits into
strawberry-graphql:mainfrom
alimony:mask-pre-execution-errors

Conversation

@alimony

@alimony alimony commented Aug 10, 2026

Copy link
Copy Markdown

Description

The original report – that MaskErrors does not mask validation errors – was already fixed by #3968 and #4330. MaskErrors masks parse errors and validation errors in every execution path today.

What is still open is the option that @erikwrede and @patrick91 asked for in that thread. There are two separate concerns: hide what the resolvers raise, and hide the shape of the schema. You cannot ask for the first one alone, so a client that sends a malformed document only gets Unexpected error. and cannot correct its query.

This PR adds mask_pre_execution_errors to the MaskErrors constructor. It defaults to True, so the behaviour does not change for anybody.

schema = strawberry.Schema(
    Query,
    extensions=[lambda: MaskErrors(mask_pre_execution_errors=False)],
)

# "Cannot query field 'helloo' on type 'Query'. Did you mean 'hello'?"
schema.execute_sync("{ helloo }")

# "Unexpected error."
schema.execute_sync("{ hiddenError }")

How it works

The extension masks the errors of execution_context.result and of each streamed frame. At that point it cannot tell which phase produced them. It now tracks the phase with the existing lifecycle hooks: on_parse sets a private flag and on_execute clears it. Every execution path enters parsing() before executing(), so an operation that reaches on_execute did not fail to parse or to validate. on_operation and on_stream_result both consult the flag, which keeps the sync, async, streaming, subscription and incremental paths consistent.

Errors that occur before the parse step, a missing query for example, stay masked. Schema.process_errors still receives the original errors, so logs do not change.

Validation

  • uv run pytest – 5122 passed. The single failure, tests/typecheckers/test_pydantic.py::test_pydantic_type, also fails on a clean checkout here, because a local binary is missing.
  • uv run mypy --config-file mypy.ini – clean, 240 source files.
  • uv run ruff check . and uv run ruff format --check . – clean.
  • The 8 new test cases fail before the change and pass after it.
  • I then broke the new logic seven ways – dropped each hook, dropped each guard, and forced the phase check to a constant – and confirmed that the tests catch every one.
  • Manual checks of the sync, async and streaming paths, and of the example in the documentation.
  • RELEASE.md included, release type minor.

Types of Changes

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

Issues Fixed or Closed by This PR

Checklist

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • 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

Add a configurable option to the MaskErrors extension to control whether syntax and validation errors are masked before execution while keeping resolver-raised errors masked by default.

New Features:

  • Introduce the mask_pre_execution_errors option on the MaskErrors extension to let clients see pre-execution syntax and validation errors when desired.

Enhancements:

  • Track execution phase within MaskErrors so masking behavior can differ between pre-execution and execution errors without affecting logging.
  • Extend MaskErrors to handle phase-aware masking for streaming operations and multiple operations sharing the same extension instance.

Documentation:

  • Document the new mask_pre_execution_errors option in the MaskErrors extension guide, including examples and guidance on schema visibility implications.

Tests:

  • Add sync, async, and streaming tests verifying mask_pre_execution_errors behavior for parse, validation, and resolver errors, and phase handling across multiple operations.

Chores:

  • Add a minor release note describing the new mask_pre_execution_errors option and its default behavior.

MaskErrors masks every error, including the syntax errors of a document and the validation errors against the schema. Clients that send an invalid query only get the generic message, so they cannot correct it.

Add a mask_pre_execution_errors option. It defaults to True, which keeps the current behaviour. Set it to False to send parse errors and validation errors to the client while the errors that resolvers raise stay masked.

The extension cannot tell which phase produced an error, so it now tracks the phase with the on_parse and on_execute hooks. The parse step and the validation step always run before execution in every path, so an operation that reaches on_execute did not fail in those steps. This behaves the same for sync, async, streaming and incremental execution.

Closes strawberry-graphql#3326
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Thanks for adding the RELEASE.md file!

Below is the changelog that will be used for the release.


This release adds a mask_pre_execution_errors option to the MaskErrors extension.

MaskErrors masks every error. This includes the syntax errors of a document and the validation errors against the schema, so a client that sends an invalid query gets only the generic message. Set the new option to False to send these errors to the client. The errors that your resolvers raise stay masked:

import strawberry
from strawberry.extensions import MaskErrors


@strawberry.type
class Query:
    @strawberry.field
    def hello(self) -> str:
        return "world"

    @strawberry.field
    def hidden_error(self) -> str:
        raise KeyError("This error will not be visible")


schema = strawberry.Schema(
    Query,
    extensions=[
        lambda: MaskErrors(mask_pre_execution_errors=False),
    ],
)

# "Cannot query field 'helloo' on type 'Query'. Did you mean 'hello'?"
schema.execute_sync("{ helloo }")

# "Unexpected error."
schema.execute_sync("{ hiddenError }")

The default value is True, which keeps the current behaviour. Validation errors give the names of the fields, the arguments and the types of your schema. Thus disable the option only if the clients can know the shape of the schema.

This release also fixes an error leak in MaskErrors. The extension kept some of its state on the instance. When one instance served more than one operation at the same time – which the deprecated extensions=[MaskErrors()] form does – a frame of an open stream could stop the extension from masking the errors of another operation, and the message of the original exception reached the client. MaskErrors now keeps this state per operation.

This release was contributed by @alimony in #4577

@sourcery-ai sourcery-ai Bot left a comment

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.

Hey - I've left some high level feedback:

  • The _in_pre_execution_phase flag is stored on the extension instance and toggled by lifecycle hooks; if an extension instance can be reused concurrently across operations (e.g., in async or threaded environments), consider making this phase-tracking state operation-scoped rather than instance-scoped to avoid race conditions and mis-masking in concurrent executions.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `_in_pre_execution_phase` flag is stored on the extension instance and toggled by lifecycle hooks; if an extension instance can be reused concurrently across operations (e.g., in async or threaded environments), consider making this phase-tracking state operation-scoped rather than instance-scoped to avoid race conditions and mis-masking in concurrent executions.

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

greptile-apps Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds an opt-out for masking parse and validation errors while continuing to mask resolver failures. It introduces lifecycle-based phase tracking, documents the new constructor option, and adds sync, async, and streaming coverage.

  • Adds mask_pre_execution_errors=True to MaskErrors.
  • Uses parse and execute hooks to classify errors before result processing.
  • Documents the behavior and its schema-disclosure implications.
  • Adds tests for parse, validation, resolver, streaming, and sequential shared-instance behavior.

Confidence Score: 3/5

The PR is not safe to merge until phase tracking is isolated per operation so concurrent requests cannot expose resolver errors.

The new masking decision depends on mutable extension-instance state, and the repository permits factories to return a shared instance across concurrent operations, allowing one request's parse phase to disable masking for another request's resolver failure.

Files Needing Attention: strawberry/extensions/mask_errors.py and tests/schema/extensions/test_stream_result.py

Security Review

A shared MaskErrors instance is unsafe under concurrent operations because its new phase flag is operation-specific mutable state. One operation entering parsing can cause another operation's resolver error to bypass masking. How this was verified: The shared-instance factory path was traced through the lifecycle hooks to the result-processing guard that reads the overwritten phase flag.

Important Files Changed

Filename Overview
strawberry/extensions/mask_errors.py Adds phase-sensitive masking, but stores per-operation phase on an instance that can be shared concurrently, allowing resolver errors to bypass masking.
tests/schema/extensions/test_mask_errors.py Adds focused sync and async coverage for exposing parse and validation errors while masking resolver failures.
tests/schema/extensions/test_stream_result.py Covers streaming and sequential shared-instance reuse but omits concurrent reuse, where the new phase state races.
docs/extensions/mask-errors.md Clearly documents the option, default behavior, and schema-disclosure tradeoff.
RELEASE.md Provides release metadata and an example of the new public option.

Sequence Diagram

sequenceDiagram
    participant A as Operation A
    participant M as Shared MaskErrors
    participant B as Operation B
    A->>M: on_execute()
    M->>M: "phase = execution"
    A->>A: Resolver raises sensitive error
    B->>M: on_parse()
    M->>M: "phase = pre-execution"
    A->>M: on_operation / on_stream_result
    M-->>A: Skip masking due to B's phase
Loading

Reviews (1): Last reviewed commit: "fix extension passing format in example" | Re-trigger Greptile

Comment thread strawberry/extensions/mask_errors.py Outdated
@alimony

alimony commented Aug 10, 2026

Copy link
Copy Markdown
Author

I will resolve the comments from Sourcery/Greptile.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MaskErrors does not mask validation errors

1 participant