Skip to content

feat: add ErrBadStatus for non-101 Dial responses - #587

Draft
mafredri wants to merge 8 commits into
masterfrom
mafredri/dial-err-bad-status
Draft

mafredri wants to merge 8 commits into
masterfrom
mafredri/dial-err-bad-status

Conversation

@mafredri

@mafredri mafredri commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Dial now wraps a new sentinel error, ErrBadStatus, when the server answers the opening handshake with a status other than 101 Switching Protocols. After errors.Is(err, websocket.ErrBadStatus), the returned response is non-nil and resp.Body holds up to the first 1024 bytes of the response body.

Before this change, the only signal was a non-nil response, as advised in #424. But Dial also returns a non-nil response when a 101 response fails validation: an invalid Connection, Upgrade or Sec-WebSocket-Accept header, an unexpected subprotocol or extension, or a body that is not writable. A caller that treats every non-nil response as an HTTP rejection reports those protocol failures as rejections. ErrBadStatus is not wrapped in those cases, or when no response arrives.

The error text is unchanged on purpose, for example failed to WebSocket dial: expected handshake response status code 101 but got 403, because public projects match on it. An unexported error type returns that text and unwraps to ErrBadStatus.

On js/wasm, Dial never wraps ErrBadStatus, because the browser does not expose the handshake response.

New tests cover a 403 with a 1025-byte body and a round tripper that returns an error, and Test_verifyServerHandshake checks that only its non-101 case wraps ErrBadStatus. The 403 test fails on master with only the variable declared. Lint, race tests and the WASM test pass.

Refs #424, #528

Decision log
  • Name and meaning follow ErrBadStatus in golang.org/x/net/websocket.
  • Only a non-101 status wraps it; invalid 101 headers and a non-writable body keep their own errors, as in x/net/websocket.
  • Unwrap instead of an Is method, so ErrBadStatus is in the error chain that the docs describe as wrapped.
  • Rejected: a %w prefix (websocket: bad status: expected ...), because it changes text that public projects match on.
  • Rejected: an exported struct with status, header and body, because it duplicates the returned response. Also rejected: one with only the status code, because Dial returns resp, and a caller that needs the status after wrapping the error can wrap it with the status. It can be added later if someone asks.
  • Rejected: an error that holds the *http.Response, because its body is the same reader as the returned response's body, so one read empties both.
  • Rejected: documenting the resp.StatusCode != 101 check only, because it gives no condition that errors.Is can test after a caller wraps the error.
  • A redirect that HTTPClient.CheckRedirect refuses stays a request error with a nil response, because net/http then returns the previous response with its body closed. Returning http.ErrUseLastResponse from CheckRedirect gives the redirect response, which wraps ErrBadStatus.
  • Unchanged: the 1024-byte limit, and Dial still closes the body.
  • Separate change: skipping the body read after a 101 response.

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

Dial returned a non-nil response both when the server rejected the
handshake with a non-101 status and when a 101 response failed
validation, so callers checking resp != nil treated protocol violations
as HTTP errors. Dial now wraps ErrBadStatus only for a non-101 status,
and documents that the response and its first 1024 body bytes are
available in that case.
Wrapping ErrBadStatus with %w inserted "websocket: bad status: " into
the Dial error string, and public projects match on the existing text.
An unexported error type now returns the previous text and unwraps to
ErrBadStatus, so errors.Is still holds.
@mafredri
mafredri force-pushed the mafredri/dial-err-bad-status branch from 6a36d7a to ddfc2e5 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:51 UTC by @mafredri
Spend: $10.98 / $100.00

Review history
  • R1 (2026-11-03): 9 reviewers, 4 P3, 3 P4, COMMENT. Review
  • R2 (2026-11-03): 9 reviewers, 5 P3, 5 P4, COMMENT. Review

deep-review v0.13.0 | Round 2 | e1a21e5..edc663d

Last posted: Round 2, 10 findings (5 P3, 5 P4), COMMENT. Review

Finding inventory

Finding inventory: PR #587

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P3 Author fixed (d78dc49) dial.go:113 Dial doc drops 1024-byte body limit for non-ErrBadStatus errors R1 Netero Yes
CRF-2 P4 Author fixed (690bba8) dial_test.go:182 networkError subtest dials freed port of closed httptest.Server R1 Netero Yes
CRF-3 Note Dropped by orchestrator (asks no change; coverage gap tracked in CRF-6) dial_test.go:156 invalidAccept and networkError pass on base; negative checks R1 Netero No
CRF-4 P3 Author contested; panel closed R2 (1/1 accept) dial.go:274 ErrBadStatus exposes no status code once the error is wrapped R1 Pariston Yes
CRF-5 P3 Author fixed (a9445fd) errors.go:7 Refused 3xx redirect returns neither ErrBadStatus nor resp, contradicting docs R1 Mafuuu P3, Pariston P4 Yes
CRF-6 P4 Author fixed (7c2b3a0) dial_test.go:156 Only one of six 101 validation failures is checked for not wrapping ErrBadStatus R1 Bisky Yes
CRF-7 P3 Open (re-raised R2: cited sub2api file not found; sources only in thread) dial.go:272 badStatusError comment states an unsourced compatibility claim and change history R1 Mafu-san P2, Gon P2, Leorio P4; R2 Mafu-san P3, Leorio P4 Yes
CRF-8 P4 Author fixed (d78dc49) dial.go:113 Dial doc repeats the ErrBadStatus condition stated in errors.go R1 Gon P2 Yes
CRF-9 Nit Dropped by orchestrator (test fails if the production limit changes; a test-local const does not link to dial.go:154) dial_test.go:133 Unnamed 1024 literal in nonSwitchingStatus R1 Gon P4 No
CRF-10 OOS Out of scope (body) dial.go:88 cloneWithDefaults removes net/http 10-redirect limit R1 Mafuuu No
CRF-11 OOS Out of scope (body) dial.go:151 Invalid 101 makes Dial block 3s reading upgraded connection R1 Mafu-san No
CRF-12 OOS Out of scope (body) dial.go:154 1024-byte body limit and 3s read timeout are unnamed literals R1 Gon No
CRF-13 OOS Out of scope (body) dial.go:117 Stale "requires at least Go 1.12" in Dial doc R1 Gon No
CRF-14 P3 Open errors.go:10 Refused-redirect doc sentence is wrong for http.ErrUseLastResponse R2 Gon P3, Leorio P4 Yes
CRF-15 Nit Dropped by orchestrator (wording follows the round 1 review's own suggested text; comment still states the reason callers depend on) dial.go:271 badStatusError comment says "unchanged" and restates Unwrap R2 Gon P2 No
CRF-16 P4 Open dial_test.go:123 badBody does not assert the error does not wrap ErrBadStatus R2 Bisky Yes
CRF-17 P4 Open errors.go:10 No test covers the redirect behavior stated in the ErrBadStatus doc R2 Mafu-san Yes

Contested and acknowledged

CRF-4 (P3, dial.go:274) - ErrBadStatus exposes no status code once the error is wrapped

  • Finding: Pariston: after a caller wraps the Dial error and no longer holds resp, ErrBadStatus cannot tell a 404 from a 502 or 503, so a reconnect loop that treats rejection as terminal (Please return non-generic errors from dial #424) stops on transient proxy errors or parses the text. Proposed exporting the type with only the status code (for example BadStatusError{StatusCode int}) so errors.As returns it.
  • Author defense (R2, PRRC_kwDOCJ5FYc8AAAABCdqXeA): Maintainer decision: no exported status type until someone asks for one. Dial returns resp with the status, a caller that needs the status after wrapping the error can include it when it wraps, and a type can be added later without breaking callers.
  • Panel closure (R2, 1/1): Pariston, the agent that raised it (reused via message_agent), accepted: the code that drops resp is the code that wraps the error, so it has the status code when it wraps; an exported type is an additive change later; Dial guarantees a non-nil resp when err wraps ErrBadStatus (dial.go:114). Mafu-san noted alpaca-trade-api-go drops resp and parses the text to tell 4xx from 5xx (client.go:544-551); evidence only, not a vote. This closure holds while Dial keeps returning resp with ErrBadStatus.

Round log

Round 1

Netero first pass: 1 P3, 1 P4, 1 Note; no P0, panel proceeded. Round type: round 1, full panel. Focused (Go only), size target 6-8: ging-go, ryosuke (specialists), pariston, mafuuu (core), bisky (tests changed), gon, leorio (once-per-PR floor), mafu-san (agent-authored PR); wildcard knov (random from distilled set). 9 panel reviewers, one over target because mafu-san was added for the agent-authored PR. Ging-go, Ryosuke, Knov: no findings.
Cross-check: Gon P2 (Dial doc duplication) downgraded to P4: duplication of a one-clause condition between a sentinel and the function returning it is a drift risk, not a defect. Gon P2 (badStatusError comment) merged into CRF-7 with Mafu-san and Leorio since one rewrite fixes both. Mafu-san P2 vs Leorio P4 on the unsourced claim settled at P3: if the claim is false, the cost is a small unexported type that keeps error text stable. Pariston's side claim that the default client stops after 10 redirects is contradicted by Mafuuu's CRF-10 and dial.go:88-100. Positive notes from Bisky, Leorio, Knov, Mafu-san, Pariston, Gon not inventoried (ask no change). 4 P3, 3 P4 posted. Reviewed against e1a21e5..ddfc2e5.

Round 2

Churn guard PROCEED: CRF-1, 2, 5, 6, 7, 8 addressed; CRF-4 contested. Fix round (docs and tests only, no files added, no exported signature change, additions fell from 102 to 79). Netero: no findings, CRF-1 and CRF-2 fixes verified. Panel: pariston (contested CRF-4, previous agent reused), mafuuu, bisky, gon, leorio, mafu-san (raised findings on touched code; gon and leorio also for doc-comment changes), ging-go, ryosuke (Go files changed), wildcard knov. Ging-go, Ryosuke, Knov, Mafuuu: no findings.
Cross-check: CRF-4 closed 1/1. Gon P3 and Leorio P4 on the redirect sentence merged as CRF-14 at P3 (higher wins; both reproduced; consequence is that a caller who trusts the doc does not use ErrUseLastResponse to get the redirect status). Mafu-san P3 and Leorio P4 on sources merged as a CRF-7 re-raise at P3; orchestrator confirmed sub2api main has no Go file with the text (shallow clone, grep). Gon P2 on the comment wording dropped as CRF-15 (the round 1 review suggested that wording). Mafu-san Note on CRF-4 kept as evidence only. 1 new P3, 2 new P4, 1 re-raised P3. Reviewed against e1a21e5..edc663d.

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 ErrBadStatus, which the Dial error wraps when the handshake response status is not 101. 4 P3 and 3 P4 findings.

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

  • dial.go:88-100: cloneWithDefaults always installs a CheckRedirect that returns nil when the caller set none, which removes the net/http 10-redirect limit, so a server that redirects to itself makes Dial loop until the context ends.
  • dial.go:151-165: after a 101 response fails validation, Dial reads from the upgraded connection and blocks for 3 seconds when the server sends nothing (measured 3.014s). The decision log calls this a separate change, and no ticket is linked.
  • dial.go:154, dial.go:156: the 1024-byte body limit and the 3-second body read timeout are unnamed literals, and the Dial doc does not mention the 3-second wait.
  • dial.go:117: "This function requires at least Go 1.12" is stale because go.mod declares go 1.26, so a reader may take it as a current constraint.

🤖 This review was automatically generated with Coder Agents.

Comment thread dial.go Resolved
Comment thread dial.go Resolved
Comment thread errors.go Resolved
Comment thread dial.go Resolved
Comment thread dial.go Resolved
Comment thread dial_test.go Resolved
Comment thread dial_test.go Resolved
Dial truncates the body of every response it returns with an error, not
only for ErrBadStatus, and the previous doc stated the limit only for a
non-101 status. The ErrBadStatus condition is now stated once, in the
ErrBadStatus doc, so the Dial doc says only what the caller gets.
A 302 refused by HTTPClient.CheckRedirect makes HTTPClient.Do return an
error, so Dial returns a request error with a nil response and no
ErrBadStatus. The doc said any non-101 answer wraps ErrBadStatus, which
is false for that redirect.
The comment claimed that callers match on the Dial error string, which
no linked caller supports, and described the history of the change. It
now states only how Error and Unwrap behave.
invalidAccept checked one of the six 101 validation failures, so making
another one wrap ErrBadStatus left the tests passing. The assertion in
Test_verifyServerHandshake covers every row and replaces invalidAccept.
Dialing the address of a closed httptest.Server can reach a server that
a parallel test started on the same port. A round tripper that returns
an error tests the same nil-response path without a port.
Public callers match on the Dial error text, for example with
strings.EqualFold on the full message, so the reason the type exists
belongs in its comment.
@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.

This round reviews the ErrBadStatus doc and test changes made after the first review: 1 new P3, 2 new P4, and 1 P3 raised again.

Fixed: the 1024-byte body limit in the Dial doc, the condition repeated in two docs, the refused-redirect behavior for non-ErrUseLastResponse errors, the per-failure ErrBadStatus check in Test_verifyServerHandshake, and the networkError port dependency. The status code question is closed by the maintainer decision. The out-of-scope items listed in the first review still have no ticket.

🤖 This review was automatically generated with Coder Agents.

Comment thread dial_test.go
@@ -123,6 +124,54 @@ func TestBadDials(t *testing.T) {
})

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-16] TestBadDials/badBody checks only the error text, so nothing fails if the non-writable body error, which the PR description lists as not wrapping ErrBadStatus, starts wrapping it. (Bisky)

That error is built in dial (dial.go:173), outside verifyServerResponse, so the new assertion in Test_verifyServerHandshake does not see it. Changing it to fmt.Errorf("response body is not a io.ReadWriteCloser: %T: %w", respBody, ErrBadStatus) leaves go test -count=1 . passing. Add assert.Equal(t, "errors.Is(err, ErrBadStatus)", false, errors.Is(err, websocket.ErrBadStatus)) after the assert.Contains.

🤖

Comment thread errors.go
// ErrBadStatus is wrapped by the error from Dial when the final response
// that DialOptions.HTTPClient returns for the opening handshake has a
// status other than 101 Switching Protocols. See Dial for the response
// returned with it. A redirect that HTTPClient.CheckRedirect refuses is

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-17] No test covers the redirect behavior that the ErrBadStatus doc now states. (Mafu-san)

If handshakeRequest changes how a refused redirect is returned, this doc becomes false and every test still passes. A transport that returns 302 reproduces both cases: a CheckRedirect error gives a request error with a nil response, and http.ErrUseLastResponse gives ErrBadStatus with the 302 response. Add both as subtests of TestDialErrBadStatus, using the transport pattern from TestDialRedirect (dial_test.go:361).

🤖

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.

1 participant