55 "fmt"
66 "net/http"
77 "strings"
8+ "sync"
9+ "sync/atomic"
10+ "time"
811
912 libshare "github.com/celestiaorg/go-square/v3/share"
1013 "github.com/filecoin-project/go-jsonrpc"
@@ -13,15 +16,30 @@ import (
1316
1417// Client dials the celestia-node RPC "blob" and "header" namespaces.
1518type Client struct {
16- Blob BlobAPI
17- Header HeaderAPI
18- closer jsonrpc.ClientCloser
19+ Blob BlobAPI
20+ Header HeaderAPI
21+ IsWebSocket atomic.Bool
22+
23+ mu sync.Mutex
24+ closer jsonrpc.ClientCloser
25+ retryCancel context.CancelFunc // stops the background WS retry loop
1926}
2027
21- // Close closes the underlying JSON-RPC connection.
28+ // Close closes the underlying JSON-RPC connection and stops any
29+ // background WebSocket retry loop.
2230func (c * Client ) Close () {
23- if c != nil && c .closer != nil {
24- c .closer ()
31+ if c == nil {
32+ return
33+ }
34+ c .mu .Lock ()
35+ if c .retryCancel != nil {
36+ c .retryCancel ()
37+ c .retryCancel = nil
38+ }
39+ closer := c .closer
40+ c .mu .Unlock ()
41+ if closer != nil {
42+ closer ()
2543 }
2644}
2745
@@ -72,18 +90,88 @@ func NewClient(ctx context.Context, addr, token string, authHeaderName string) (
7290// NewWSClient connects to the DA RPC endpoint over WebSocket.
7391// Automatically converts http:// to ws:// (and https:// to wss://).
7492// Supports channel-based subscriptions (e.g. Subscribe).
75- // Note: WebSocket connections are eager — they connect at creation time
76- // if the initial WS dial fails, falls back to HTTP polling for the entire session.
93+ // WebSocket connections are eager — they connect at creation time.
94+ // If the initial WS dial fails, it falls back to HTTP polling and spawns a
95+ // background goroutine that periodically retries the WS connection. When
96+ // the WS endpoint becomes reachable, the transport is transparently upgraded.
7797func NewWSClient (ctx context.Context , logger zerolog.Logger , addr , token string , authHeaderName string ) (* Client , error ) {
7898 client , err := NewClient (ctx , httpToWS (addr ), token , authHeaderName )
7999 if err != nil {
80100 logger .Warn ().Err (err ).Msg ("DA websocket connection failed, falling back to DA polling" )
81- return NewClient (ctx , addr , token , authHeaderName )
101+ client , err = NewClient (ctx , addr , token , authHeaderName )
102+ if err != nil {
103+ return nil , err
104+ }
105+ client .IsWebSocket .Store (false )
106+
107+ // Retry WS in the background so transient outages don't force a permanent downgrade.
108+ retryCtx , retryCancel := context .WithCancel (ctx )
109+ client .retryCancel = retryCancel
110+ go client .retryWSLoop (retryCtx , logger , addr , token , authHeaderName )
111+
112+ return client , nil
82113 }
83114
115+ client .IsWebSocket .Store (true )
84116 return client , nil
85117}
86118
119+ const wsRetryInterval = 30 * time .Second
120+
121+ // retryWSLoop periodically attempts to re-establish a WebSocket connection.
122+ // When successful, it swaps the transport in-place and exits.
123+ func (c * Client ) retryWSLoop (ctx context.Context , logger zerolog.Logger , addr , token , authHeaderName string ) {
124+ ticker := time .NewTicker (wsRetryInterval )
125+ defer ticker .Stop ()
126+
127+ for {
128+ select {
129+ case <- ctx .Done ():
130+ return
131+ case <- ticker .C :
132+ if c .tryUpgradeWS (ctx , logger , addr , token , authHeaderName ) {
133+ return
134+ }
135+ }
136+ }
137+ }
138+
139+ // tryUpgradeWS attempts to open a WS connection and, if successful, swaps
140+ // the transport internals so subsequent calls use WebSocket. Returns true
141+ // when the upgrade succeeds (or the client is already on WS).
142+ func (c * Client ) tryUpgradeWS (ctx context.Context , logger zerolog.Logger , addr , token , authHeaderName string ) bool {
143+ wsClient , err := NewClient (ctx , httpToWS (addr ), token , authHeaderName )
144+ if err != nil {
145+ return false
146+ }
147+
148+ c .mu .Lock ()
149+ defer c .mu .Unlock ()
150+
151+ // Another goroutine may have already upgraded.
152+ if c .IsWebSocket .Load () {
153+ wsClient .Close ()
154+ return true
155+ }
156+
157+ // Swap function pointers from the new WS client into the active client.
158+ c .Blob .Internal = wsClient .Blob .Internal
159+ c .Header .Internal = wsClient .Header .Internal
160+
161+ // Close the old HTTP connections and wire the new closer.
162+ oldCloser := c .closer
163+ c .closer = func () {
164+ wsClient .closer ()
165+ if oldCloser != nil {
166+ oldCloser ()
167+ }
168+ }
169+
170+ c .IsWebSocket .Store (true )
171+ logger .Info ().Msg ("DA websocket connection restored, switching back from HTTP polling" )
172+ return true
173+ }
174+
87175// BlobAPI mirrors celestia-node's blob module (nodebuilder/blob/blob.go).
88176// jsonrpc.NewClient wires Internal.* to RPC stubs.
89177type BlobAPI struct {
0 commit comments