@@ -16,6 +16,9 @@ interface SessionInfo {
1616// Store sessions by host+password hash to reuse sessions until they expire
1717const sessionCache = new Map < string , SessionInfo > ( ) ;
1818
19+ // Track request timing to prevent overwhelming Pi-hole with concurrent requests
20+ const requestTimestamps = new Map < string , number > ( ) ;
21+
1922// Generate a cache key from host and password (without storing the actual password)
2023const getCacheKey = ( baseUrl : string , password : string ) : string => {
2124 // Simple hash function - do not use the password directly as a key
@@ -24,6 +27,22 @@ const getCacheKey = (baseUrl: string, password: string): string => {
2427 return `${ baseUrl } :${ hash } ` ;
2528} ;
2629
30+ // Helper function to add a small delay between requests to the same Pi-hole instance
31+ const addRequestDelay = async ( baseUrl : string ) : Promise < void > => {
32+ const lastRequestTime = requestTimestamps . get ( baseUrl ) || 0 ;
33+ const now = Date . now ( ) ;
34+ const timeSinceLastRequest = now - lastRequestTime ;
35+
36+ // If the last request was less than 100ms ago, add a small delay
37+ if ( timeSinceLastRequest < 100 ) {
38+ const delay = 100 - timeSinceLastRequest ;
39+ await new Promise ( resolve => setTimeout ( resolve , delay ) ) ;
40+ }
41+
42+ // Update the timestamp
43+ requestTimestamps . set ( baseUrl , Date . now ( ) ) ;
44+ } ;
45+
2746// Clean expired sessions periodically
2847setInterval ( async ( ) => {
2948 const now = Date . now ( ) ;
@@ -65,6 +84,20 @@ setInterval(async () => {
6584 // Wait for all logout operations to complete
6685 await Promise . all ( logoutPromises ) ;
6786 }
87+
88+ // Clean up old request timestamps (older than 1 hour)
89+ const oneHourAgo = now - ( 60 * 60 * 1000 ) ;
90+ let cleanedTimestamps = 0 ;
91+ requestTimestamps . forEach ( ( timestamp , key ) => {
92+ if ( timestamp < oneHourAgo ) {
93+ requestTimestamps . delete ( key ) ;
94+ cleanedTimestamps ++ ;
95+ }
96+ } ) ;
97+
98+ if ( expiredCount > 0 || cleanedTimestamps > 0 ) {
99+ console . log ( `Pi-hole cleanup: ${ expiredCount } expired sessions, ${ logoutCount } successful logouts, ${ cleanedTimestamps } old timestamps cleaned` ) ;
100+ }
68101} , 60000 ) ; // Check every minute
69102
70103// Helper function to validate and get itemId with better error message
@@ -108,6 +141,21 @@ const getPassword = (req: Request): string | null => {
108141 return password ;
109142} ;
110143
144+ /**
145+ * Helper function to check if an error is a connection/session related error that should trigger a retry
146+ */
147+ const isConnectionError = ( error : any ) : boolean => {
148+ return error . code === 'ECONNRESET' ||
149+ error . code === 'ECONNABORTED' ||
150+ error . code === 'ETIMEDOUT' ||
151+ error . message ?. includes ( 'socket hang up' ) ||
152+ error . message ?. includes ( 'ECONNRESET' ) ||
153+ error . message ?. includes ( 'ECONNABORTED' ) ||
154+ error . message ?. includes ( 'timeout' ) ||
155+ error . response ?. status === 401 ||
156+ error . response ?. status === 403 ;
157+ } ;
158+
111159/**
112160 * Helper function to check if an error is a DNS resolution error
113161 */
@@ -136,33 +184,43 @@ async function authenticatePihole(baseUrl: string, password: string): Promise<{
136184 const now = Date . now ( ) ;
137185
138186 if ( cachedSession && cachedSession . expires > now ) {
139- // If the session is about to expire soon (within 30 seconds), don't use it
140- // This avoids potential edge cases where the session might expire during the request
141- if ( cachedSession . expires - now > 30000 ) {
187+ // If the session is about to expire soon (within 60 seconds), don't use it
188+ // This gives us more buffer to avoid edge cases where sessions expire during requests
189+ if ( cachedSession . expires - now > 60000 ) {
142190 return {
143191 sid : cachedSession . sid ,
144192 csrf : cachedSession . csrf
145193 } ;
194+ } else {
195+ // Session is expiring soon, remove it from cache
196+ console . log ( 'Pi-hole session expiring soon, removing from cache' ) ;
197+ sessionCache . delete ( cacheKey ) ;
146198 }
147199 }
148200
149201 try {
202+ console . log ( `Authenticating with Pi-hole at ${ baseUrl } ` ) ;
203+
150204 const response = await axios . post (
151205 `${ baseUrl } /api/auth` ,
152206 { password } ,
153207 {
154208 headers : {
155209 'Content-Type' : 'application/json'
156210 } ,
157- timeout : 2000
211+ timeout : 10000 // Increased timeout for authentication
158212 }
159213 ) ;
160214
161215 if ( response . data ?. session ?. valid && response . data ?. session ?. sid ) {
162216 // Calculate expiration based on validity period (in seconds) returned by the API
163217 // Default to 1800 seconds (30 minutes) if not provided, which is Pi-hole v6's default
164218 const validitySeconds = response . data . session . validity || 1800 ;
165- const expiresAt = now + ( validitySeconds * 1000 ) ;
219+ // Reduce the validity by 10% to ensure we refresh before actual expiration
220+ const adjustedValiditySeconds = Math . floor ( validitySeconds * 0.9 ) ;
221+ const expiresAt = now + ( adjustedValiditySeconds * 1000 ) ;
222+
223+ console . log ( `Pi-hole session created, expires in ${ adjustedValiditySeconds } seconds` ) ;
166224
167225 // Store the session in cache
168226 const sessionInfo : SessionInfo = {
@@ -184,17 +242,19 @@ async function authenticatePihole(baseUrl: string, password: string): Promise<{
184242 throw new Error ( 'Authentication failed: Invalid or missing session information' ) ;
185243 } catch ( error : any ) {
186244 console . error ( 'Pi-hole v6 authentication error:' , error . message ) ;
187- console . error ( 'Auth error details:' , {
188- status : error . response ?. status ,
189- statusText : error . response ?. statusText ,
190- data : error . response ?. data ,
191- headers : error . response ?. headers ,
192- stack : error . stack
193- } ) ;
194245
195246 // Clear any cached session as it might be invalid
196247 sessionCache . delete ( cacheKey ) ;
197248
249+ // Enhanced error handling for connection issues
250+ if ( error . message ?. includes ( 'socket hang up' ) || error . code === 'ECONNRESET' ) {
251+ throw {
252+ status : 503 ,
253+ message : 'Connection to Pi-hole failed (socket hang up). The Pi-hole server may be overloaded or experiencing network issues.' ,
254+ code : 'CONNECTION_ERROR'
255+ } ;
256+ }
257+
198258 // Check for rate limiting (429 Too Many Requests)
199259 if ( isRateLimitError ( error ) ) {
200260 throw {
@@ -222,6 +282,15 @@ async function authenticatePihole(baseUrl: string, password: string): Promise<{
222282 } ;
223283 }
224284
285+ // Check for timeout errors
286+ if ( error . code === 'ECONNABORTED' || error . message ?. includes ( 'timeout' ) ) {
287+ throw {
288+ status : 504 ,
289+ message : 'Authentication timeout. Pi-hole server may be slow to respond.' ,
290+ code : 'TIMEOUT_ERROR'
291+ } ;
292+ }
293+
225294 throw {
226295 status : error . response ?. status || 500 ,
227296 message : error . response ?. data ?. error ?. message || error . message || 'Authentication failed' ,
@@ -231,8 +300,8 @@ async function authenticatePihole(baseUrl: string, password: string): Promise<{
231300}
232301
233302/**
234- * Helper function to handle API calls with automatic retry on 401 errors
235- * This centralizes the logic for handling expired sessions
303+ * Helper function to handle API calls with automatic retry on connection/session errors
304+ * This centralizes the logic for handling expired sessions and connection issues
236305 */
237306async function handleApiWith401Retry (
238307 baseUrl : string ,
@@ -243,17 +312,20 @@ async function handleApiWith401Retry(
243312 retryAttempt = 0
244313) : Promise < any > {
245314 // Maximum retry attempts to prevent infinite loops
246- const MAX_RETRIES = 1 ;
315+ const MAX_RETRIES = 2 ;
247316
248317 try {
318+ // Add a small delay to prevent overwhelming Pi-hole with concurrent requests
319+ await addRequestDelay ( baseUrl ) ;
320+
249321 // First authenticate to get a session
250322 const authInfo = await authenticatePihole ( baseUrl , password ) ;
251323
252- // Prepare the request config
324+ // Prepare the request config with increased timeout for better reliability
253325 const config = {
254326 params : { sid : authInfo . sid } ,
255327 headers : { 'X-FTL-CSRF' : authInfo . csrf , 'Content-Type' : 'application/json' } ,
256- timeout : 2000
328+ timeout : 5000 // Increased from 2000ms to 5000ms for better reliability
257329 } ;
258330
259331 // Make the API request based on the method
@@ -270,19 +342,30 @@ async function handleApiWith401Retry(
270342
271343 return response ;
272344 } catch ( error : any ) {
273- // If this is a 401 error and we haven't exceeded max retries, try to re-authenticate
274- if ( error . response ?. status === 401 && retryAttempt < MAX_RETRIES ) {
275- console . log ( `Received 401 during API call to ${ endpoint } , clearing session and retrying...` ) ;
345+ console . error ( `Pi-hole API error on ${ endpoint } (attempt ${ retryAttempt + 1 } ):` , {
346+ message : error . message ,
347+ code : error . code ,
348+ status : error . response ?. status ,
349+ data : error . response ?. data
350+ } ) ;
351+
352+ // If this is a connection error (including socket hang up) and we haven't exceeded max retries
353+ if ( isConnectionError ( error ) && retryAttempt < MAX_RETRIES ) {
354+ console . log ( `Connection error detected on ${ endpoint } , clearing session and retrying...` ) ;
276355
277356 // Clear the cached session as it's likely invalid
278357 const cacheKey = getCacheKey ( baseUrl , password ) ;
279358 sessionCache . delete ( cacheKey ) ;
280359
360+ // Add a longer delay before retry to give Pi-hole time to recover
361+ const retryDelay = Math . min ( 2000 + ( retryAttempt * 1000 ) , 5000 ) ; // 2s, 3s, max 5s
362+ await new Promise ( resolve => setTimeout ( resolve , retryDelay ) ) ;
363+
281364 // Retry the request with a fresh authentication
282365 return handleApiWith401Retry ( baseUrl , password , endpoint , method , data , retryAttempt + 1 ) ;
283366 }
284367
285- // If it's not a 401 or we've exceeded retries, throw the error
368+ // If it's not a retryable error or we've exceeded retries, throw the error
286369 throw error ;
287370 }
288371}
0 commit comments