Skip to content

fix: propagate ResponseWriter through module prevent double write#2605

Merged
StarpTech merged 8 commits intomainfrom
dustin/eng-9117-router-module-middleware-wrapper-discards-responsewriter
Mar 7, 2026
Merged

fix: propagate ResponseWriter through module prevent double write#2605
StarpTech merged 8 commits intomainfrom
dustin/eng-9117-router-module-middleware-wrapper-discards-responsewriter

Conversation

@StarpTech
Copy link
Copy Markdown
Contributor

@StarpTech StarpTech commented Mar 6, 2026

Summary by CodeRabbit

  • Bug Fixes

    • Unified forbidden (403) handling from downstream services: requests short-circuit, responses are normalized to a single standardized forbidden payload, preventing partial or duplicate errors and ensuring consistent streaming/buffering behavior.
  • New Features

    • Added configurable OTEL test error handler and improved exporter error logging for telemetry tests.
  • Refactor

    • Ensured correct response-writer propagation through middleware/on-request chains.
  • Tests

    • Expanded tests for forbidden workflows, middleware writer propagation, error-location filtering, and telemetry behavior.

Checklist

@github-actions github-actions Bot added the router label Mar 6, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 6, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a ForbiddenHandlerModule with origin- and response-level forbidden detection/rewrites, expands tests for forbidden behavior and response-writer propagation, and ensures router middleware/on-request handlers expose the active response writer to modules.

Changes

Cohort / File(s) Summary
Forbidden Handler Implementation
router-tests/modules/custom-forbidden-handler/module.go
Adds OnOriginRequest short-circuiting, enhances OnOriginResponse to detect 403 via HTTP status or GraphQL extension fields (numeric/string), normalizes forbidden responses to a shared forbiddenErrorBody and status 200 for routing, adds isForbiddenCode helper, streaming/buffering adjustments, and registers EnginePreOriginHandler implementation.
Forbidden Handler Tests
router-tests/modules/forbidden_handler_test.go
Refactors test scaffolding into helpers, centralizes expected forbidden body, and adds numerous subtests covering HTTP 403 and extension-based 403 detection, single/multi-subgraph short-circuiting, duplicate-error prevention, extension filtering, and atomic counters to verify short-circuit behavior.
Middleware writer propagation (core + tests)
router/core/router.go, router-tests/modules/middleware_writer_propagation_test.go
Propagates the effective response writer into module contexts (reqContext.responseWriter = writer) for both middleware and on-request handler paths; adds tests and test modules (buffering, passthrough, transforming, capturingWriter) to validate writer propagation, buffering, headers, status, and body capture across multi-module chains.
Error handling tests
router-tests/error_handling_test.go
Adds subtests ensuring OmitLocations strips locations while preserving extensions across passthrough and wrapped error modes, including multi-error scenarios (tests only).
Telemetry / tracing tests & testenv
router-tests/telemetry/attribute_processor_test.go, router-tests/testenv/testenv.go
Removes eventual polling from a telemetry test to make it synchronous; adds rtrace import and configures Otel test error handler in test router setup (TestErrorHandler = rtrace.NewOtelErrorHandler).
Trace package changes
router/pkg/trace/config.go, router/pkg/trace/errors.go, router/pkg/trace/meter.go
Adds Config.TestErrorHandler func(error); introduces NewOtelErrorHandler and an errorLoggingExporter wrapper to invoke a local handler on exporter errors; adjusts exporter registration to wrap MemoryExporter with the error-logging wrapper when test handler is set and conditions for global error handler registration.
Other tests
router-tests/... (various)
Minor test adjustments and additions related to the above changes (imports, synchronous assertions, new test functions).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ 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: propagating ResponseWriter through module middleware to prevent double-write errors, which is reflected in router/core/router.go and related test additions.

✏️ 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)

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

@StarpTech StarpTech changed the title fix: propagate ResponseWriter through module middleware chain to prev… fix: propagate ResponseWriter through module prevent double write Mar 6, 2026
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 6, 2026

Router-nonroot image scan passed

✅ No security vulnerabilities found in image:

ghcr.io/wundergraph/cosmo/router:sha-67ebb531de6a4b0e851c4c6aac093eed5cfe88e1-nonroot

@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 6, 2026

Codecov Report

❌ Patch coverage is 95.65217% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 62.69%. Comparing base (d5b3b45) to head (24ebf0a).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
router/pkg/trace/errors.go 92.30% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2605      +/-   ##
==========================================
- Coverage   64.25%   62.69%   -1.56%     
==========================================
  Files         301      244      -57     
  Lines       42656    25776   -16880     
  Branches     4558        0    -4558     
==========================================
- Hits        27409    16161   -11248     
+ Misses      15226     8251    -6975     
- Partials       21     1364    +1343     
Files with missing lines Coverage Δ
router/core/router.go 69.92% <100.00%> (ø)
router/pkg/trace/config.go 73.33% <ø> (ø)
router/pkg/trace/meter.go 45.16% <100.00%> (ø)
router/pkg/trace/errors.go 87.50% <92.30%> (ø)

... and 541 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@StarpTech StarpTech marked this pull request as ready for review March 6, 2026 20:58
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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@router/cmd/custom/module/module.go`:
- Around line 63-75: The origin hooks still run for streaming requests and thus
can buffer/consume streams; update ForbiddenHandlerModule.OnOriginRequest and
ForbiddenHandlerModule.OnOriginResponse to early-return (skip
creating/replacing/reading responses) when the request is a streaming request by
checking the same context flag used in Middleware (e.g.,
ctx.GetBool("streaming") or the project’s streaming flag set in Middleware) so
streaming flows are not consumed — mirror the Middleware streaming opt-out logic
to short-circuit both OnOriginRequest and OnOriginResponse.
- Around line 161-178: The middleware captures the original writer w but
replaces it with bw before calling next.ServeHTTP, causing downstream type
assertions against http.Flusher, http.Hijacker, http.Pusher, and
http.NewResponseController to fail; fix by having bufferedWriter hold the
captured real writer (set bw.real = w before calling next.ServeHTTP) and
implement the optional interfaces (Flush, Hijack, Push, NewResponseController)
on bufferedWriter to delegate to bw.real when available, or if delegation isn't
possible restore ctx.ResponseWriter() back to the original w after
next.ServeHTTP; update the bufferedWriter type and its methods and ensure
ForbiddenHandlerModule.Middleware sets the real writer on bw prior to
next.ServeHTTP so downstream code (which does w.(http.Flusher) etc.) continues
to work.
- Around line 201-205: The isStreamingRequest function currently checks the raw
Accept header case-sensitively; normalize the header value by calling
strings.ToLower on r.Header.Get("Accept") (and optionally strings.TrimSpace)
before matching so media types like "Text/Event-Stream" or "Multipart/Mixed" are
detected; update the checks in isStreamingRequest to search the lowercased
accept string for "text/event-stream" and "multipart/mixed".

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7b0ba98e-1c26-4380-9c37-173f1febadd9

📥 Commits

Reviewing files that changed from the base of the PR and between 0000765 and 7d59471.

📒 Files selected for processing (2)
  • router-tests/modules/middleware_writer_propagation_test.go
  • router/cmd/custom/module/module.go

Comment thread router/cmd/custom/module/module.go Outdated
Comment thread router/cmd/custom/module/module.go Outdated
Comment thread router/cmd/custom/module/module.go Outdated
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: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@router-tests/modules/custom-forbidden-handler/module.go`:
- Around line 183-187: The handler currently copies all headers from bw.header
via maps.Copy then only removes Content-Length before writing
forbiddenErrorBody, which can leave payload-specific headers (e.g.,
Content-Encoding, ETag, Trailer, Transfer-Encoding, Content-Range) that no
longer match the response; change the logic in this branch (around maps.Copy,
w.Header(), bw.header and where forbiddenErrorBody is written) to either remove
those payload-specific headers explicitly after copying or, safer, build the
response headers by whitelisting only desired headers (e.g., Cache-Control,
Vary, custom tracing headers) and set those on w.Header() before writing
http.StatusOK and forbiddenErrorBody so no stale payload headers are forwarded.
- Around line 159-163: The streaming_request flag is set in the handler but
never inspected by the origin hooks, so update OnOriginRequest and
OnOriginResponse to check the request context for "streaming_request" (the same
key set via ctx.Set("streaming_request", true)) and, when present/true, skip the
forbidden-handling logic (short-circuit and body-rewrite paths) so streaming
requests are left untouched; locate these checks inside the OnOriginRequest and
OnOriginResponse handlers and gate the existing forbidden handling branches with
a guard that returns/continues when the flag is set.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cb021c54-d923-43ac-90dc-1438a60de883

📥 Commits

Reviewing files that changed from the base of the PR and between 7d59471 and bbc3523.

📒 Files selected for processing (1)
  • router-tests/modules/custom-forbidden-handler/module.go

Comment thread router-tests/modules/custom-forbidden-handler/module.go
Comment thread router-tests/modules/custom-forbidden-handler/module.go
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.

🧹 Nitpick comments (1)
router-tests/modules/custom-forbidden-handler/module.go (1)

39-41: Consider protecting forbiddenErrorBody from accidental mutation.

Package-level var with a byte slice can be modified at runtime. Since this is effectively a constant value, consider documenting the immutability expectation or converting usage to return a fresh copy when writing.

💡 Alternative: use a function to return a fresh slice
-var forbiddenErrorBody = []byte(`{"errors":[{"message":"Insufficient permissions to fulfill the request.","extensions":{"errorCode":"FORBIDDEN"}}],"data":null}`)
+var forbiddenErrorBody = []byte(`{"errors":[{"message":"Insufficient permissions to fulfill the request.","extensions":{"errorCode":"FORBIDDEN"}}],"data":null}`)
+
+// getForbiddenErrorBody returns a copy of the standardized error body.
+// Use this if there's any risk of downstream mutation.
+func getForbiddenErrorBody() []byte {
+	return append([]byte(nil), forbiddenErrorBody...)
+}

Since Go's Write methods don't typically mutate the input slice, this is low-risk, but worth noting for defensive coding.

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

In `@router-tests/modules/custom-forbidden-handler/module.go` around lines 39 -
41, forbiddenErrorBody is declared as a package-level var holding a byte slice
that can be mutated; change usage to ensure immutability by replacing the
mutable var with a provider that returns a fresh slice (e.g., a function like
ForbiddenErrorBody() that returns a new []byte copy) or by documenting
immutability and always writing a copy before use; update all call sites that
pass forbiddenErrorBody to io.Writer methods (refer to forbiddenErrorBody and
any writers in module.go) to use the provider/copy so the original bytes cannot
be changed at runtime.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@router-tests/modules/custom-forbidden-handler/module.go`:
- Around line 39-41: forbiddenErrorBody is declared as a package-level var
holding a byte slice that can be mutated; change usage to ensure immutability by
replacing the mutable var with a provider that returns a fresh slice (e.g., a
function like ForbiddenErrorBody() that returns a new []byte copy) or by
documenting immutability and always writing a copy before use; update all call
sites that pass forbiddenErrorBody to io.Writer methods (refer to
forbiddenErrorBody and any writers in module.go) to use the provider/copy so the
original bytes cannot be changed at runtime.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 77ddc365-1e88-42e1-85c2-47878413ce13

📥 Commits

Reviewing files that changed from the base of the PR and between bbc3523 and c70d8ed.

📒 Files selected for processing (1)
  • router-tests/modules/custom-forbidden-handler/module.go

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

🧹 Nitpick comments (1)
router/pkg/trace/meter.go (1)

171-179: TestErrorHandler is ignored unless MemoryExporter is set.

The only read of Config.TestErrorHandler sits inside the config.MemoryExporter != nil branch. If a caller sets the new hook with the normal OTLP exporters, this code still falls through to otel.SetErrorHandler(errHandler(config)) below, so the new field becomes a silent no-op. Either wire it in both branches or narrow the field contract/name to make that limitation explicit.

Also applies to: 225-227

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

In `@router/pkg/trace/meter.go` around lines 171 - 179, The TestErrorHandler is
currently only used when MemoryExporter is non-nil (in the MemoryExporter branch
around MemoryExporter and errorLoggingExporter) which silently makes that hook a
no-op for other exporters; update the logic so the TestErrorHandler is honored
unconditionally: either wrap whatever exporter is being used with
errorLoggingExporter when config.Config.TestErrorHandler != nil (not just inside
the MemoryExporter branch) or, alternately, only call
otel.SetErrorHandler(errHandler(config)) when config.Config.TestErrorHandler is
set; in short, move the TestErrorHandler check out of the
MemoryExporter-specific branch and apply it to the general exporter
creation/wrapping code paths (references: MemoryExporter, errorLoggingExporter,
errHandler(config), and otel.SetErrorHandler).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@router/pkg/trace/errors.go`:
- Around line 56-61: The ExportSpans method on errorLoggingExporter currently
calls e.handler(err) but still returns the error which causes the SDK (e.g.,
when used with sdktrace.WithSyncer / SimpleSpanProcessor) to call otel.Handle
and double-report; modify errorLoggingExporter.ExportSpans so that after calling
e.handler(err) it returns nil (only return the actual error when you want global
handling), ensuring local handling prevents otel.Handle from being invoked
twice.

---

Nitpick comments:
In `@router/pkg/trace/meter.go`:
- Around line 171-179: The TestErrorHandler is currently only used when
MemoryExporter is non-nil (in the MemoryExporter branch around MemoryExporter
and errorLoggingExporter) which silently makes that hook a no-op for other
exporters; update the logic so the TestErrorHandler is honored unconditionally:
either wrap whatever exporter is being used with errorLoggingExporter when
config.Config.TestErrorHandler != nil (not just inside the MemoryExporter
branch) or, alternately, only call otel.SetErrorHandler(errHandler(config)) when
config.Config.TestErrorHandler is set; in short, move the TestErrorHandler check
out of the MemoryExporter-specific branch and apply it to the general exporter
creation/wrapping code paths (references: MemoryExporter, errorLoggingExporter,
errHandler(config), and otel.SetErrorHandler).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 33e1a3c1-5a1e-4b91-972d-6a9ac3f7d1a1

📥 Commits

Reviewing files that changed from the base of the PR and between c70d8ed and 24ebf0a.

📒 Files selected for processing (6)
  • router-tests/error_handling_test.go
  • router-tests/telemetry/attribute_processor_test.go
  • router-tests/testenv/testenv.go
  • router/pkg/trace/config.go
  • router/pkg/trace/errors.go
  • router/pkg/trace/meter.go

Comment thread router/pkg/trace/errors.go
@StarpTech StarpTech merged commit 391bffe into main Mar 7, 2026
42 of 43 checks passed
@StarpTech StarpTech deleted the dustin/eng-9117-router-module-middleware-wrapper-discards-responsewriter branch March 7, 2026 00:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants