Skip to content

Commit baca495

Browse files
committed
Prioritize HTTP status in raced connection retry decisions
1 parent afb4aab commit baca495

3 files changed

Lines changed: 70 additions & 7 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,10 @@ The first successful upgrade wins. Pending upgrades are canceled, and opened
130130
losers close asynchronously with status 1000 and reason `loser socket`.
131131
The winner proceeds without waiting for those close handshakes. If every
132132
endpoint fails, the returned error joins the failures in configured URL order.
133-
When failures carry HTTP status codes, initial background reconnect classification
134-
uses the first matching status in that order, preserving the existing
135-
single-endpoint retry rules.
133+
Initial background reconnect classification uses the first matching HTTP status
134+
in configured URL order, ahead of network errors from other endpoints. When no
135+
HTTP status is present, network and dial failures remain retryable. The existing
136+
single-endpoint retry rules remain unchanged.
136137

137138
With zero or one race URL, the client uses `SocketConfig.URL`, or the default
138139
endpoint if `URL` is empty. With racing enabled, the list supplies the endpoints

‎client.go‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -577,10 +577,8 @@ func (cli *Client) Connect() error {
577577
}
578578

579579
func isRetryableConnectError(err error) bool {
580-
if exhttp.IsNetworkError(err) {
581-
return true
582-
}
583-
580+
// HTTP upgrade rejections take precedence over network failures from
581+
// other racers. Joined errors preserve the configured endpoint order.
584582
var statusErr socket.ErrWithStatusCode
585583
if errors.As(err, &statusErr) {
586584
switch statusErr.StatusCode {
@@ -591,6 +589,9 @@ func isRetryableConnectError(err error) bool {
591589
}
592590
}
593591

592+
if exhttp.IsNetworkError(err) {
593+
return true
594+
}
594595
return errors.Is(err, socket.ErrDialFailed)
595596
}
596597

‎socket_race_test.go‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright (c) 2026 Rajeh Taher
2+
// Licensed under the MIT License. See LICENSE-MIT for details.
3+
4+
package whatsmeow
5+
6+
import (
7+
"context"
8+
"errors"
9+
"net"
10+
"net/http"
11+
"net/http/httptest"
12+
"syscall"
13+
"testing"
14+
"time"
15+
16+
"github.com/polymorfa/hypermeow/socket"
17+
waLog "github.com/polymorfa/hypermeow/util/log"
18+
)
19+
20+
func connectStatusError(t *testing.T, status int) error {
21+
t.Helper()
22+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
23+
w.WriteHeader(status)
24+
}))
25+
defer server.Close()
26+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
27+
defer cancel()
28+
_, err := socket.ConnectRace(ctx, waLog.Noop, http.DefaultClient, []string{"ws" + server.URL[4:]}, nil)
29+
var statusErr socket.ErrWithStatusCode
30+
if !errors.As(err, &statusErr) || statusErr.StatusCode != status {
31+
t.Fatalf("connect error = %v, want HTTP %d", err, status)
32+
}
33+
return err
34+
}
35+
36+
func TestConnectRetryClassificationPreservesHTTPStatusPrecedence(t *testing.T) {
37+
forbidden := connectStatusError(t, http.StatusForbidden)
38+
unavailable := connectStatusError(t, http.StatusServiceUnavailable)
39+
network := &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED}
40+
for _, tc := range []struct {
41+
name string
42+
err error
43+
want bool
44+
}{
45+
{"single rejection", forbidden, false},
46+
{"single server error", unavailable, true},
47+
{"single network error", network, true},
48+
{"rejection before network", errors.Join(forbidden, network), false},
49+
{"network before rejection", errors.Join(network, forbidden), false},
50+
{"rejection before server error", errors.Join(forbidden, unavailable), false},
51+
{"server error before rejection", errors.Join(unavailable, forbidden), true},
52+
{"all rejected", errors.Join(forbidden, forbidden), false},
53+
{"no error", nil, false},
54+
} {
55+
t.Run(tc.name, func(t *testing.T) {
56+
if got := isRetryableConnectError(tc.err); got != tc.want {
57+
t.Fatalf("retryable = %v, want %v: %v", got, tc.want, tc.err)
58+
}
59+
})
60+
}
61+
}

0 commit comments

Comments
 (0)