@@ -19,6 +19,13 @@ export class HttpConnection extends MeshDevice {
1919
2020 private abortController : AbortController ;
2121
22+ private readonly defaultRetryConfig : Types . HttpRetryConfig = {
23+ maxRetries : 3 ,
24+ initialDelayMs : 1000 ,
25+ maxDelayMs : 10000 ,
26+ backoffFactor : 2 ,
27+ } ;
28+
2229 constructor ( configId ?: number ) {
2330 super ( configId ) ;
2431
@@ -37,6 +44,105 @@ export class HttpConnection extends MeshDevice {
3744 ) ;
3845 }
3946
47+ /**
48+ * Checks if the error should trigger a retry attempt
49+ * @param response - The fetch response
50+ * @returns boolean indicating if should retry
51+ */
52+ private shouldRetry ( response : Response ) : boolean {
53+ if ( response . status >= 500 && response . status <= 599 ) {
54+ return true ;
55+ }
56+
57+ if ( ! response . ok && response . status < 400 ) {
58+ return true ;
59+ }
60+
61+ return false ;
62+ }
63+
64+ /**
65+ * Implements exponential backoff retry logic for HTTP operations
66+ * @param operation - The async operation to retry
67+ * @param retryConfig - Configuration for retry behavior
68+ * @param operationName - Name of the operation for logging
69+ */
70+ private async withRetry (
71+ operation : ( ) => Promise < Response > ,
72+ retryConfig : Types . HttpRetryConfig ,
73+ operationName : string ,
74+ ) : Promise < Response > {
75+ let delay = retryConfig . initialDelayMs ;
76+
77+ for ( let attempt = 1 ; attempt <= retryConfig . maxRetries ; attempt ++ ) {
78+ try {
79+ const response = await operation ( ) ;
80+
81+ // If the response is success or a non-retryable error, return it
82+ if ( ! this . shouldRetry ( response ) ) {
83+ return response ;
84+ }
85+
86+ const error = new Error (
87+ `HTTP ${ response . status } : ${ response . statusText } ` ,
88+ ) ;
89+
90+ if ( attempt === retryConfig . maxRetries ) {
91+ throw error ;
92+ }
93+
94+ this . log . warn (
95+ `${ operationName } failed (attempt ${ attempt } /${ retryConfig . maxRetries } ): ${ error . message } ` ,
96+ ) ;
97+
98+ await new Promise ( ( resolve ) => setTimeout ( resolve , delay ) ) ;
99+ delay = Math . min (
100+ delay * retryConfig . backoffFactor ,
101+ retryConfig . maxDelayMs ,
102+ ) ;
103+ } catch ( error ) {
104+ // If it's not a Response error (e.g., network error), don't retry
105+ if ( ! ( error instanceof Error ) || ! error . message . startsWith ( "HTTP" ) ) {
106+ throw error ;
107+ }
108+
109+ if ( attempt === retryConfig . maxRetries ) {
110+ throw error ;
111+ }
112+
113+ this . log . warn (
114+ `${ operationName } failed (attempt ${ attempt } /${ retryConfig . maxRetries } ): ${ error . message } ` ,
115+ ) ;
116+ }
117+ }
118+
119+ // This line should never be reached due to the error handling above,
120+ throw new Error ( "Unexpected end of retry loop" ) ;
121+ }
122+
123+ /**
124+ * Attempts a single connection to the device
125+ */
126+ private async attemptConnection (
127+ params : Types . HttpConnectionParameters ,
128+ ) : Promise < Response > {
129+ const { address, tls = false } = params ;
130+ this . portId = `${ tls ? "https://" : "http://" } ${ address } ` ;
131+
132+ // We create a dummy request here just to have a Response object to work with
133+ // The actual connection check is done via ping()
134+ const response = await fetch ( `${ this . portId } /hotspot-detect.html` , {
135+ signal : this . abortController . signal ,
136+ mode : "no-cors" ,
137+ } ) ;
138+
139+ if ( ! response . ok ) {
140+ throw new Error ( `HTTP ${ response . status } : ${ response . statusText } ` ) ;
141+ }
142+
143+ return response ;
144+ }
145+
40146 /**
41147 * Initiates the connect process to a Meshtastic device via HTTP(S)
42148 */
@@ -46,45 +152,73 @@ export class HttpConnection extends MeshDevice {
46152 receiveBatchRequests = false ,
47153 tls = false ,
48154 } : Types . HttpConnectionParameters ) : Promise < void > {
155+ // Set initial state
49156 this . updateDeviceStatus ( Types . DeviceStatusEnum . DeviceConnecting ) ;
50-
51157 this . receiveBatchRequests = receiveBatchRequests ;
52158
53- this . portId = `${ tls ? "https://" : "http://" } ${ address } ` ;
54-
55- if (
56- this . deviceStatus === Types . DeviceStatusEnum . DeviceConnecting &&
57- ( await this . ping ( ) )
58- ) {
59- this . log . debug (
60- Types . Emitter [ Types . Emitter . Connect ] ,
61- "Ping succeeded, starting configuration and request timer." ,
159+ try {
160+ // Attempt connection with retries
161+ await this . withRetry (
162+ ( ) => this . attemptConnection ( { address, tls, fetchInterval } ) ,
163+ {
164+ ...this . defaultRetryConfig ,
165+ maxRetries : 5 , // More retries for initial connection
166+ maxDelayMs : 10000 , // Max 10s between retries
167+ } ,
168+ "Connect" ,
62169 ) ;
63- this . configure ( ) . catch ( ( ) => {
64- // TODO: FIX, workaround for `wantConfigId` not getting acks.
65- } ) ;
66- this . readLoop = setInterval ( ( ) => {
67- this . readFromRadio ( ) . catch ( ( e : Error ) => {
68- this . log . error (
170+
171+ // If connection successful, set up device
172+ if ( this . deviceStatus === Types . DeviceStatusEnum . DeviceConnecting ) {
173+ this . log . debug (
174+ Types . Emitter [ Types . Emitter . Connect ] ,
175+ "Connection succeeded, starting configuration and request timer." ,
176+ ) ;
177+
178+ // Start device configuration
179+ await this . configure ( ) . catch ( ( error ) => {
180+ this . log . warn (
69181 Types . Emitter [ Types . Emitter . Connect ] ,
70- `❌ ${ e . message } ` ,
182+ `Configuration warning: ${ error . message } ` ,
71183 ) ;
72184 } ) ;
73- } , fetchInterval ) ;
74- } else if (
75- this . deviceStatus !== Types . DeviceStatusEnum . DeviceDisconnected
76- ) {
77- setTimeout ( ( ) => {
185+
186+ if ( ! this . readLoop ) {
187+ this . readLoop = setInterval ( async ( ) => {
188+ try {
189+ await this . readFromRadio ( ) ;
190+ } catch ( error ) {
191+ if ( error instanceof Error ) {
192+ this . log . error (
193+ Types . Emitter [ Types . Emitter . Connect ] ,
194+ `❌ Read loop error: ${ error . message } ` ,
195+ ) ;
196+ }
197+ }
198+ } , fetchInterval ) ;
199+ }
200+ }
201+ } catch ( error ) {
202+ if ( error instanceof Error ) {
203+ this . log . error (
204+ Types . Emitter [ Types . Emitter . Connect ] ,
205+ `❌ Connection failed: ${ error . message } ` ,
206+ ) ;
207+ }
208+
209+ // Only attempt reconnection if we haven't been disconnected
210+ if ( this . deviceStatus !== Types . DeviceStatusEnum . DeviceDisconnected ) {
211+ this . updateDeviceStatus ( Types . DeviceStatusEnum . DeviceReconnecting ) ;
212+
78213 this . connect ( {
79- address : address ,
80- fetchInterval : fetchInterval ,
81- receiveBatchRequests : receiveBatchRequests ,
82- tls : tls ,
214+ address,
215+ fetchInterval,
216+ receiveBatchRequests,
217+ tls,
83218 } ) ;
84- } , 10000 ) ;
219+ }
85220 }
86221 }
87-
88222 /** Disconnects from the Meshtastic device */
89223 public disconnect ( ) : void {
90224 this . abortController . abort ( ) ;
@@ -95,7 +229,7 @@ export class HttpConnection extends MeshDevice {
95229 }
96230 }
97231
98- /** Pings device to check if it is avaliable */
232+ /** Pings device to check if it is available with retry logic */
99233 public async ping ( ) : Promise < boolean > {
100234 this . log . debug (
101235 Types . Emitter [ Types . Emitter . Ping ] ,
@@ -104,22 +238,34 @@ export class HttpConnection extends MeshDevice {
104238
105239 const { signal } = this . abortController ;
106240
107- let pingSuccessful = false ;
241+ try {
242+ const response = await this . withRetry (
243+ async ( ) => {
244+ return await fetch ( `${ this . portId } /hotspot-detect.html` , {
245+ signal,
246+ mode : "no-cors" ,
247+ } ) ;
248+ } ,
249+ { ...this . defaultRetryConfig } ,
250+ "Ping" ,
251+ ) ;
108252
109- await fetch ( `${ this . portId } /hotspot-detect.html` , {
110- signal,
111- mode : "no-cors" ,
112- } )
113- . then ( ( ) => {
114- pingSuccessful = true ;
115- this . updateDeviceStatus ( Types . DeviceStatusEnum . DeviceConnected ) ;
116- } )
117- . catch ( ( e : Error ) => {
118- pingSuccessful = false ;
119- this . log . error ( Types . Emitter [ Types . Emitter . Ping ] , `❌ ${ e . message } ` ) ;
120- this . updateDeviceStatus ( Types . DeviceStatusEnum . DeviceReconnecting ) ;
121- } ) ;
122- return pingSuccessful ;
253+ if ( ! response . ok ) {
254+ throw new Error ( `HTTP ${ response . status } : ${ response . statusText } ` ) ;
255+ }
256+
257+ this . updateDeviceStatus ( Types . DeviceStatusEnum . DeviceConnected ) ;
258+ return true ;
259+ } catch ( error ) {
260+ if ( error instanceof Error ) {
261+ this . log . error (
262+ Types . Emitter [ Types . Emitter . Ping ] ,
263+ `❌ ${ error . message } ` ,
264+ ) ;
265+ }
266+ this . updateDeviceStatus ( Types . DeviceStatusEnum . DeviceReconnecting ) ;
267+ return false ;
268+ }
123269 }
124270
125271 /** Reads any avaliable protobuf messages from the radio */
0 commit comments