Skip to content

fix(server): recover from panics in RPC and UI HTTP handlers - #1877

Open
whoAbhishekSah wants to merge 4 commits into
mainfrom
fix/http-handler-panic-recovery
Open

fix(server): recover from panics in RPC and UI HTTP handlers#1877
whoAbhishekSah wants to merge 4 commits into
mainfrom
fix/http-handler-panic-recovery

Conversation

@whoAbhishekSah

Copy link
Copy Markdown
Member

Problem

Frontier has no panic recovery in the request path. When a handler panics, the only net is Go's built-in per-connection recover in net/http:

  • The client's connection is dropped with no response at all (HTTP/1.1: empty reply; HTTP/2: INTERNAL_ERROR stream reset), instead of an RPC error.
  • The stack trace goes through net/http's error logger. With slog set as the default logger, that lands as a single INFO-level line with the whole stack stuffed into msg — invisible to anything watching for error-level logs, and with no request context.

Reproduced by planting a deliberate panic() in a handler:

$ curl -v .../FrontierService/ListAuthStrategies ...
* Empty reply from server            # HTTP/1.1
* HTTP/2 stream 1 was not closed cleanly: INTERNAL_ERROR (err 2)

Fix

  • Pass connect.WithRecover(...) when building the Frontier and Admin service handlers. The recover callback logs the procedure, panic value, and stack through slog at error level, and returns CodeInternal with a generic message. It is placed before the interceptor chain so it sits outermost and also catches panics thrown from interceptors, not just handlers.
  • Wrap the UI server mux (/configs, /frontier-connect/ proxy, SPA routes) with an equivalent recovery handler that returns a plain 500, since WithRecover only covers connect handlers. It re-panics on http.ErrAbortHandler, matching net/http's own contract.
  • No panic details reach the caller in either path.

Out of scope: panics on internal background goroutines (audit log listener, shutdown watchers) — those are not HTTP calls and need their own recover treatment separately.

After the fix

Same deliberately panicking handler:

$ curl -s -w "\nHTTP %{http_code}\n" .../FrontierService/ListAuthStrategies ...
{"code":"internal","message":"internal server error"}
HTTP 500

and one structured log entry:

{"level":"ERROR","msg":"rpc handler panic","procedure":"/raystack.frontier.v1beta1.FrontierService/ListAuthStrategies","panic":"...","stack":"goroutine ..."}

Tests

  • A panicking connect handler returns HTTP 500 with "code":"internal", no panic detail in the body, and an error-level log entry carrying the procedure and stack.
  • The UI recovery wrapper returns a generic 500 on panic, passes normal requests through untouched, and re-panics on http.ErrAbortHandler.
  • Verified live against a running server over both HTTP/1.1 and HTTP/2 (output above).

🤖 Generated with Claude Code

A panicking handler previously fell through to net/http's per-connection
recover: the client's connection was dropped with no response (HTTP/2
stream reset), and the stack trace bypassed structured logging.

Add connect.WithRecover on the Frontier and Admin service handlers so a
panic anywhere in the handler chain returns CodeInternal with a generic
message, and wrap the UI server mux with an equivalent recovery handler
that returns a plain 500. Both log the panic value and stack through
slog at error level; no panic details reach the caller.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
frontier Ready Ready Preview Aug 13, 2026 10:21am

@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes
    • Prevented unexpected server crashes by recovering from panics in UI, RPC, and other HTTP request handlers.
    • Returned safe internal-error responses without exposing sensitive panic details.
    • Preserved standard behavior for normal requests and intentional connection-abort handling.
    • Added diagnostic logging with relevant request context to support troubleshooting while protecting sensitive information.

Walkthrough

The server adds centralized panic recovery for Connect RPC and UI HTTP handlers. Recovery logs panic context, hides panic details from clients, returns controlled errors, and preserves http.ErrAbortHandler. Integration tests cover these behaviors.

Changes

Panic recovery

Layer / File(s) Summary
Recovery wrappers and response handling
pkg/server/recovery.go, pkg/server/recovery_test.go
Connect RPC panics return generic internal errors. UI panics return HTTP 500 responses when no response is committed. Committed responses preserve http.ErrAbortHandler. Tests cover logging, redaction, passthrough, response commitment, flushing, unwrapping, and ReadFrom.
Server handler integration
pkg/server/server.go
The UI mux and Connect HTTP mux use httpPanicRecovery. Frontier and Admin ConnectRPC handlers use connectPanicRecovery as their outermost interceptor.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Mergeability Score: 🟡 Moderate · up to 83897

The PR adds panic recovery for RPC and UI requests, but a UI panic after response data or status has already been sent can still leave clients with a successful status and partial response instead of a controlled 500; this response-integrity issue should be fixed or explicitly accepted before merge.

Suggested reviewers: amangit07

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

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

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: bda1a8bf-e75d-4b74-815a-5f79464ef6d5

📥 Commits

Reviewing files that changed from the base of the PR and between fafc986 and f5b2cae.

📒 Files selected for processing (3)
  • pkg/server/recovery.go
  • pkg/server/recovery_test.go
  • pkg/server/server.go

Comment thread pkg/server/recovery.go Outdated
…covery

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coveralls

coveralls commented Aug 13, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 31690670686

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage increased (+0.1%) to 48.313%

Details

  • Coverage increased (+0.1%) from the base build.
  • Patch coverage: 7 uncovered changes across 2 files (45 of 52 lines covered, 86.54%).
  • 126 coverage regressions across 4 files.

Uncovered Changes

File Changed Covered %
pkg/server/recovery.go 46 40 86.96%
pkg/server/server.go 6 5 83.33%

Coverage Regressions

126 previously-covered lines in 4 files lost coverage.

File Lines Losing Coverage Coverage
internal/store/postgres/organization_repository.go 66 79.25%
internal/store/spicedb/relation_repository.go 31 0.0%
pkg/db/db.go 26 24.39%
config/config.go 3 83.33%

Coverage Stats

Coverage Status
Relevant Lines: 39809
Covered Lines: 19233
Line Coverage: 48.31%
Coverage Strength: 15.42 hits per line

💛 - Coveralls

…body

When a UI handler panics after writing part of a response, the status
line is already on the wire: http.Error cannot change it and would only
append the error text to the partial body, so the client would read a
corrupt 200. Track response commitment; once committed, log the panic
and re-panic with http.ErrAbortHandler so net/http drops the connection
and the client sees a truncated response instead of a fake success. The
wrapper forwards Flush for the reverse proxy's streaming.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread pkg/server/recovery.go

// Flush commits the response too. The connect reverse proxy on the UI mux
// needs the Flusher interface for streaming.
func (c *committedWriter) Flush() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

committedWriter only re-exposes Flush. It does not implement http.Hijacker and has no Unwrap method, so http.ResponseController on this writer cannot reach the underlying connection.

The UI mux hands committedWriter to the /frontier-connect/ ReverseProxy. If a request ever needs a protocol upgrade, ReverseProxy calls http.NewResponseController(rw).Hijack(). Since committedWriter is not a Hijacker and has no Unwrap, that returns ErrNotSupported and the proxy fails with can't switch protocols using non-Hijacker ResponseWriter type. The same missing Unwrap also blocks SetReadDeadline, SetWriteDeadline, and EnableFullDuplex, and turns FlushError into the error-less Flush, so flush and write errors on a broken client are dropped without notice.

One method fixes all of it, because http.ResponseController walks Unwrap to find Hijacker, the deadline setters, and FlushError:

func (c *committedWriter) Unwrap() http.ResponseWriter { return c.ResponseWriter }

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.

Agree on Unwrap — worth adding. One correction: Unwrap restores Hijack, the deadline setters, and EnableFullDuplex, but not FlushError. ResponseController.Flush checks FlushError, then Flusher, then unwraps; committedWriter implements Flush, so the lookup stops at the wrapper and returns nil either way. Getting flush errors back needs a FlushError method on the wrapper itself. The proxy discards flush errors anyway, so Unwrap alone seems enough.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added Unwrap in 838970f. ResponseController can now reach the underlying writer for Hijack, the deadline setters, and full duplex. Left FlushError alone per the thread below — the proxy discards flush errors anyway.

Comment thread pkg/server/recovery.go
c.ResponseWriter.WriteHeader(statusCode)
}

func (c *committedWriter) Write(b []byte) (int, error) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

committedWriter does not forward io.ReaderFrom, so the SPA static file server behind the UI mux loses its sendfile fast path.

Before this change the SPA handler got the raw *http.response, which implements io.ReaderFrom, so ServeContent used sendfile for static assets. Now it gets committedWriter, which has no ReadFrom, so every admin UI asset is copied through a 32KB userspace buffer loop instead. Small but real cost on the busiest UI path.

Unwrap does not fix this one. io.Copy checks the destination for io.ReaderFrom directly and does not walk Unwrap, so it needs its own method:

func (c *committedWriter) ReadFrom(r io.Reader) (int64, error) {
	c.committed = true
	if rf, ok := c.ResponseWriter.(io.ReaderFrom); ok {
		return rf.ReadFrom(r)
	}
	return io.Copy(c.ResponseWriter, r)
}

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.

The sendfile premise doesn't hold here. The admin SPA is served from embed.FS (web/apps/admin/embed.go, go:embed all:dist), and sendfile only applies when the copy source is a real OS file. Embedded assets were already copied through a userspace buffer before this change. The only delta is io.Copy allocating its 32KB buffer instead of using net/http's pooled one. The ReadFrom passthrough is still fine to add, but it's tidiness, not a lost fast path.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added ReadFrom in 838970f. As noted below, the admin assets come from embed.FS so there was no sendfile path to lose, but the passthrough keeps the underlying writer's copy path and it costs one small method. It also marks the response as committed, since bytes reached the wire. Added a test that io.Copy into the wrapper dispatches to it.

Comment thread pkg/server/server.go Outdated
Address review feedback on the recovery wrapper and its coverage:

- Add Unwrap on committedWriter so http.ResponseController can reach
  the underlying writer's Hijacker, deadline, and full-duplex methods;
  without it a protocol upgrade through the connect reverse proxy on
  the UI server would fail.
- Add ReadFrom passthrough so copies into the wrapper keep the
  underlying writer's optimized path, which io.Copy looks up on the
  destination directly without walking Unwrap.
- Wrap the connect server mux with the same recovery handler. The
  webhook bridge does its own parsing before the protected handler,
  and ping, health, reflection, and CORS had no recovery at all. RPC
  panics are still converted to connect error codes by WithRecover
  first; the outer wrapper only sees panics that escape it.
- Rename uiPanicRecovery to httpPanicRecovery since it now fronts both
  servers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 03e7e87a-e04d-437e-84ce-32e176594906

📥 Commits

Reviewing files that changed from the base of the PR and between eecc659 and 838970f.

📒 Files selected for processing (3)
  • pkg/server/recovery.go
  • pkg/server/recovery_test.go
  • pkg/server/server.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • pkg/server/recovery.go

rec := httptest.NewRecorder()
cw := &committedWriter{ResponseWriter: rec}

n, err := io.Copy(cw, strings.NewReader("data"))

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the declared Go version and the test dispatch path.
if [[ -f go.mod ]]; then
  rg -n '^go [0-9.]+' go.mod
fi

sed -n '173,187p' pkg/server/recovery_test.go
rg -n -A12 -B2 'func \(c \*committedWriter\) ReadFrom' pkg/server/recovery.go

Repository: raystack/frontier

Length of output: 1370


Exercise committedWriter.ReadFrom directly.

io.Copy selects strings.Reader.WriteTo before committedWriter.ReadFrom. The test can therefore pass without executing ReadFrom. Replace io.Copy(cw, strings.NewReader("data")) with cw.ReadFrom(strings.NewReader("data")).

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.

4 participants