import type { AuthConfig } from "@/types/auth"; import type { CacheType } from "@/types/cache"; import { getServerCacheService } from "./server-cache.service"; import { ConfigDBService } from "./config-db.service"; import { ERROR_CODES } from "../../constants/errorCodes"; import { AppError } from "../../utils/errors"; import type { KomgaConfig } from "@/types/komga"; import type { ServerCacheService } from "./server-cache.service"; import { RequestMonitorService } from "./request-monitor.service"; import { RequestQueueService } from "./request-queue.service"; import { CircuitBreakerService } from "./circuit-breaker.service"; import { PreferencesService } from "./preferences.service"; import logger from "@/lib/logger"; export type { CacheType }; interface KomgaRequestInit extends RequestInit { isImage?: boolean; noJson?: boolean; } interface KomgaUrlBuilder { path: string; params?: Record; } export abstract class BaseApiService { private static requestQueueInitialized = false; private static circuitBreakerInitialized = false; /** * Initialise le RequestQueueService avec les préférences de l'utilisateur */ private static async initializeRequestQueue(): Promise { if (this.requestQueueInitialized) { return; } try { // Configurer le getter qui récupère dynamiquement la valeur depuis les préférences RequestQueueService.setMaxConcurrentGetter(async () => { try { const preferences = await PreferencesService.getPreferences(); return preferences.komgaMaxConcurrentRequests; } catch (error) { logger.error({ err: error }, "Failed to get preferences for request queue"); return 5; // Valeur par défaut } }); this.requestQueueInitialized = true; } catch (error) { logger.error({ err: error }, "Failed to initialize request queue"); } } /** * Initialise le CircuitBreakerService avec les préférences de l'utilisateur */ private static async initializeCircuitBreaker(): Promise { if (this.circuitBreakerInitialized) { return; } try { // Configurer le getter qui récupère dynamiquement la config depuis les préférences CircuitBreakerService.setConfigGetter(async () => { try { const preferences = await PreferencesService.getPreferences(); return preferences.circuitBreakerConfig; } catch (error) { logger.error({ err: error }, "Failed to get preferences for circuit breaker"); return { threshold: 5, timeout: 30000, resetTimeout: 60000, }; } }); this.circuitBreakerInitialized = true; } catch (error) { logger.error({ err: error }, "Failed to initialize circuit breaker"); } } protected static async getKomgaConfig(): Promise { // Initialiser les services si ce n'est pas déjà fait await Promise.all([this.initializeRequestQueue(), this.initializeCircuitBreaker()]); try { const config: KomgaConfig | null = await ConfigDBService.getConfig(); if (!config) { throw new AppError(ERROR_CODES.KOMGA.MISSING_CONFIG); } return { serverUrl: config.url, authHeader: config.authHeader, }; } catch (error) { if (error instanceof AppError && error.code === ERROR_CODES.KOMGA.MISSING_CONFIG) { throw error; } logger.error({ err: error }, "Erreur lors de la récupération de la configuration"); throw new AppError(ERROR_CODES.KOMGA.MISSING_CONFIG, {}, error); } } protected static getAuthHeaders(config: AuthConfig): Headers { if (!config.authHeader) { throw new AppError(ERROR_CODES.KOMGA.MISSING_CREDENTIALS); } return new Headers({ Authorization: `Basic ${config.authHeader}`, Accept: "application/json", }); } protected static async fetchWithCache( key: string, fetcher: () => Promise, type: CacheType = "DEFAULT" ): Promise { const cacheService: ServerCacheService = await getServerCacheService(); try { const result = await cacheService.getOrSet(key, fetcher, type); return result; } catch (error) { throw error; } } protected static buildUrl( config: AuthConfig, path: string, params?: Record ): string { const url = new URL(`${config.serverUrl}/api/v1/${path}`); if (params) { Object.entries(params).forEach(([key, value]) => { if (value !== undefined) { url.searchParams.append(key, value); } }); } return url.toString(); } protected static async resolveWithFallback(url: string): Promise { // DNS resolution is only needed server-side and causes build issues // The fetch API will handle DNS resolution automatically return url; } protected static async fetchFromApi( urlBuilder: KomgaUrlBuilder, headersOptions = {}, options: KomgaRequestInit = {} ): Promise { const config: AuthConfig = await this.getKomgaConfig(); const { path, params } = urlBuilder; const url = await this.resolveWithFallback(this.buildUrl(config, path, params)); const headers: Headers = this.getAuthHeaders(config); if (headersOptions) { for (const [key, value] of Object.entries(headersOptions)) { headers.set(key as string, value as string); } } const isDebug = process.env.KOMGA_DEBUG === "true"; const startTime = isDebug ? Date.now() : 0; if (isDebug) { const queueStats = { active: RequestQueueService.getActiveCount(), queued: RequestQueueService.getQueueLength(), }; logger.info( { url, method: options.method || "GET", params, isImage: options.isImage, noJson: options.noJson, queue: queueStats, }, "🔵 Komga Request" ); } // Timeout réduit à 15 secondes pour éviter les blocages longs const timeoutMs = 15000; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { // Utiliser le circuit breaker pour éviter de surcharger Komga const response = await CircuitBreakerService.execute(async () => { // Enqueue la requête pour limiter la concurrence return await RequestQueueService.enqueue(async () => { try { return await fetch(url, { headers, ...options, signal: controller.signal, // Configure undici connection timeouts // @ts-ignore - undici-specific options not in standard fetch types connectTimeout: timeoutMs, bodyTimeout: timeoutMs, headersTimeout: timeoutMs, }); } catch (fetchError: any) { // Gestion spécifique des erreurs DNS if (fetchError?.cause?.code === "EAI_AGAIN" || fetchError?.code === "EAI_AGAIN") { logger.error( `DNS resolution failed for ${url}. Retrying with different DNS settings...` ); // Retry avec des paramètres DNS différents return await fetch(url, { headers, ...options, signal: controller.signal, // @ts-ignore - undici-specific options connectTimeout: timeoutMs, bodyTimeout: timeoutMs, headersTimeout: timeoutMs, // Force IPv4 si IPv6 pose problème // @ts-ignore family: 4, }); } // Retry automatique sur timeout de connexion (cold start) if (fetchError?.cause?.code === "UND_ERR_CONNECT_TIMEOUT") { logger.info(`⏱️ Connection timeout for ${url}. Retrying once (cold start)...`); return await fetch(url, { headers, ...options, signal: controller.signal, // @ts-ignore - undici-specific options connectTimeout: timeoutMs, bodyTimeout: timeoutMs, headersTimeout: timeoutMs, }); } throw fetchError; } }); }); clearTimeout(timeoutId); if (isDebug) { const duration = Date.now() - startTime; logger.info( { url, status: response.status, duration: `${duration}ms`, ok: response.ok, }, "🟢 Komga Response" ); } if (!response.ok) { if (isDebug) { logger.error( { url, status: response.status, statusText: response.statusText, }, "🔴 Komga Error Response" ); } throw new AppError(ERROR_CODES.KOMGA.HTTP_ERROR, { status: response.status, statusText: response.statusText, }); } if (options.isImage) { return response as T; } if (options.noJson) { return undefined as T; } return response.json(); } catch (error) { if (isDebug) { const duration = Date.now() - startTime; logger.error( { url, error: error instanceof Error ? error.message : String(error), duration: `${duration}ms`, }, "🔴 Komga Request Failed" ); } throw error; } finally { clearTimeout(timeoutId); RequestMonitorService.decrementActive(); } } }