@@ -2,6 +2,119 @@ import { createHash } from "crypto";
22import fetch from "node-fetch" ;
33import { logger } from "./logger.js" ;
44
5+ /**
6+ * Custom error class that includes HTTP status code information
7+ */
8+ class HttpError extends Error {
9+ constructor (
10+ message : string ,
11+ public readonly statusCode : number ,
12+ ) {
13+ super ( message ) ;
14+ this . name = "HttpError" ;
15+ }
16+ }
17+
18+ interface RetryOptions {
19+ maxRetries : number ;
20+ baseDelayMs : number ;
21+ maxDelayMs : number ;
22+ backoffMultiplier : number ;
23+ }
24+
25+ const DEFAULT_RETRY_OPTIONS : RetryOptions = {
26+ maxRetries : 3 ,
27+ baseDelayMs : 1000 ,
28+ maxDelayMs : 10000 ,
29+ backoffMultiplier : 2 ,
30+ } ;
31+
32+ /**
33+ * Determines if an error should be retried based on HTTP status codes
34+ * Uses a simple, reliable approach: only retry on known server errors and timeouts
35+ */
36+ function shouldRetryError ( error : Error , statusCode ?: number ) : boolean {
37+ // If we have an HTTP status code, use standard HTTP semantics
38+ if ( statusCode !== undefined ) {
39+ // Only retry server errors (5xx)
40+ return statusCode >= 500 ;
41+ }
42+
43+ // For network errors without status codes, only retry specific known transient issues
44+ // Check Node.js system error codes (most reliable)
45+ if ( "code" in error && typeof ( error as any ) . code === "string" ) {
46+ const code = ( error as any ) . code ;
47+ return code === "ETIMEDOUT" || code === "ECONNRESET" ;
48+ }
49+
50+ // Don't retry anything else - be conservative
51+ return false ;
52+ }
53+
54+ async function withRetry < T > (
55+ operation : ( ) => Promise < T > ,
56+ operationName : string ,
57+ options : Partial < RetryOptions > = { } ,
58+ ) : Promise < T > {
59+ const { maxRetries, baseDelayMs, maxDelayMs, backoffMultiplier } = {
60+ ...DEFAULT_RETRY_OPTIONS ,
61+ ...options ,
62+ } ;
63+
64+ let lastError : Error ;
65+ let lastStatusCode : number | undefined ;
66+
67+ for ( let attempt = 1 ; attempt <= maxRetries + 1 ; attempt ++ ) {
68+ try {
69+ const result = await operation ( ) ;
70+ if ( attempt > 1 ) {
71+ logger . info ( `${ operationName } succeeded on attempt ${ attempt } ` ) ;
72+ }
73+ return result ;
74+ } catch ( error ) {
75+ lastError = error as Error ;
76+
77+ // Extract status code if it's an HttpError
78+ if ( error instanceof HttpError ) {
79+ lastStatusCode = error . statusCode ;
80+ } else {
81+ lastStatusCode = undefined ;
82+ }
83+
84+ if (
85+ attempt <= maxRetries &&
86+ shouldRetryError ( lastError , lastStatusCode )
87+ ) {
88+ const delay = Math . min (
89+ baseDelayMs * Math . pow ( backoffMultiplier , attempt - 1 ) ,
90+ maxDelayMs ,
91+ ) ;
92+
93+ const statusInfo = lastStatusCode ? ` (HTTP ${ lastStatusCode } )` : "" ;
94+ logger . warn (
95+ `${ operationName } failed on attempt ${ attempt } /${ maxRetries + 1 } : ${ lastError . message } ${ statusInfo } . Retrying in ${ delay } ms...` ,
96+ ) ;
97+
98+ await new Promise ( ( resolve ) => setTimeout ( resolve , delay ) ) ;
99+ } else {
100+ if ( attempt <= maxRetries ) {
101+ const statusInfo = lastStatusCode ? ` (HTTP ${ lastStatusCode } )` : "" ;
102+ logger . info (
103+ `${ operationName } failed with non-retryable error: ${ lastError . message } ${ statusInfo } . Not retrying.` ,
104+ ) ;
105+ } else {
106+ logger . error (
107+ `${ operationName } failed after ${ maxRetries + 1 } attempts. Final error: ${ lastError . message } ` ,
108+ ) ;
109+ }
110+ break ;
111+ }
112+ }
113+ }
114+
115+ throw lastError ! ;
116+ }
117+
5118export interface HameApiResponse {
6119 code : string ;
7120 msg : string ;
@@ -60,16 +173,28 @@ export class HameApi {
60173 url . searchParams . append ( "pwd" , hashedPassword ) ;
61174
62175 logger . info ( `Fetching device token for ${ mailbox } ...` ) ;
63- const resp = await fetch ( url . toString ( ) , { headers : this . headers } ) ;
64- const data = ( await resp . json ( ) ) as HameApiResponse ;
65176
66- if ( data . code !== "2" || ! data . token ) {
67- throw new Error (
68- `Unexpected API response code: ${ data . code } - ${ data . msg } ` ,
69- ) ;
70- }
177+ return withRetry ( async ( ) => {
178+ const resp = await fetch ( url . toString ( ) , { headers : this . headers } ) ;
71179
72- return data ;
180+ // Check HTTP status first - we have the response object here
181+ if ( ! resp . ok ) {
182+ throw new HttpError (
183+ `HTTP ${ resp . status } : ${ resp . statusText } ` ,
184+ resp . status ,
185+ ) ;
186+ }
187+
188+ const data = ( await resp . json ( ) ) as HameApiResponse ;
189+
190+ if ( data . code !== "2" || ! data . token ) {
191+ throw new Error (
192+ `Unexpected API response code: ${ data . code } - ${ data . msg } ` ,
193+ ) ;
194+ }
195+
196+ return data ;
197+ } , `Fetch device token for ${ mailbox } ` ) ;
73198 }
74199
75200 async fetchDeviceList (
@@ -84,21 +209,42 @@ export class HameApi {
84209 url . searchParams . append ( "token" , token ) ;
85210
86211 logger . info ( "Fetching device list..." ) ;
87- const resp = await fetch ( url . toString ( ) , { headers : this . headers } ) ;
88- const data = ( await resp . json ( ) ) as HameDeviceListResponse ;
89212
90- if ( data . code !== 1 ) {
91- throw new Error (
92- `Unexpected API response from device list: ${ data . code } - ${ data . msg } ` ,
93- ) ;
94- }
213+ return withRetry ( async ( ) => {
214+ const resp = await fetch ( url . toString ( ) , { headers : this . headers } ) ;
95215
96- return data ;
216+ // Check HTTP status first - we have the response object here
217+ if ( ! resp . ok ) {
218+ throw new HttpError (
219+ `HTTP ${ resp . status } : ${ resp . statusText } ` ,
220+ resp . status ,
221+ ) ;
222+ }
223+
224+ const data = ( await resp . json ( ) ) as HameDeviceListResponse ;
225+
226+ if ( data . code !== 1 ) {
227+ throw new Error (
228+ `Unexpected API response from device list: ${ data . code } - ${ data . msg } ` ,
229+ ) ;
230+ }
231+
232+ return data ;
233+ } , "Fetch device list" ) ;
97234 }
98235
99236 async fetchDevices ( mailbox : string , password : string ) : Promise < DeviceInfo [ ] > {
100- const tokenResp = await this . fetchDeviceToken ( mailbox , password ) ;
101- const list = await this . fetchDeviceList ( mailbox , tokenResp . token ! ) ;
102- return list . data ;
237+ return withRetry (
238+ async ( ) => {
239+ const tokenResp = await this . fetchDeviceToken ( mailbox , password ) ;
240+ const list = await this . fetchDeviceList ( mailbox , tokenResp . token ! ) ;
241+ logger . info (
242+ `Successfully fetched ${ list . data . length } devices from Hame API` ,
243+ ) ;
244+ return list . data ;
245+ } ,
246+ "Fetch devices from Hame API" ,
247+ { maxRetries : 2 } , // Fewer retries for the overall operation since individual calls already retry
248+ ) ;
103249 }
104250}
0 commit comments