Skip to content

feat: add AcceptOptions.ErrorHandler - #588

Draft
mafredri wants to merge 12 commits into
masterfrom
mafredri/accept-error-handler
Draft

mafredri wants to merge 12 commits into
masterfrom
mafredri/accept-error-handler

Conversation

@mafredri

@mafredri mafredri commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Accept writes a plain text http.Error response when it rejects a handshake request. A server that needs a structured error body, such as RFC 9457 JSON (#545), cannot replace that response. A second write after Accept returns only appends to the plain text body: the status and text/plain content type stay Accept's, and net/http logs a superfluous WriteHeader call.

AcceptOptions.ErrorHandler func(w http.ResponseWriter, r *http.Request, status int, err error) writes that response instead. It receives the status Accept chose and the error Accept returns, before wrapping. w.Header() already holds the headers Accept set, such as Sec-WebSocket-Version: 13 for an unsupported version. It is called for every rejection before the 101 response: request validation, origin, and a ResponseWriter without http.Hijacker. It is not called when hijacking fails, because Accept has already written 101 Switching Protocols. A handler that writes nothing leaves net/http's default 200 OK with an empty body.

A nil ErrorHandler keeps the previous response. For an invalid OriginPatterns entry, Accept now returns the pattern error instead of "Forbidden", and the handler receives it. The client still gets 403 Forbidden. The Accept doc now says not to write to w after Accept returns an error.

Tests cover the handler for all 13 rejections. A separate test pins the default status, headers and body for all 13, and those match master.

Fixes #545

Decision log
  • Handler field instead of a typed error plus an opt-out flag: with the flag, callers must write after a rejection but not after a hijack failure, and a wrong branch gives 200 OK or a double write.
  • The handler receives r, so one handler shared across requests can use it, for example r.Context(). Allow customize how HTTP errors are rendered when websocket.Accept fails to upgrade #545 discussed capturing the request in a closure instead, which does not work for shared options.
  • Documentation alone was rejected because callers still could not change the body (Allow customize how HTTP errors are rendered when websocket.Accept fails to upgrade #545, Support disabling error writing via AcceptOptions #152).
  • No exported per-reason errors; status and error text are enough to render a response, and sentinels can be added later.
  • For an invalid OriginPatterns entry and without http.Hijacker, the handler receives the descriptive error while the nil default body stays "Forbidden" or "Not Implemented". The ErrorHandler doc says err may contain server details.
  • No recover around the handler: a panic behaves like any other handler panic in net/http.
  • The hijack failure after the 101 is unchanged; whether to hijack before writing 101 is a separate decision.
  • The field is also added to the js/wasm AcceptOptions stub so shared code compiles.

🤖 This PR was created with the help of Coder Agents.

@mafredri
mafredri force-pushed the mafredri/accept-error-handler branch from ff05d69 to f154711 Compare October 30, 2026 15:18
Accept's rejection responses had no test that checked the status,
headers and body together. Pinning them for every rejection before the
101 lets a change to how the response is written prove that the default
output stays the same.
Accept always wrote a plain text http.Error response when it rejected a
handshake, so servers with a structured error format had to write a
second response, which net/http appends to Accept's body and logs as a
superfluous WriteHeader call. ErrorHandler lets the caller write that
response instead, with the same signature as gorilla's Upgrader.Error;
nil keeps the previous output.

Refs #545
@mafredri
mafredri force-pushed the mafredri/accept-error-handler branch from f154711 to 0eca8fe Compare November 3, 2026 19:14
@mafredri

This comment has been minimized.

@coder-agents-review

coder-agents-review Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Chat: Review posted | View chat
Requested: 2026-11-03 20:58 UTC by @mafredri
Spend: $15.06 / $100.00

Review history
  • R1 (2026-11-03): 12 reviewers, 2 Nit, 5 P3, 3 P4, COMMENT. Review
  • R2 (2026-11-03): 12 reviewers, 3 Nit, 5 P3, 6 P4, COMMENT. Review

deep-review v0.13.0 | Round 2 | e1a21e5..56eccc2

Last posted: Round 2, 14 findings (5 P3, 6 P4, 3 Nit), COMMENT. Review

Finding inventory

Finding inventory: coder/websocket PR #588

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P3 Author fixed (5728d8e) accept.go:88 No test asserts ErrorHandler is not called when Hijack fails R1 Netero Yes
CRF-2 Note Note (body) accept_test.go:211 newHandshakeRequest duplicates inline handshake setup in TestAccept (inline copies predate PR) R1 Netero No
CRF-3 P3 Author fixed (273a0ac) accept_test.go:388 server subtest leaks net/http transport goroutines; TestMain leak check fails filtered runs R1 Bisky, Pariston Yes
CRF-4 P3 Author fixed (d01f39d) accept.go:144 ErrorHandler doc does not define whether err is client-safe; call sites differ R1 Meruem P3, Leorio P3, Pariston Note Yes
CRF-5 P4 Author fixed (56eccc2) accept.go:136 Bad OriginPatterns error masked as "Forbidden" before ErrorHandler runs R1 Hisoka P4, Chopper P4, Ryosuke Note Yes
CRF-6 P3 Author fixed (5cf85d0) accept_test.go:220 Rejection table claims exhaustive but omits two authenticateOrigin rejections; description says 11, there are 13 R1 Mafu-san P2, Hisoka Note, Chopper Nit Yes
CRF-7 P3 Author fixed (38ae558) accept_test.go:334 No test asserts ErrorHandler receives the request passed to Accept R1 Bisky Yes
CRF-8 P4 Author fixed (0f9f7d7) accept_test.go:411 want map duplicates acceptRejectionTests data keyed by name R1 Bisky P4, Meruem P4 Yes
CRF-9 Note Dropped by orchestrator (contradicted by Leorio, who found the "must write" sentence useful; remaining cuts are a few words of a correct doc) accept.go:83 ErrorHandler doc wording could be shorter R1 Gon P2 No
CRF-10 Nit Author fixed (06b6aac) accept.go:197 Rename body to defaultBody and delete writeRejection doc comment R1 Gon P2 Yes
CRF-11 P4 Open accept_test.go:319 badOriginPattern comment describes only the default path R1 Gon P2 (R1, re-raised R2) Yes
CRF-12 Nit Author fixed (273a0ac, b10f4ad) accept_test.go:355 server subtest comment restates its table cases R1 Gon P2 Yes
CRF-13 Note Dropped by orchestrator (comment states the test pins existing behavior, which the name does not) accept_test.go:401 Delete TestAcceptRejectionDefault doc comment R1 Gon P2 No
CRF-14 P4 Author contested; panel closed R2 (1/1 accept) accept.go:91 Name ErrorHandler implies all Accept errors; hijack failure excluded R1 Gon P4 Yes
CRF-15 Note Note (body) accept_test.go:229 In 7bd7405 the wantHeader comment refers to a handler added in 0eca8fe R1 Gon No
CRF-16 P4 Note (body) PR description "For four rejections ... byte for byte" names neither the rejections nor the command R1 Leorio P4, Mafu-san Note No
CRF-17 Nit Note (body) PR description PR body says Fixes #545, commit 0eca8fe says Refs #545 R1 Leorio No
CRF-18 Note Dropped by orchestrator (commit message already records the gorilla reason; Ryosuke and Mafu-san confirmed the parity) PR description Decision log omits gorilla Upgrader.Error signature parity R1 Leorio, Ryosuke, Mafu-san No
CRF-19 Note Dropped by orchestrator (informational, asks for no change) accept.go:86 "must write a response" sentence gives requirement and result R1 Leorio No
CRF-20 Note Dropped by orchestrator (informational, asks for no change) accept_test.go:314 Table tests check real behavior R1 Bisky No
CRF-21 OOS Out of scope (body) accept.go:175 Hijack failure calls http.Error after WriteHeader(101); 500 never sent R1 Netero, Ryosuke, Mafuuu, Chopper, Meruem No
CRF-22 OOS Out of scope (body) accept.go:143 Hijacker error text does not name usual causes R1 Leorio No
CRF-23 OOS Out of scope (body) accept.go:91 No stable rejection reason identity; sentinels deferred without ticket R1 Chopper Note, Pariston No
CRF-24 OOS Out of scope (body) accept.go:207 errCode vs status naming for the same value R1 Gon No
CRF-25 OOS Out of scope (body) accept.go:385 go fix reports strings.SplitSeq and dead tc := tc copies in unchanged code R1 Ging-Go No
CRF-26 Note Note (body) accept.go:135 Invalid OriginPatterns error now both logged via log.Printf and returned/passed to ErrorHandler R2 Netero No
CRF-27 Nit Open accept.go:86 "so ErrorHandler decides what to send to the client" repeats the doc's first sentence R2 Gon P2 Yes
CRF-28 Note Note (body) accept_test.go:439 "Upgrade, close" has no comment; removing close changed no result in 5 runs R2 Gon P4 No
CRF-29 P4 Open accept.go:273 Pattern error now returned from Accept says "path pattern", not OriginPatterns R2 Leorio Yes
CRF-30 P4 Open accept.go:83 ErrorHandler doc does not state that err wraps path.ErrBadPattern for a bad OriginPatterns entry R2 Leorio Yes
CRF-31 Note Note (body) accept_test.go:483 TestAcceptRejectionDefault no longer passes against master accept.go, so "match master" cannot be rechecked by running it R2 Mafu-san No
CRF-32 Note Dropped by orchestrator (praise, asks for no change) accept_test.go:222 One rejection row drives both table tests R2 Kite No

Contested and acknowledged

CRF-14 (P4, accept.go:91) - Name ErrorHandler implies all Accept errors

  • Finding: Gon proposed renaming ErrorHandler to RejectionHandler, because the handler does not run when hijacking fails and a caller who reads only the name may log every Accept failure from it.
  • Author defense: Maintainer decision to keep ErrorHandler. The field doc states the hijack exception, and httputil.ReverseProxy.ErrorHandler and gorilla's Upgrader.Error use the same kind of name.
  • Panel closure (R2, 1/1): Gon, the agent that raised it (same agent as R1, resumed), accepted. A caller who logs every Accept failure from the handler misses only the hijack failure, Accept still returns that error, and the field doc states the exception at accept.go:89-91. gorilla's Upgrader.Error has the same kind of exception for errors after it hijacks.

Round log

Round 1

Full panel (round 1). Netero: 1 P3, 1 Note, no P0. Panel: ging-go, ryosuke, kurapika, pariston, mafuuu, bisky, gon, leorio, hisoka, chopper, mafu-san; wildcard meruem (random from distilled set). Kurapika added for the origin rejection path, which put the panel in the 9-12 target. Posted 5 P3, 3 P4, 2 Nit. Gon's comment-wording P2s rated Nit (style, code correct) or dropped. Mafu-san's P2 on rejection table coverage set to P3: both missing branches go through the tested call site at accept.go:136 in unchanged authenticateOrigin; the gap is the default-response pin and the claim. PR description items (CRF-16, CRF-17) have no file location and are posted as a body process observation. Orchestrator verified CRF-3 (1/30 failures of TestAcceptErrorHandler filtered run) and CRF-21 (net/http server.go:1172 excludes 101 from 1xx handling). Reviewed against e1a21e5..0eca8fe.

Round 2

Fix round: no files added or renamed, no exported signature changed, no function rewritten, +47 effective additions since R1. Churn guard PROCEED: 9 addressed, 1 contested (CRF-14). CRF-11 reopened because 56eccc2 passes the pattern error to ErrorHandler, removing the drop premise. Netero: 1 Note (CRF-26). Panel: gon (resumed R1 agent for CRF-14 dispute check-in), bisky, pariston, meruem, leorio, hisoka, chopper, ryosuke, mafu-san (raised findings the new commits touch), ging-go, kurapika (changed Go files and origin path); wildcard kite. All verified fixes held (CRF-1, 3, 4, 5, 6, 7, 8, 10, 12). CRF-14 closed 1/1. New: 2 P4 (CRF-29, CRF-30), 1 Nit (CRF-27); CRF-11 re-raised and posted at P4 (inaccurate test comment, no runtime effect). Gon's CRF-27 P2 rated Nit (wording of a correct doc). Gon's CRF-28 P4 moved to Note (body): keep-argument is that an unexplained header token leaves readers unsure whether the leak fix depends on it; it stays a note because close is the standard HTTP token for a one-request connection and changed no observed result. Reviewed against e1a21e5..56eccc2.

About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This PR adds AcceptOptions.ErrorHandler, which writes the HTTP response when Accept rejects a handshake request before the 101 response. 5 P3, 3 P4, 2 Nit.

PR description: "For four rejections, the raw responses match master byte for byte" names neither the four rejections nor the command, so a reviewer cannot repeat it; a real-server comparison of all 12 rejections a server can produce matched base e1a21e5 byte for byte excluding Date, so the sentence can say that. The PR body says Fixes #545 and commit 0eca8fe says Refs #545; use one keyword in both.

Out of scope (needs a ticket or explicit acceptance by a human):

  • accept.go:175: after hj.Hijack() fails, Accept calls http.Error after WriteHeader(101). net/http treats 101 as the final status (net/http/server.go excludes StatusSwitchingProtocols from 1xx handling), so the 500 is a superfluous WriteHeader and the client receives a 101 with no usable connection.
  • accept.go:143: "http.ResponseWriter does not implement http.Hijacker" does not name the usual causes (an HTTP/2 request, or middleware that wraps the ResponseWriter without Unwrap), so an operator has to find the cause without help.
  • accept.go:91: rejection reasons have no stable identity (status 400 covers four reasons), so a handler that maps reasons to RFC 9457 type values must match error strings. The decision log defers sentinel errors without a ticket.
  • accept.go:207: verifyClientRequest names the status errCode, while ErrorHandler and writeRejection name the same value status.
  • accept.go:385, accept_test.go:566, and other unchanged lines: go fix -diff . reports a strings.SplitSeq rewrite and dead tc := tc copies.

Notes:

  • accept_test.go:211: newHandshakeRequest builds the same request that several TestAccept cases build inline; the inline copies predate this PR.
  • accept_test.go:229: in commit 7bd7405, the wantHeader comment refers to a handler that is added in 0eca8fe.

🤖 This review was automatically generated with Coder Agents.

Comment thread accept.go Resolved
Comment thread accept_test.go Resolved
Comment thread accept.go Resolved
Comment thread accept.go Resolved
Comment thread accept_test.go Resolved
Comment thread accept_test.go Resolved
Comment thread accept_test.go Resolved
Comment thread accept.go Resolved
Comment thread accept_test.go Resolved
Comment thread accept.go Resolved
The doc comment on writeRejection only existed to explain that body is
the fallback text for a nil ErrorHandler. The name defaultBody says that
at each call site, so the comment is removed.
The ErrorHandler doc did not say whether err is safe to send to the
client. For noHijacker, err names a server-side interface, so a handler
that writes err.Error() into the response would expose server details.
No test failed if Accept called ErrorHandler after a failed hijack, when
the 101 status line is already written. The new case uses a hijacker
that returns an error and asserts the handler never runs.
The http.Client transport goroutines in the server subtest exited after
TestMain counted goroutines, so filtered runs such as -run TestAccept
failed the leak check intermittently. A raw TCP connection with
Connection: close leaves no client goroutines behind.
Every test handler discarded the request argument, so passing nil or a
copy to ErrorHandler left the tests passing. The table test now asserts
the handler gets the same *http.Request that was passed to Accept.
TestAcceptRejectionDefault repeated each row's header and body in a map
keyed by row name, and a new row without a map entry failed with a
header mismatch that did not name the missing key. The header and body
now come from the row, with wantDefaultBody for the one row whose
default body is not wantErr.
acceptRejectionTests is documented as covering every rejection, but the
Origin parse failure and the Origin without a host had no row, so their
responses were not pinned.
The previous wording said err is not meant for the client, but Accept's
default response sends err.Error() for every rejection except
noHijacker. The doc now leaves the choice of what to send to the
handler.
The earlier rewrite dropped the reason for testing over a real
connection along with the part that restated the writesNothing row. Only
the restated part needed to go.
Accept replaced an invalid OriginPatterns error with "Forbidden", so an
ErrorHandler could not tell a misconfigured server from a disallowed
origin. The real error now reaches ErrorHandler and Accept's caller, and
the default response body stays "Forbidden".
@mafredri

mafredri commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

/coder-agents-review

🤖

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-review at 56eccc2. 2 P4, 1 Nit, plus one round-1 test comment that 56eccc2 made inaccurate (P4).

The fixes for the round-1 findings hold: the hijack-failure test, the raw-connection server subtest (0 of 40 filtered runs fail, down from 19 of 40), the err doc sentence, the pattern error passed to ErrorHandler, the two Origin rows, the request pointer check, the derived default expectations, and the defaultBody rename. Keeping the name ErrorHandler is accepted.

Notes:

  • accept.go:135: an invalid OriginPatterns entry is now both written by log.Printf and returned from Accept and passed to ErrorHandler, so a server that logs Accept errors records it twice per cross-origin request. The global log still helps callers that ignore the error.
  • accept_test.go:483: after 56eccc2, the badOriginPattern row asserts the new returned error, so TestAcceptRejectionDefault fails against master's accept.go. The description's "those match master" is correct for status, headers and body (verified with the err assertions removed), but running the test against master no longer shows it.
  • accept_test.go:439: the close token in Connection: Upgrade, close has no comment; removing it changed no result in 5 runs of the server subtest.

accept.go:273

P4 [CRF-29] Since 56eccc2, failed to parse path pattern "[": syntax error in pattern is the error Accept returns and ErrorHandler receives, and it does not say the pattern is an OriginPatterns entry. (Leorio)

Before 56eccc2 only log.Printf printed this text. An operator who logs Accept errors now sees failed to accept WebSocket connection: failed to parse path pattern "["... (pinned in TestAcceptRejectionDefault), and "path pattern" points them at URL routes instead of the option. Change the message to fmt.Errorf("invalid OriginPatterns entry %q: %w", hostPattern, err) and update the badOriginPattern row.

🤖

🤖 This review was automatically generated with Coder Agents.

Comment thread accept_test.go
wantHeader: http.Header{},
},
{
// The pattern error is logged and not sent to the client.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P4 [CRF-11] "The pattern error is logged and not sent to the client." is no longer true for the handler path this row drives. (Gon)

Since 56eccc2 the row sets wantErr to the pattern error and wantErrIs: path.ErrBadPattern, and TestAcceptErrorHandler asserts that ErrorHandler receives it, so a handler that writes err sends the pattern to the client. Only the nil-handler body hides it, which wantDefaultBody: "Forbidden" already shows. Change it to // The default body hides the pattern error, which Accept logs.

🤖

Comment thread accept.go
// ErrorHandler writes the HTTP response when Accept rejects the handshake
// request. status is the HTTP status Accept chose, w.Header() holds the
// headers Accept set for the rejection, and Accept returns an error
// wrapping err. err may contain server details, so ErrorHandler decides

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit [CRF-27] "so ErrorHandler decides what to send to the client" repeats the doc's first sentence, which already says ErrorHandler writes the response. (Gon)

The fact the caller needs is that err may contain server details; err may contain server details. says it.

🤖

Comment thread accept.go
// is a response to a ping and does not trigger any further frame transmission.
OnPongReceived func(ctx context.Context, payload []byte)

// ErrorHandler writes the HTTP response when Accept rejects the handshake

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P4 [CRF-30] The ErrorHandler doc does not say how a handler can tell an invalid OriginPatterns entry from a disallowed origin, though 56eccc2's commit message gives that as the reason for the change. (Leorio)

Both cases arrive with status 403, so a handler can separate them only with errors.Is(err, path.ErrBadPattern) or by matching error text. The badOriginPattern row pins wantErrIs: path.ErrBadPattern, so the tests already treat the wrap as a contract. Add "For an invalid OriginPatterns entry, err wraps path.ErrBadPattern." to the doc, or drop the wantErrIs assertion if the wrap is not meant to be relied on.

🤖

This branch has not been deployed

No deployments
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.

Allow customize how HTTP errors are rendered when websocket.Accept fails to upgrade

1 participant