Skip to content

fix: keep order of error extension fields after sanitization#1407

Merged
Noroth merged 4 commits intomasterfrom
ludwig/eng-8996-flaky-tests-on-json-error-response
Feb 25, 2026
Merged

fix: keep order of error extension fields after sanitization#1407
Noroth merged 4 commits intomasterfrom
ludwig/eng-8996-flaky-tests-on-json-error-response

Conversation

@Noroth
Copy link
Copy Markdown
Contributor

@Noroth Noroth commented Feb 24, 2026

When sanitizing the fields in the error extensions, we did not keep the order. This made the response rather non-deterministic. This PR makes sure that the order of fields are retained.

Summary by CodeRabbit

  • Improvements

    • Improved GraphQL error JSON handling and marshaling for more reliable, order-preserving error payloads
    • Made error-extension filtering order-agnostic so only allowed fields propagate from subgraph errors
    • Exposed additional downstream error context (reason, response code, and downstream errors) for richer responses
  • Tests

    • Added tests validating allowed extension field propagation in error flows
    • Added test helper to run post-evaluation assertions with configurable resolver options

Checklist

  • I have discussed my proposed changes in an issue and have received approval to proceed.
  • I have followed the coding standards of the project.
  • Tests or benchmarks have been added or updated.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 24, 2026

📝 Walkthrough

Walkthrough

GraphQLError.Extensions was changed from map[string]any to *astjson.Value with custom JSON marshal/unmarshal. SubgraphError gained Reason, ResponseCode, and DownstreamErrors []*GraphQLError. Extension filtering now iterates astjson objects to copy only allowed keys. Tests and a test helper were updated to use the new API.

Changes

Cohort / File(s) Summary
Core error extension refactor
v2/pkg/engine/resolve/errors.go
Changed GraphQLError.Extensions from map[string]any*astjson.Value; added UnmarshalJSON and MarshalJSON; added SubgraphError fields Reason, ResponseCode, DownstreamErrors []*GraphQLError; updated code-paths to read extensions via Extensions.Get(...).
Extension filtering logic
v2/pkg/engine/resolve/loader.go
Replaced per-key map lookups with iteration over extensions.GetObject().Visit(...), reconstructing a new extensions object containing only allowed keys (order-agnostic). Minor string conversion adjustments for key checks.
Tests — loader hooks
v2/pkg/engine/resolve/loader_hooks_test.go
Updated assertions to check for nil via Extensions.Get("code"); added a test scenario verifying only allowed extension fields propagate to downstream errors (duplicated in file).
Tests — resolver helpers
v2/pkg/engine/resolve/resolve_test.go
Added testFnWithPostEvaluationAndOptions helper to run tests with custom ResolverOptions and a post-evaluation callback.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: replacing order-agnostic field filtering with order-preserving sanitization of error extension fields.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch ludwig/eng-8996-flaky-tests-on-json-error-response

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
v2/pkg/engine/resolve/errors.go (1)

85-97: ⚠️ Potential issue | 🟠 Major

Use StringBytes() instead of String() to extract unquoted error codes.

The astjson.Value.String() method returns the JSON representation of the value. For a JSON string like "UNAUTHORIZED", it returns the Go string "\"UNAUTHORIZED\"" (16 chars with surrounding double-quotes included). The Codes() method currently appends these quoted values to the slice, so downstream code comparing codes against plain strings will fail.

Call StringBytes() on the code value instead:

if code := downstreamError.Extensions.Get("code"); code != nil {
    codeStr := string(code.StringBytes())
    if !slices.Contains(codes, codeStr) {
        codes = append(codes, codeStr)
    }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v2/pkg/engine/resolve/errors.go` around lines 85 - 97, In
SubgraphError.Codes(), the code value is being appended using
astjson.Value.String() which yields a quoted JSON representation; update the
extraction to use code.StringBytes(), convert to string (e.g.,
string(code.StringBytes())), and use that unquoted codeStr when checking
slices.Contains and appending to the codes slice so comparisons against plain
strings succeed (references: SubgraphError.Codes, DownstreamErrors,
downstreamError.Extensions.Get("code"), slices.Contains).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@v2/pkg/engine/resolve/errors.go`:
- Around line 40-42: Replace the panic-prone astjson.MustParseBytes call used to
populate e.Extensions from aux.Extensions with the error-returning
astjson.ParseBytes: call astjson.ParseBytes(aux.Extensions), handle and return
any parse error, and if the parsed *astjson.Value is of JSON null set
e.Extensions = nil (to preserve prior nil-map semantics and omitempty behavior);
update the code around e.Extensions and aux.Extensions to propagate the parse
error instead of panicking.
- Around line 47-61: GraphQLError.MarshalJSON currently embeds *GraphQLError in
the anonymous aux struct which promotes the MarshalJSON method and causes
infinite recursion; change the aux to embed a non-method-carrying alias (e.g.
define type Alias GraphQLError and use *Alias in aux) so json.Marshal reflects
fields instead of calling back into GraphQLError.MarshalJSON, and copy e into
the alias before marshalling; preserve the existing Extensions handling (calling
e.Extensions.MarshalTo(nil) into aux.Extensions) but assign aux.GraphQLError =
(*Alias)(&e) to break the recursion.

In `@v2/pkg/engine/resolve/loader.go`:
- Line 685: Remove the stray debug print in loader.go that prints mergeErrors to
stdout; delete the fmt.Println("mergeErrors", string(value.String())) call and,
if logging is required, replace it with the project’s logger (e.g.,
processLogger or zap) using value.String() directly (do not wrap it in
string(...)). Ensure no raw fmt.Println remains and that any replacement uses
the existing logger API for appropriate log level and message context.

---

Outside diff comments:
In `@v2/pkg/engine/resolve/errors.go`:
- Around line 85-97: In SubgraphError.Codes(), the code value is being appended
using astjson.Value.String() which yields a quoted JSON representation; update
the extraction to use code.StringBytes(), convert to string (e.g.,
string(code.StringBytes())), and use that unquoted codeStr when checking
slices.Contains and appending to the codes slice so comparisons against plain
strings succeed (references: SubgraphError.Codes, DownstreamErrors,
downstreamError.Extensions.Get("code"), slices.Contains).

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d82a593 and a48f464.

📒 Files selected for processing (4)
  • v2/pkg/engine/resolve/errors.go
  • v2/pkg/engine/resolve/loader.go
  • v2/pkg/engine/resolve/loader_hooks_test.go
  • v2/pkg/engine/resolve/resolve_test.go

Comment thread v2/pkg/engine/resolve/errors.go
Comment thread v2/pkg/engine/resolve/errors.go
Comment thread v2/pkg/engine/resolve/loader.go Outdated
@ysmolski ysmolski self-assigned this Feb 25, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
v2/pkg/engine/resolve/errors.go (2)

57-72: MarshalJSON infinite-recursion bug is correctly fixed.

The type Alias GraphQLError declaration ensures Alias does not carry the MarshalJSON method, so json.Marshal(aux) reflects over fields rather than recursively calling MarshalJSON. This resolves the critical issue flagged in the previous review.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v2/pkg/engine/resolve/errors.go` around lines 57 - 72, The previous
infinite-recursion in GraphQLError.MarshalJSON is fixed by defining "type Alias
GraphQLError" and embedding "*Alias" in the aux struct so json.Marshal(aux)
reflects fields rather than calling MarshalJSON again; keep the current
implementation in MarshalJSON (use Alias: (*Alias)(&e), set aux.Extensions =
e.Extensions.MarshalTo(nil) when e.Extensions != nil) and ensure the symbols
GraphQLError, MarshalJSON, Alias, aux, Extensions, and MarshalTo remain as shown
to prevent recursion.

43-50: ⚠️ Potential issue | 🟡 Minor

JSON null extensions still produces a non-nil *astjson.Value.

"extensions": null passes the len(aux.Extensions) > 0 guard (4 bytes) and astjson.ParseBytes returns a non-nil TypeNull value, leaving e.Extensions != nil. This diverges from the previous nil-map behaviour: the omitempty tag on the struct field won't suppress it during marshalling, and callers that nil-check Extensions before calling .Get(...) will behave differently.

The prior review proposed guarding against this:

🛡️ Proposed fix
 	if len(aux.Extensions) > 0 {
 		extensions, err := astjson.ParseBytes(aux.Extensions)
 		if err != nil {
 			return err
 		}
-		e.Extensions = extensions
+		if extensions.Type() != astjson.TypeNull {
+			e.Extensions = extensions
+		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v2/pkg/engine/resolve/errors.go` around lines 43 - 50, The code currently
parses aux.Extensions and assigns e.Extensions even when the JSON is literal
null (astjson returns a non-nil TypeNull), causing e.Extensions to be non-nil;
fix by, after calling astjson.ParseBytes(aux.Extensions), check the parsed
value's type and only assign e.Extensions when the parsed value is not the JSON
null sentinel (e.g., parsed.Type() != astjson.TypeNull or !parsed.IsNull());
leave e.Extensions nil for JSON null so omitempty and nil-checks behave as
before.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@v2/pkg/engine/resolve/errors.go`:
- Around line 100-102: In Codes(), guard against a nil Extensions pointer on
downstreamError before calling Get: check that downstreamError.Extensions != nil
(or similar) prior to calling downstreamError.Extensions.Get("code"); only call
Get and append code.String() when Extensions is non-nil and the returned value
is non-nil to avoid the nil-pointer panic in Codes().

---

Duplicate comments:
In `@v2/pkg/engine/resolve/errors.go`:
- Around line 57-72: The previous infinite-recursion in GraphQLError.MarshalJSON
is fixed by defining "type Alias GraphQLError" and embedding "*Alias" in the aux
struct so json.Marshal(aux) reflects fields rather than calling MarshalJSON
again; keep the current implementation in MarshalJSON (use Alias: (*Alias)(&e),
set aux.Extensions = e.Extensions.MarshalTo(nil) when e.Extensions != nil) and
ensure the symbols GraphQLError, MarshalJSON, Alias, aux, Extensions, and
MarshalTo remain as shown to prevent recursion.
- Around line 43-50: The code currently parses aux.Extensions and assigns
e.Extensions even when the JSON is literal null (astjson returns a non-nil
TypeNull), causing e.Extensions to be non-nil; fix by, after calling
astjson.ParseBytes(aux.Extensions), check the parsed value's type and only
assign e.Extensions when the parsed value is not the JSON null sentinel (e.g.,
parsed.Type() != astjson.TypeNull or !parsed.IsNull()); leave e.Extensions nil
for JSON null so omitempty and nil-checks behave as before.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a48f464 and 4d7345e.

📒 Files selected for processing (2)
  • v2/pkg/engine/resolve/errors.go
  • v2/pkg/engine/resolve/loader.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • v2/pkg/engine/resolve/loader.go

Comment thread v2/pkg/engine/resolve/errors.go Outdated
Copy link
Copy Markdown
Contributor

@ysmolski ysmolski left a comment

Choose a reason for hiding this comment

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

LGTM

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
v2/pkg/engine/resolve/errors.go (1)

43-50: ⚠️ Potential issue | 🟡 Minor

Treat extensions: null as absent to preserve previous behavior.

On Line 43, non-empty extensions values are always parsed, so JSON null becomes a non-nil *astjson.Value. That changes prior nil-map semantics and can force "extensions": null instead of omitting the field.

💡 Proposed fix
 	if len(aux.Extensions) > 0 {
-		extensions, err := astjson.ParseBytes(aux.Extensions)
-		if err != nil {
-			return err
-		}
-
-		e.Extensions = extensions
+		raw := bytes.TrimSpace(aux.Extensions)
+		if bytes.Equal(raw, []byte("null")) {
+			e.Extensions = nil
+		} else {
+			extensions, err := astjson.ParseBytes(raw)
+			if err != nil {
+				return err
+			}
+			e.Extensions = extensions
+		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v2/pkg/engine/resolve/errors.go` around lines 43 - 50, The code currently
parses aux.Extensions unconditionally when its length>0 which converts JSON null
into a non-nil astjson.Value and changes semantics; update the conditional
around aux.Extensions in errors.go to detect and treat JSON "null" as absent
before calling astjson.ParseBytes (e.g., check aux.Extensions equals the bytes
for "null" or parse and verify the resulting value is not a JSON null) and only
assign to e.Extensions when a non-null parsed value is present, keeping
references to aux.Extensions, astjson.ParseBytes, and e.Extensions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@v2/pkg/engine/resolve/errors.go`:
- Around line 43-50: The code currently parses aux.Extensions unconditionally
when its length>0 which converts JSON null into a non-nil astjson.Value and
changes semantics; update the conditional around aux.Extensions in errors.go to
detect and treat JSON "null" as absent before calling astjson.ParseBytes (e.g.,
check aux.Extensions equals the bytes for "null" or parse and verify the
resulting value is not a JSON null) and only assign to e.Extensions when a
non-null parsed value is present, keeping references to aux.Extensions,
astjson.ParseBytes, and e.Extensions.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4d7345e and 0abe0dc.

📒 Files selected for processing (1)
  • v2/pkg/engine/resolve/errors.go

@Noroth Noroth merged commit e974cd3 into master Feb 25, 2026
9 checks passed
@Noroth Noroth deleted the ludwig/eng-8996-flaky-tests-on-json-error-response branch February 25, 2026 12:06
Noroth pushed a commit that referenced this pull request Feb 25, 2026
🤖 I have created a release *beep* *boop*
---


##
[2.0.0-rc.257](v2.0.0-rc.256...v2.0.0-rc.257)
(2026-02-25)


### Bug Fixes

* keep order of error extension fields after sanitization
([#1407](#1407))
([e974cd3](e974cd3))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Fixed an issue where the order of error extension fields could be lost
during sanitization in v2.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
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.

3 participants