Skip to content

feat: Handle GraphQL error responses#3668

Open
Dexter2099 wants to merge 3 commits into
meltano:mainfrom
Dexter2099:codex/handle-graphql-errors
Open

feat: Handle GraphQL error responses#3668
Dexter2099 wants to merge 3 commits into
meltano:mainfrom
Dexter2099:codex/handle-graphql-errors

Conversation

@Dexter2099

@Dexter2099 Dexter2099 commented Jun 5, 2026

Copy link
Copy Markdown

Closes #1421

Summary

  • detect GraphQL response payloads containing a non-empty errors field during response validation
  • raise FatalAPIError with the GraphQL error message instead of treating the response as successful
  • add regression coverage for GraphQL error payloads returned with HTTP 200

Testing

  • uv run pytest tests/core/test_streams.py::test_graphql_validate_response_raises_for_errors -q
  • uv run pytest tests/core/test_streams.py -q
  • nox -rs tests-3.11 -- tests/core/test_streams.py
  • pre-commit run --files singer_sdk/streams/graphql.py tests/core/test_streams.py

Summary by Sourcery

Handle GraphQL error payloads returned with HTTP 200 by validating the response body and surfacing errors as fatal API failures.

Bug Fixes:

  • Treat GraphQL responses containing a non-empty errors field as failures and raise a FatalAPIError instead of treating them as successful responses.

Tests:

  • Add regression tests covering GraphQL error payloads with HTTP 200, non-JSON and non-dict bodies, empty or missing errors fields, and alternate error structures.

@sourcery-ai

sourcery-ai Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Extend GraphQLStream response validation to treat GraphQL error payloads (non-empty errors field) as fatal API errors and add regression tests covering both error and non-error payload shapes.

File-Level Changes

Change Details Files
Validate GraphQL responses for non-empty errors field and raise FatalAPIError with aggregated messages.
  • Override validate_response in GraphQLStream to call RESTStream.validate_response first, then inspect the JSON payload for an errors key.
  • Parse response JSON defensively, returning early for non-JSON, non-dict payloads, or when errors is missing or empty.
  • Normalize errors into a single human-readable error_message, handling lists of dicts/strings and non-list errors, and raise FatalAPIError with a prefixed message.
singer_sdk/streams/graphql.py
Add regression tests for GraphQLStream.validate_response covering error and non-error payloads.
  • Add test to assert that GraphQL responses with a standard GraphQL errors list cause validate_response to raise FatalAPIError with the first error message in the match string.
  • Add parameterized test cases ensuring validate_response ignores payloads that are not JSON, not dicts, missing errors, or have empty errors arrays.
  • Add parameterized test cases ensuring validate_response raises FatalAPIError for alternate error payload shapes such as list items without message fields and non-list errors values.
tests/core/test_streams.py

Assessment against linked issues

Issue Objective Addressed Explanation
#1421 Extend GraphQLStream.validate_response to detect GraphQL error payloads (non-empty errors field in the JSON body) and treat them as failures rather than successful responses.
#1421 Raise an appropriate SDK exception (e.g., FatalAPIError) that surfaces the GraphQL error messages when such errors are present.
#1421 Add tests to cover GraphQL error handling behavior, including error payloads with HTTP 200 and payloads without errors that should still be treated as successful.

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

@Dexter2099 Dexter2099 marked this pull request as ready for review June 5, 2026 05:06
@codecov

codecov Bot commented Jun 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.15%. Comparing base (4a84df1) to head (133d7e4).
⚠️ Report is 7 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3668      +/-   ##
==========================================
+ Coverage   94.13%   94.15%   +0.01%     
==========================================
  Files          73       73              
  Lines        6203     6224      +21     
  Branches      763      766       +3     
==========================================
+ Hits         5839     5860      +21     
  Misses        270      270              
  Partials       94       94              
Flag Coverage Δ
core 83.01% <100.00%> (+0.05%) ⬆️
end-to-end 75.88% <38.09%> (-0.13%) ⬇️
optional-components 44.71% <14.28%> (-0.11%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@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 validate_response signature references requests.Response but requests is only imported under TYPE_CHECKING, which will trigger a NameError at runtime on Python versions that evaluate annotations; either move the import out of the TYPE_CHECKING block, enable from __future__ import annotations, or quote the type annotation.
  • In validate_response, JSON decode failures and non-dict payloads are silently ignored; consider at least logging or surfacing this scenario if it might indicate a misbehaving GraphQL API rather than a valid non-JSON response.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `validate_response` signature references `requests.Response` but `requests` is only imported under `TYPE_CHECKING`, which will trigger a `NameError` at runtime on Python versions that evaluate annotations; either move the import out of the `TYPE_CHECKING` block, enable `from __future__ import annotations`, or quote the type annotation.
- In `validate_response`, JSON decode failures and non-dict payloads are silently ignored; consider at least logging or surfacing this scenario if it might indicate a misbehaving GraphQL API rather than a valid non-JSON response.

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.

@codspeed-hq

codspeed-hq Bot commented Jun 5, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 8 untouched benchmarks


Comparing Dexter2099:codex/handle-graphql-errors (133d7e4) with main (4a84df1)

Open in CodSpeed

@Dexter2099 Dexter2099 marked this pull request as draft June 5, 2026 05:09
@read-the-docs-community

read-the-docs-community Bot commented Jun 5, 2026

Copy link
Copy Markdown

@Dexter2099 Dexter2099 force-pushed the codex/handle-graphql-errors branch from 014116c to 85cdf6b Compare June 5, 2026 05:11
@Dexter2099 Dexter2099 marked this pull request as ready for review June 5, 2026 05:14

@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 found 1 issue, and left some high level feedback:

  • In GraphQLStream.validate_response, the response: requests.Response type hint will raise a NameError at runtime because requests is only imported under TYPE_CHECKING; either move the import out of the guard, quote the annotation, or enable from __future__ import annotations to avoid this.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `GraphQLStream.validate_response`, the `response: requests.Response` type hint will raise a `NameError` at runtime because `requests` is only imported under `TYPE_CHECKING`; either move the import out of the guard, quote the annotation, or enable `from __future__ import annotations` to avoid this.

## Individual Comments

### Comment 1
<location path="tests/core/test_streams.py" line_range="397-420" />
<code_context>
     assert list(records) == [{"id": 1, "value": "abc"}, {"id": 2, "value": "def"}]


+def test_graphql_validate_response_raises_for_errors(tap: Tap):
+    """Validate GraphQL error payloads are handled as response errors."""
+    fake_response = requests.Response()
+    fake_response.status_code = 200
+    fake_response.reason = "OK"
+    fake_response.url = GraphqlTestStream.url_base
+    fake_response._content = json.dumps(
+        {
+            "errors": [
+                {
+                    "message": 'Cannot query field "nonexistentField" on type "Query".',
+                },
+            ],
+            "data": None,
+        },
+    ).encode()
+
+    stream = GraphqlTestStream(tap)
+
+    with pytest.raises(
+        FatalAPIError,
+        match='GraphQL API error: Cannot query field "nonexistentField"',
+    ):
+        stream.validate_response(fake_response)
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** Add coverage for multiple GraphQL errors to verify message aggregation logic

This test only exercises a single-entry `errors` list. Since `validate_response` joins multiple error messages with `'; '.join(...)`, please add a case with multiple errors and assert that the `FatalAPIError` message includes all of them, in order, to lock in the aggregation behavior and catch regressions.

```suggestion
def test_graphql_validate_response_raises_for_errors(tap: Tap):
    """Validate GraphQL error payloads are handled as response errors."""
    fake_response = requests.Response()
    fake_response.status_code = 200
    fake_response.reason = "OK"
    fake_response.url = GraphqlTestStream.url_base
    fake_response._content = json.dumps(
        {
            "errors": [
                {
                    "message": 'Cannot query field "nonexistentField" on type "Query".',
                },
            ],
            "data": None,
        },
    ).encode()

    stream = GraphqlTestStream(tap)

    with pytest.raises(
        FatalAPIError,
        match='GraphQL API error: Cannot query field "nonexistentField"',
    ):
        stream.validate_response(fake_response)


def test_graphql_validate_response_raises_for_multiple_errors(tap: Tap):
    """Validate that multiple GraphQL errors are aggregated into a single message."""
    fake_response = requests.Response()
    fake_response.status_code = 200
    fake_response.reason = "OK"
    fake_response.url = GraphqlTestStream.url_base
    fake_response._content = json.dumps(
        {
            "errors": [
                {"message": "First error."},
                {"message": "Second error."},
            ],
            "data": None,
        },
    ).encode()

    stream = GraphqlTestStream(tap)

    # Expect the error messages to be joined with '; ' in order.
    with pytest.raises(
        FatalAPIError,
        match=r"GraphQL API error: First error\.; Second error\.",
    ):
        stream.validate_response(fake_response)
```
</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 +397 to +420
def test_graphql_validate_response_raises_for_errors(tap: Tap):
"""Validate GraphQL error payloads are handled as response errors."""
fake_response = requests.Response()
fake_response.status_code = 200
fake_response.reason = "OK"
fake_response.url = GraphqlTestStream.url_base
fake_response._content = json.dumps(
{
"errors": [
{
"message": 'Cannot query field "nonexistentField" on type "Query".',
},
],
"data": None,
},
).encode()

stream = GraphqlTestStream(tap)

with pytest.raises(
FatalAPIError,
match='GraphQL API error: Cannot query field "nonexistentField"',
):
stream.validate_response(fake_response)

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.

suggestion (testing): Add coverage for multiple GraphQL errors to verify message aggregation logic

This test only exercises a single-entry errors list. Since validate_response joins multiple error messages with '; '.join(...), please add a case with multiple errors and assert that the FatalAPIError message includes all of them, in order, to lock in the aggregation behavior and catch regressions.

Suggested change
def test_graphql_validate_response_raises_for_errors(tap: Tap):
"""Validate GraphQL error payloads are handled as response errors."""
fake_response = requests.Response()
fake_response.status_code = 200
fake_response.reason = "OK"
fake_response.url = GraphqlTestStream.url_base
fake_response._content = json.dumps(
{
"errors": [
{
"message": 'Cannot query field "nonexistentField" on type "Query".',
},
],
"data": None,
},
).encode()
stream = GraphqlTestStream(tap)
with pytest.raises(
FatalAPIError,
match='GraphQL API error: Cannot query field "nonexistentField"',
):
stream.validate_response(fake_response)
def test_graphql_validate_response_raises_for_errors(tap: Tap):
"""Validate GraphQL error payloads are handled as response errors."""
fake_response = requests.Response()
fake_response.status_code = 200
fake_response.reason = "OK"
fake_response.url = GraphqlTestStream.url_base
fake_response._content = json.dumps(
{
"errors": [
{
"message": 'Cannot query field "nonexistentField" on type "Query".',
},
],
"data": None,
},
).encode()
stream = GraphqlTestStream(tap)
with pytest.raises(
FatalAPIError,
match='GraphQL API error: Cannot query field "nonexistentField"',
):
stream.validate_response(fake_response)
def test_graphql_validate_response_raises_for_multiple_errors(tap: Tap):
"""Validate that multiple GraphQL errors are aggregated into a single message."""
fake_response = requests.Response()
fake_response.status_code = 200
fake_response.reason = "OK"
fake_response.url = GraphqlTestStream.url_base
fake_response._content = json.dumps(
{
"errors": [
{"message": "First error."},
{"message": "Second error."},
],
"data": None,
},
).encode()
stream = GraphqlTestStream(tap)
# Expect the error messages to be joined with '; ' in order.
with pytest.raises(
FatalAPIError,
match=r"GraphQL API error: First error\.; Second error\.",
):
stream.validate_response(fake_response)

@Dexter2099 Dexter2099 force-pushed the codex/handle-graphql-errors branch from 85cdf6b to ed7e22e Compare June 5, 2026 05:25
@edgarrmondragon edgarrmondragon changed the title fix: handle GraphQL error responses feat: Handle GraphQL error responses Jun 10, 2026
Comment thread singer_sdk/streams/graphql.py Outdated
Comment on lines +46 to +49
try:
data = response.json()
except ValueError:
return

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Returning here would mean processing of the response continues as if it was valid. Should we raise FatalAPIError(...) from ... here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I replaced the malformed-response silent returns with FatalAPIError; the JSON decode path now uses raise ... from e so the original parsing failure is preserved.

I kept valid GraphQL payloads without errors (missing-errors / empty-errors) as non-fatal, and moved only malformed bodies into the raises test.

Poudel-Sanskriti pushed a commit to Poudel-Sanskriti/meltano-sdk that referenced this pull request Jul 3, 2026
Align validate_response with review feedback on meltano#3668:

- Catch ValueError from response.json() and raise FatalAPIError so
  non-JSON bodies fail loudly instead of propagating a decode error.
- Raise FatalAPIError when the body is JSON but not an object, instead
  of silently treating the response as valid.
- Stringify non-dict error entries and non-list errors values instead
  of silently dropping them.
- Cover all new paths with tests, including multi-error aggregation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Poudel-Sanskriti pushed a commit to Poudel-Sanskriti/meltano-sdk that referenced this pull request Jul 3, 2026
Align validate_response with review feedback on meltano#3668:

- Catch ValueError from response.json() and raise FatalAPIError so
  non-JSON bodies fail loudly instead of propagating a decode error.
- Raise FatalAPIError when the body is JSON but not an object, instead
  of silently treating the response as valid.
- Stringify non-dict error entries and non-list errors values instead
  of silently dropping them.
- Cover all new paths with tests, including multi-error aggregation.
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.

[Feature]: Handle GraphQL errors

2 participants