Skip to content

Commit a992058

Browse files
committed
Add backoff for post-handshake reconnects.
Prior to this change, the client would immediately attempt to reconnect to the server without any backoff when it encountered errors after the initial authentication handshake. This resulted in excessive reconnect attempts. This commit prevents the backoff-retry logic from resetting its timer unless the connection has been stable for more than 2 minutes, which is double the configured max backoff interval (excluding jitter).
1 parent 9471d9b commit a992058

1 file changed

Lines changed: 59 additions & 13 deletions

File tree

websocket/client.go

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package massivews
22

33
import (
4+
"context"
45
"encoding/json"
56
"errors"
67
"fmt"
@@ -18,10 +19,11 @@ import (
1819
)
1920

2021
const (
21-
writeWait = 5 * time.Second
22-
pongWait = 30 * time.Second
23-
pingPeriod = pongWait - 5*time.Second // send ping 5 seconds before deadline
24-
maxMessageSize = 1_000_000 // 1MB
22+
writeWait = 5 * time.Second
23+
pongWait = 30 * time.Second
24+
pingPeriod = pongWait - 5*time.Second // send ping 5 seconds before deadline
25+
maxMessageSize = 1_000_000 // 1MB
26+
clientMaxBackoffInterval = 60 * time.Second
2527
)
2628

2729
// Client defines a client to the Massive WebSocket API.
@@ -31,8 +33,10 @@ type Client struct {
3133
market Market
3234
url string
3335

34-
shouldClose bool
36+
closeCtx context.Context
37+
closeCtxFn context.CancelFunc
3538
backoff backoff.BackOff
39+
connectTime time.Time
3640

3741
mtx sync.Mutex
3842
rwtomb tomb.Tomb
@@ -57,12 +61,15 @@ func New(config Config) (*Client, error) {
5761
if err := config.validate(); err != nil {
5862
return nil, fmt.Errorf("invalid client options: %w", err)
5963
}
60-
64+
expBackoff := backoff.NewExponentialBackOff()
65+
expBackoff.MaxInterval = clientMaxBackoffInterval
66+
// The client should give up if it is in a reconnect loop for too long.
67+
expBackoff.MaxElapsedTime = time.Hour
6168
c := &Client{
6269
apiKey: config.APIKey,
6370
feed: config.Feed,
6471
market: config.Market,
65-
backoff: backoff.NewExponentialBackOff(),
72+
backoff: expBackoff,
6673
rQueue: make(chan json.RawMessage, 10000),
6774
wQueue: make(chan json.RawMessage, 1000),
6875
subs: make(subscriptions),
@@ -98,11 +105,12 @@ func (c *Client) Connect() error {
98105
if c.conn != nil {
99106
return nil
100107
}
108+
c.closeCtx, c.closeCtxFn = context.WithCancel(context.Background())
101109

102-
notify := func(err error, _ time.Duration) {
110+
notify := func(err error) {
103111
c.log.Errorf(err.Error())
104112
}
105-
if err := backoff.RetryNotify(c.connect(false), c.backoff, notify); err != nil {
113+
if err := c.backoffRetry(c.connect(false), notify); err != nil {
106114
return err
107115
}
108116

@@ -172,11 +180,48 @@ func (c *Client) Error() <-chan error {
172180

173181
// Close attempts to gracefully close the connection to the server.
174182
func (c *Client) Close() {
183+
if c.closeCtxFn != nil {
184+
// Close the context so that any pending backoff-retries instantly cancel.
185+
c.closeCtxFn()
186+
}
175187
c.mtx.Lock()
176188
defer c.mtx.Unlock()
177189
c.close(false)
178190
}
179191

192+
func (c *Client) backoffRetry(fn func() error, notify func(error)) error {
193+
var err error
194+
for {
195+
if time.Since(c.connectTime) > 2*clientMaxBackoffInterval {
196+
// Reset the backoff timer only if the connection has been stable for double the max interval.
197+
c.backoff.Reset()
198+
err = nil
199+
}
200+
// Skip backoffs only for the very first connection a client performs.
201+
if !c.connectTime.IsZero() {
202+
// Backoff regardless of prior error status in this func, since it's possible for
203+
// another goroutine other than the `fn` to have failed and triggered a reconnect.
204+
wait := c.backoff.NextBackOff()
205+
if wait == backoff.Stop {
206+
return err // Return the last-known recent error.
207+
}
208+
select {
209+
case <-time.After(wait):
210+
case <-c.closeCtx.Done():
211+
return c.closeCtx.Err()
212+
}
213+
}
214+
c.connectTime = time.Now()
215+
err = fn()
216+
if err == nil {
217+
return nil
218+
}
219+
if notify != nil {
220+
notify(err)
221+
}
222+
}
223+
}
224+
180225
func newConn(uri string) (*websocket.Conn, error) {
181226
conn, res, err := websocket.DefaultDialer.Dial(uri, nil)
182227
if err != nil {
@@ -239,20 +284,21 @@ func (c *Client) reconnect() {
239284
c.mtx.Lock()
240285
defer c.mtx.Unlock()
241286

242-
if c.shouldClose {
287+
if c.closeCtx.Err() != nil {
243288
return
244289
}
245290

246291
c.log.Debugf("unexpected disconnect: reconnecting")
247292
c.close(true)
248293

249-
notify := func(err error, _ time.Duration) {
294+
notify := func(err error) {
250295
c.log.Errorf(err.Error())
251296
if c.reconnectCallback != nil {
252297
c.reconnectCallback(err)
253298
}
254299
}
255-
err := backoff.RetryNotify(c.connect(true), c.backoff, notify)
300+
301+
err := c.backoffRetry(c.connect(true), notify)
256302
if err != nil {
257303
err = fmt.Errorf("error reconnecting: %w: closing connection", err)
258304
c.log.Errorf(err.Error())
@@ -286,7 +332,7 @@ func (c *Client) close(reconnect bool) {
286332
if err := c.ptomb.Wait(); err != nil {
287333
c.log.Errorf("process thread closed: %v", err)
288334
}
289-
c.shouldClose = true
335+
c.closeCtxFn()
290336
c.closeOutput()
291337
}
292338

0 commit comments

Comments
 (0)