Skip to content

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

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

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

Conversation

@mafredri

@mafredri mafredri commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

The error from Dial now matches a new sentinel error, ErrBadStatus, under errors.Is 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. The error does not match ErrBadStatus 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 matches ErrBadStatus through an Is method. It does not unwrap to ErrBadStatus, so code that unwraps the error to its innermost error still gets the status text.

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

New tests cover a 403 with a 1025-byte body, a round tripper that returns an error, and a 302 with a CheckRedirect error or http.ErrUseLastResponse. Test_verifyServerHandshake checks that only its non-101 case matches ErrBadStatus, and the non-writable body test checks that its error does not. The 403 test fails on master with only the variable declared. It also checks the innermost error's text, which fails if badStatusError unwraps to ErrBadStatus instead of matching it through Is. 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 matches it; invalid 101 headers and a non-writable body keep their own errors, as in x/net/websocket.
  • An Is method instead of Unwrap, so the innermost error keeps its text. errors.Is gives the same result either way, and errors.As cannot target ErrBadStatus, whose type is unexported. Only code that compares errors.Unwrap results with ErrBadStatus cannot find it.
  • Rejected: a %w prefix (websocket: bad status: expected ...), because it changes err.Error(), which public projects compare in full.
  • 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 CheckRedirect error other than http.ErrUseLastResponse stays a request error with a nil response, because net/http then returns the previous response with its body closed. http.ErrUseLastResponse gives the redirect response, which matches 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.

@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-04 12:31 UTC by @mafredri
Spend: $23.16 / $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
  • R3 (2026-11-04): 8 reviewers, 6 P3, 6 P4, COMMENT. Review
  • R4 (2026-11-04): 8 reviewers, 1 Nit, 6 P3, 8 P4, COMMENT. Review

deep-review v0.13.0 | Round 4 | e1a21e5..793e884

Last posted: Round 4, 15 findings (6 P3, 8 P4, 1 Nit), 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 Author contested; panel closed R3 (3/3 accept) 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 Author fixed (5b410e6) 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 Author fixed (cb240bd) dial_test.go:123 badBody does not assert the error does not wrap ErrBadStatus R2 Bisky Yes
CRF-17 P4 Author fixed (cb240bd) errors.go:10 No test covers the redirect behavior stated in the ErrBadStatus doc R2 Mafu-san Yes
CRF-18 P3 Author fixed (ff78d4a) dial.go:282 Unwrap makes the PR breaking in a v1 module; an Is method gives the same errors.Is result without it R3 Pariston P3, Zoro P3 Yes
CRF-19 P4 Author fixed (793e884) dial.go:282 No test fails if Unwrap is replaced with an Is method R3 Bisky Yes
CRF-20 Nit Dropped by orchestrator (comment wording depends on the Unwrap-or-Is decision in CRF-18; decision log and PR description state the cost) dial.go:272 Unwrap comment sentence omits the innermost-error cost R3 Gon P2 No
CRF-21 Nit Dropped by orchestrator (the round 2 review suggested this exact assertion; renaming the case fails the test rather than skipping the check) dial_test.go:391 ErrBadStatus expectation keyed on case name instead of a struct field R3 Zoro No
CRF-22 Note Note (body) dial.go:284 feat! cannot become a v2 release on this module path; breaking-change text belongs in v1 release notes R3 Ryosuke No
CRF-23 P4 Open errors.go:9 "For backward compatibility" in exported ErrBadStatus doc gives a reason godoc readers cannot decode R4 Gon P2, Leorio Nit Yes
CRF-24 P4 Open dial_test.go:153 PR description does not say the innermost-text check guards against Unwrap R4 Leorio Yes
CRF-25 Nit Open dial_test.go:181 Test helper redirect reads as a verb but returns an *http.Client R4 Gon Yes
CRF-26 Nit Dropped by orchestrator (current sentence already states Is over Unwrap and the innermost-text cost; the rewrite changes wording only) dial.go:277 badStatusError comment restates the Is method R4 Gon P2 No

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.

CRF-7 (P3, dial.go:272) - badStatusError compatibility claim and its sources

  • Finding: R1: the badStatusError comment states that callers match on the Dial error string, with no source; asked to link a caller or describe only what the type does and drop the history phrase. R2 re-raise: the cited sub2api file (backend/internal/service/openai_request_scoped_403.go) is not in Wei-Shaw/sub2api main, the cited alpaca-trade-api-go check is a substring match that the rejected %w prefix would still satisfy, and the sources are only in the review thread so the squash commit will not carry them; asked for a resolvable link in the decision log, or to say there that the text is kept as a precaution and known callers match on substrings.
  • Author defense (R3, PRRC_kwDOCJ5FYc8AAAABCkRkWg): "Partly right." The cited file is in a different repository from the one the reviewer checked and compares the full message with strings.EqualFold, so a caller that depends on the exact text exists (repository not named). The alpaca check is a substring match. The decision log does not name callers, by maintainer decision. The decision log now says the %w prefix is rejected because it changes err.Error(), "which public projects compare in full". Separately, the author found that Unwrap makes ErrBadStatus the innermost error, kept Unwrap by maintainer decision, marked the PR as a breaking change, and changed the comment (8631dc0, 95c3b6d) to say why Unwrap is used instead of an Is method.
  • Panel closure (R3, 3/3): All three raising personas checked in with their previous agents (message_agent). Mafu-san accepted after finding the full-text caller: backend/internal/service/openai_request_scoped_403.go:37-38 in the sub2api forks Ritel-T/sub2api, ziyue67/sub2api and ranxi2001/sub2api compares the message with strings.EqualFold against "failed to WebSocket dial: expected handshake response status code 101 but got 403", and those forks depend on coder/websocket v1.8.14; the %w prefix would break that match. Leorio accepted: the claim is true as worded, naming callers in the decision log is a maintainer choice about PR text, and the decision log no longer leans on the alpaca substring match. Gon accepted: the history phrase is gone and the claim is supported. Closure holds while the comment and decision log claim only that callers match on the text.

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.

Round 3

Churn guard PROCEED: CRF-14, CRF-16, CRF-17 addressed; CRF-7 contested. Fix round (docs and tests; no files added; no exported signature change; title changed to feat! with a breaking-change paragraph but the stated goal is unchanged; additions 79 to 127). Netero: no findings. Panel: mafu-san, leorio, gon (contested CRF-7, previous agents reused), bisky (tests changed), ging-go, ryosuke (Go files changed), pariston (new breaking-change premise), wildcard zoro. Ging-go, Ryosuke: no findings.
Cross-check: CRF-7 closed 3/3. Pariston P3 and Zoro P3 converge on Unwrap vs Is as CRF-18 (both reproduced the innermost-text difference; Ryosuke's note that the module path has no /v2 strengthens it). Bisky P4 (CRF-19) posted as the alternative if Unwrap stays. Gon P2 on Unwrap comment wording dropped as CRF-20 (depends on CRF-18). Zoro Nit dropped as CRF-21 (review-suggested code). Ryosuke's claim that the latest tag is v1.8.9 is wrong (v1.8.15); it does not change the note. 1 P3, 1 P4 posted. Reviewed against e1a21e5..95c3b6d.

Round 4

Churn guard PROCEED: CRF-18, CRF-19 addressed. Restructure round: Unwrap replaced by an Is method, the ErrBadStatus contract wording changed from "wraps" to "matches", docs moved from errors.go to Dial, and ws_js.go joined the diff. Netero: no findings; CRF-19 fix verified by mutation. Full panel (focused, target 6-8): ging-go, ryosuke (specialists), pariston, mafuuu (core), bisky (tests), gon, leorio (doc comments changed), wildcard meruem. Ging-go, Ryosuke, Pariston, Mafuuu, Bisky, Meruem: no findings; four reviewers reproduced that restoring Unwrap fails nonSwitchingStatus.
Cross-check: Gon P2 and Leorio Nit on "For backward compatibility" merged as CRF-23 at P4 (the instruction the reader acts on is correct; only the reason clause is unclear). Gon P2 on the badStatusError comment dropped as CRF-26. Gon Nit on the redirect helper name posted as CRF-25. Leorio P4 on the PR description posted as CRF-24. 2 P4, 1 Nit posted. Reviewed against e1a21e5..793e884.

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[bot]

This comment was marked as outdated.

@mafredri

This comment has been minimized.

coder-agents-review[bot]

This comment was marked as outdated.

@mafredri mafredri changed the title feat: add ErrBadStatus for non-101 Dial responses feat!: add ErrBadStatus for non-101 Dial responses Nov 4, 2026
@mafredri

This comment has been minimized.

coder-agents-review[bot]

This comment was marked as outdated.

@mafredri mafredri changed the title feat!: add ErrBadStatus for non-101 Dial responses feat: add ErrBadStatus for non-101 Dial responses Nov 4, 2026
@mafredri

This comment has been minimized.

coder-agents-review[bot]

This comment was marked as outdated.

@mafredri
mafredri marked this pull request as ready for review November 4, 2026 12:45
@mafredri
mafredri requested a review from code-asher November 5, 2026 11:55
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.
The Dial doc repeated the condition that the ErrBadStatus doc states, so
a change to the condition had to edit both.
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.
The doc said a redirect that CheckRedirect refuses never wraps
ErrBadStatus, but returning http.ErrUseLastResponse stops the redirect
and returns the 3xx response, which does wrap it.
The ErrBadStatus doc says how a CheckRedirect error and
http.ErrUseLastResponse are reported, and a non-writable body after a
101 must not wrap it, but no test checked either.
An Is method would also satisfy errors.Is and would keep the innermost
error's text. Unwrap is kept so code that walks the error chain finds
ErrBadStatus too.
A single errors.Unwrap on the Dial error returns badStatusError, so the
comment's errors.Unwrap claim held only for code that walks the chain.
The ErrBadStatus doc described which HTTPClient response counts, how
CheckRedirect errors are returned and what happens on js/wasm, which is
Dial's behavior. ErrBadStatus now says only what it means, and both Dial
docs state their own rules.
Unwrap made ErrBadStatus the innermost error, so code that unwraps a
Dial error to its innermost error got "websocket: bad status" instead of
the status text. An Is method gives the same errors.Is result without
changing the chain.
Unwrap and Is give the same errors.Is result, so only the innermost
error shows whether a change brought back the old chain.
The helper returns an *http.Client, but HTTPClient: redirect(...) read
as performing a redirect.
ErrBadStatus is new, so "for backward compatibility" alone did not say
what is kept. The innermost error is still the status text.
@mafredri
mafredri force-pushed the mafredri/dial-err-bad-status branch from b8ae92d to 72732d9 Compare November 5, 2026 12:03

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