Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion dial.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ func (opts *DialOptions) cloneWithDefaults(ctx context.Context) (context.Context
// connection. For any other status, resp.Body holds at most the first
// 1024 bytes of the body.
//
// The error matches ErrBadStatus when the final response that
// DialOptions.HTTPClient returns has a status other than 101 Switching
// Protocols, and resp is then non nil. If HTTPClient.CheckRedirect returns
// an error other than http.ErrUseLastResponse, the error does not match
// ErrBadStatus and resp is nil.
//
// This function requires at least Go 1.12 as it uses a new feature
// in net/http to perform WebSocket handshakes.
// See docs on the HTTPClient option and https://github.com/golang/go/issues/26937#issuecomment-415855861
Expand Down Expand Up @@ -252,7 +258,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") {
Expand All @@ -278,6 +284,21 @@ func verifyServerResponse(opts *DialOptions, copts *compressionOptions, secWebSo
return verifyServerExtensions(copts, resp.Header)
}

// badStatusError keeps the Dial error text unchanged, because callers match
// on that text. It matches ErrBadStatus through Is instead of Unwrap, so code
// that unwraps to the innermost error still gets that text.
type badStatusError struct {
Comment thread
mafredri marked this conversation as resolved.
Resolved
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) Is(target error) bool {
return target == ErrBadStatus
}

func verifySubprotocol(subprotos []string, resp *http.Response) error {
proto := resp.Header.Get("Sec-WebSocket-Protocol")
if proto == "" {
Expand Down
103 changes: 103 additions & 0 deletions dial_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"bytes"
"context"
"crypto/rand"
"errors"
"io"
"maps"
"net/http"
Expand Down Expand Up @@ -120,6 +121,107 @@ func TestBadDials(t *testing.T) {
HTTPClient: mockHTTPClient(rt),
})
assert.Contains(t, err, "response body is not a io.ReadWriteCloser")
assert.Equal(t, "errors.Is(err, ErrBadStatus)", false, errors.Is(err, websocket.ErrBadStatus))
})
}

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)
// Code that unwraps to the innermost error must still get the status text.
inner := err
for e := errors.Unwrap(inner); e != nil; e = errors.Unwrap(inner) {
inner = e
}
assert.Equal(t, "innermost error", "expected handshake response status code 101 but got 403", inner.Error())
Comment thread
mafredri marked this conversation as resolved.
Resolved
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("networkError", func(t *testing.T) {
Comment thread
mafredri marked this conversation as resolved.
Resolved
t.Parallel()

ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()

_, resp, err := websocket.Dial(ctx, "ws://example.com", &websocket.DialOptions{
HTTPClient: mockHTTPClient(func(*http.Request) (*http.Response, error) {
return nil, errors.New("connection refused")
}),
})
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)
}
})

redirectClient := func(checkRedirect error) *http.Client {
hc := mockHTTPClient(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusFound,
Header: http.Header{"Location": {"wss://example.com/login"}},
}, nil
})
hc.CheckRedirect = func(*http.Request, []*http.Request) error {
return checkRedirect
}
return hc
}

t.Run("redirectRefused", func(t *testing.T) {
t.Parallel()

ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()

errRefused := errors.New("redirect refused")
_, resp, err := websocket.Dial(ctx, "ws://example.com", &websocket.DialOptions{
HTTPClient: redirectClient(errRefused),
})
assert.ErrorIs(t, errRefused, 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)
}
})

t.Run("redirectUseLastResponse", func(t *testing.T) {
t.Parallel()

ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()

_, resp, err := websocket.Dial(ctx, "ws://example.com", &websocket.DialOptions{
HTTPClient: redirectClient(http.ErrUseLastResponse),
})
assert.ErrorIs(t, websocket.ErrBadStatus, err)
if resp == nil {
t.Fatal("expected non-nil response")
}
assert.Equal(t, "status code", http.StatusFound, resp.StatusCode)
})
Comment thread
mafredri marked this conversation as resolved.
Resolved
}

Expand Down Expand Up @@ -292,6 +394,7 @@ func Test_verifyServerHandshake(t *testing.T) {
} else {
assert.Error(t, err)
}
assert.Equal(t, "errors.Is(err, ErrBadStatus)", tc.name == "badStatus", errors.Is(err, websocket.ErrBadStatus))
})
}
}
Expand Down
6 changes: 6 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,11 @@ import (
"errors"
)

// ErrBadStatus means that the server answered the opening handshake from
// Dial with a status other than 101 Switching Protocols. Test for it with
// errors.Is. For backward compatibility, the error from Dial does not
Comment thread
mafredri marked this conversation as resolved.
Resolved
// unwrap to it, so its innermost error is still the status text.
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")
3 changes: 2 additions & 1 deletion ws_js.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,8 @@ type DialOptions struct {
// Dial creates a new WebSocket connection to the given url with the given options.
// The passed context bounds the maximum time spent waiting for the connection to open.
// The returned *http.Response is always nil or a mock. It's only in the signature
// to match the core API.
// to match the core API. The error never matches ErrBadStatus, because the
// browser does not expose the handshake response.
func Dial(ctx context.Context, url string, opts *DialOptions) (*Conn, *http.Response, error) {
c, resp, err := dial(ctx, url, opts)
if err != nil {
Expand Down
Loading