diff --git a/dial.go b/dial.go index f5e4544b..60016883 100644 --- a/dial.go +++ b/dial.go @@ -109,8 +109,10 @@ func (opts *DialOptions) cloneWithDefaults(ctx context.Context) (context.Context // The response is the WebSocket handshake response from the server. // You never need to close resp.Body yourself. // -// If an error occurs, the returned response may be non nil. -// However, you can only read the first 1024 bytes of the body. +// If an error occurs, the returned response may be non nil. If the server +// answers with a status other than 101 Switching Protocols, err wraps +// ErrBadStatus, the response is non nil, and resp.Body holds up to the +// first 1024 bytes of the response body. // // This function requires at least Go 1.12 as it uses a new feature // in net/http to perform WebSocket handshakes. @@ -241,7 +243,7 @@ func secWebSocketKey(rr io.Reader) (string, error) { func verifyServerResponse(opts *DialOptions, copts *compressionOptions, secWebSocketKey string, resp *http.Response) (*compressionOptions, error) { if resp.StatusCode != http.StatusSwitchingProtocols { - return nil, fmt.Errorf("expected handshake response status code %v but got %v", http.StatusSwitchingProtocols, resp.StatusCode) + return nil, badStatusError{statusCode: resp.StatusCode} } if !headerContainsTokenIgnoreCase(resp.Header, "Connection", "Upgrade") { @@ -267,6 +269,20 @@ func verifyServerResponse(opts *DialOptions, copts *compressionOptions, secWebSo return verifyServerExtensions(copts, resp.Header) } +// badStatusError wraps ErrBadStatus without adding its text, because +// callers match on the Dial error string from before ErrBadStatus existed. +type badStatusError struct { + statusCode int +} + +func (e badStatusError) Error() string { + return fmt.Sprintf("expected handshake response status code %v but got %v", http.StatusSwitchingProtocols, e.statusCode) +} + +func (badStatusError) Unwrap() error { + return ErrBadStatus +} + func verifySubprotocol(subprotos []string, resp *http.Response) error { proto := resp.Header.Get("Sec-WebSocket-Protocol") if proto == "" { diff --git a/dial_test.go b/dial_test.go index 492ac6b3..f5de6eb3 100644 --- a/dial_test.go +++ b/dial_test.go @@ -6,6 +6,7 @@ import ( "bytes" "context" "crypto/rand" + "errors" "io" "maps" "net/http" @@ -123,6 +124,80 @@ func TestBadDials(t *testing.T) { }) } +func TestDialErrBadStatus(t *testing.T) { + t.Parallel() + + t.Run("nonSwitchingStatus", func(t *testing.T) { + t.Parallel() + + body := strings.Repeat("a", 1024) + "b" + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + io.WriteString(w, body) + })) + defer s.Close() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + _, resp, err := websocket.Dial(ctx, s.URL, nil) + assert.Error(t, err) + assert.Equal(t, "error", "failed to WebSocket dial: expected handshake response status code 101 but got 403", err.Error()) + assert.ErrorIs(t, websocket.ErrBadStatus, err) + if resp == nil { + t.Fatal("expected non-nil response") + } + assert.Equal(t, "status code", http.StatusForbidden, resp.StatusCode) + b, err := io.ReadAll(resp.Body) + assert.Success(t, err) + assert.Equal(t, "body", body[:1024], string(b)) + }) + + t.Run("invalidAccept", func(t *testing.T) { + t.Parallel() + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, brw, err := w.(http.Hijacker).Hijack() + if err != nil { + return + } + defer conn.Close() + brw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: invalid\r\n\r\n") + brw.Flush() + })) + defer s.Close() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + _, resp, err := websocket.Dial(ctx, s.URL, nil) + assert.Contains(t, err, "invalid Sec-WebSocket-Accept") + assert.Equal(t, "errors.Is(err, ErrBadStatus)", false, errors.Is(err, websocket.ErrBadStatus)) + if resp == nil { + t.Fatal("expected non-nil response") + } + assert.Equal(t, "status code", http.StatusSwitchingProtocols, resp.StatusCode) + }) + + t.Run("networkError", func(t *testing.T) { + t.Parallel() + + s := httptest.NewServer(http.NotFoundHandler()) + u := s.URL + s.Close() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + _, resp, err := websocket.Dial(ctx, u, nil) + assert.Error(t, err) + assert.Equal(t, "errors.Is(err, ErrBadStatus)", false, errors.Is(err, websocket.ErrBadStatus)) + if resp != nil { + t.Fatalf("expected nil response, got status %d", resp.StatusCode) + } + }) +} + func Test_verifyHostOverride(t *testing.T) { testCases := []struct { name string diff --git a/errors.go b/errors.go index bf4fc2b0..6b7f94b8 100644 --- a/errors.go +++ b/errors.go @@ -4,5 +4,13 @@ import ( "errors" ) +// ErrBadStatus is wrapped by the error from Dial when the server answers +// the opening handshake with an HTTP status other than 101 Switching +// Protocols. See Dial for the response returned with it. +// +// On js/wasm, Dial never returns an error wrapping ErrBadStatus because +// the browser does not expose the handshake response. +var ErrBadStatus = errors.New("websocket: bad status") + // ErrMessageTooBig is returned when a message exceeds the read limit. var ErrMessageTooBig = errors.New("websocket: message too big")