Guide technique

Concevoir des Systemes pour la Panne (Parce qu'Ils Vont Tomber en Panne)

Patterns de reponse aux pannes pour les systemes en production. Circuit breakers, strategies de retry, degradation gracieuse, dead letter queues, budgets de timeout et chaos engineering pour petites equipes.

31 mars 202614 min de lectureÉquipe d'Ingénierie Oronts

La Taxonomie des Pannes

Les systemes tombent en panne de quatre facons. Chacune necessite une reponse differente.

TypeDescriptionExempleReponse correcte
TransitoireGlitch bref, se resout tout seulTimeout reseau, reset de connexionRetry avec backoff
PermanenteCasse jusqu'a intervention humaineConfig invalide, mismatch de schemaFail fast, alerte, pas de retry
PartielleCertaines fonctionnalites marchentUn fournisseur sur trois est downDegrader gracieusement, servir ce qui marche
En cascadeUne panne en declenche d'autresSurcharge de base de donnees qui fait timeout tous les servicesCircuit breaker, delester la charge

L'erreur la plus dangereuse : traiter toutes les pannes de la meme maniere. Retenter une panne permanente gaspille des ressources. Ne pas retenter une panne transitoire degrade l'experience utilisateur. Ne pas detecter une panne en cascade fait tomber tout le systeme.

Pour la gestion des pannes specifiquement dans les systemes event-driven, consulte notre guide d'architecture event-driven. Pour les pannes de systemes IA, voir notre guide des modes de defaillance IA.

Circuit Breakers

Un circuit breaker empeche un service defaillant de faire tomber tout ce qui en depend. Quand un service en aval echoue de maniere repetee, le circuit breaker "s'ouvre" et court-circuite les requetes immediatement au lieu d'attendre des timeouts.

class CircuitBreaker {
    private failures = 0;
    private lastFailure = 0;
    private state: 'closed' | 'open' | 'half-open' = 'closed';

    constructor(
        private threshold: number = 5,      // pannes avant ouverture
        private resetTimeout: number = 30000, // ms avant de reessayer
    ) {}

    async execute<T>(fn: () => Promise<T>): Promise<T> {
        if (this.state === 'open') {
            if (Date.now() - this.lastFailure > this.resetTimeout) {
                this.state = 'half-open'; // Essayer une requete
            } else {
                throw new CircuitOpenError('Circuit is open');
            }
        }

        try {
            const result = await fn();
            this.onSuccess();
            return result;
        } catch (error) {
            this.onFailure();
            throw error;
        }
    }

    private onSuccess() {
        this.failures = 0;
        this.state = 'closed';
    }

    private onFailure() {
        this.failures++;
        this.lastFailure = Date.now();
        if (this.failures >= this.threshold) {
            this.state = 'open';
        }
    }
}

// Utilisation
const supplierBreaker = new CircuitBreaker(5, 30000);

async function checkAvailability(productId: string) {
    return supplierBreaker.execute(async () => {
        return await supplierApi.checkAvailability(productId);
    });
}

L'Erreur Classique

La plupart des equipes implementent le circuit breaker mais ne gerent pas l'etat ouvert. Quand le circuit est ouvert, qu'est-ce que l'utilisateur voit ? Une erreur 500, c'est la mauvaise reponse. La bonne reponse depend du contexte :

ContexteQuand le circuit s'ouvreComportement correct
Recherche produitUn fournisseur downAfficher les resultats des autres fournisseurs
Verification de prixService de pricing downAfficher le prix en cache avec un label "au X"
CheckoutPasserelle de paiement downMettre la commande en file, traiter plus tard
RecommandationService ML downAfficher les produits populaires a la place
Service d'imagesCDN downAfficher une image placeholder

Strategies de Retry

Tous les retries ne se valent pas. La strategie depend du type de panne.

interface RetryConfig {
    maxAttempts: number;
    strategy: 'immediate' | 'fixed' | 'exponential' | 'none';
    baseDelay: number;      // ms
    maxDelay: number;        // ms
    jitter: boolean;         // randomiser pour eviter le thundering herd
}

const RETRY_CONFIGS: Record<string, RetryConfig> = {
    // Transitoire : retry avec backoff exponentiel
    network_timeout: {
        maxAttempts: 3,
        strategy: 'exponential',
        baseDelay: 1000,     // 1s, 2s, 4s
        maxDelay: 10000,
        jitter: true,
    },
    // Rate limited : retry avec delai fixe
    rate_limited: {
        maxAttempts: 5,
        strategy: 'fixed',
        baseDelay: 5000,     // attendre 5s entre les tentatives
        maxDelay: 5000,
        jitter: false,
    },
    // Conflit de lock optimiste : retry immediat
    lock_conflict: {
        maxAttempts: 3,
        strategy: 'immediate',
        baseDelay: 0,
        maxDelay: 0,
        jitter: false,
    },
    // Panne permanente : pas de retry
    validation_error: {
        maxAttempts: 1,
        strategy: 'none',
        baseDelay: 0,
        maxDelay: 0,
        jitter: false,
    },
};

async function retryWithStrategy<T>(
    fn: () => Promise<T>,
    config: RetryConfig,
): Promise<T> {
    let lastError: Error;

    for (let attempt = 0; attempt < config.maxAttempts; attempt++) {
        try {
            return await fn();
        } catch (error) {
            lastError = error;
            if (attempt === config.maxAttempts - 1) break;
            if (config.strategy === 'none') break;

            let delay = config.baseDelay;
            if (config.strategy === 'exponential') {
                delay = Math.min(config.baseDelay * Math.pow(2, attempt), config.maxDelay);
            }
            if (config.jitter) {
                delay += Math.random() * delay * 0.5; // 0-50% de jitter
            }

            await sleep(delay);
        }
    }
    throw lastError;
}

Le jitter est critique. Sans lui, tous les clients retentent en meme temps apres une panne (thundering herd). Le jitter repartit les retries sur une fenetre de temps, reduisant le pic sur le service en cours de recuperation.

Degradation Gracieuse

Quand une dependance tombe en panne, sers ce que tu peux au lieu de planter completement.

async function getProductPage(productId: string): Promise<ProductPageData> {
    // Donnees critiques : doit reussir
    const product = await productService.getById(productId);
    if (!product) throw new NotFoundError();

    // Donnees non critiques : degrader gracieusement
    const [reviews, recommendations, availability] = await Promise.allSettled([
        reviewService.getForProduct(productId),
        recommendationService.getSimilar(productId),
        inventoryService.checkStock(productId),
    ]);

    return {
        product,
        reviews: reviews.status === 'fulfilled' ? reviews.value : [],
        recommendations: recommendations.status === 'fulfilled' ? recommendations.value : [],
        availability: availability.status === 'fulfilled'
            ? availability.value
            : { status: 'unknown', message: 'Check availability in store' },
    };
}

Promise.allSettled est la cle. Contrairement a Promise.all, elle n'echoue pas si une promesse est rejetee. Chaque resultat est resolu independamment. La page produit s'affiche avec les donnees disponibles.

Des Donnees Perimees Valent Mieux que Pas de Donnees

async function getProductPrice(productId: string): Promise<PriceInfo> {
    try {
        const livePrice = await pricingService.getPrice(productId);
        await cache.set(`price:${productId}`, livePrice, { ttl: 300 });
        return livePrice;
    } catch (error) {
        // Service de pricing down : servir le prix en cache
        const cached = await cache.get(`price:${productId}`);
        if (cached) {
            return { ...cached, stale: true, staleSince: cached.cachedAt };
        }
        // Pas de cache non plus : retourner le prix catalogue
        const product = await productService.getById(productId);
        return { price: product.listPrice, stale: true, approximate: true };
    }
}

Un prix perime du cache vaut mieux qu'une erreur 500. Un prix catalogue approximatif vaut mieux que pas de prix du tout. Aie toujours un fallback, meme s'il est moins precis.

Budgets de Timeout

Chaque operation a un budget de temps. Si le budget est depasse, echoue rapidement au lieu de faire attendre l'utilisateur indefiniment.

const TIMEOUT_BUDGETS = {
    api_request: 5000,          // 5s au total pour toute requete API
    database_query: 2000,       // 2s pour toute requete base de donnees
    external_api: 3000,         // 3s pour les appels de services externes
    llm_generation: 30000,      // 30s pour la generation IA (streaming)
    search_query: 1000,         // 1s pour la recherche
    cache_operation: 100,       // 100ms pour lecture/ecriture cache
};

async function withTimeout<T>(promise: Promise<T>, budget: number, label: string): Promise<T> {
    const timeout = new Promise<never>((_, reject) => {
        setTimeout(() => reject(new TimeoutError(`${label} exceeded ${budget}ms budget`)), budget);
    });
    return Promise.race([promise, timeout]);
}

// Utilisation
const results = await withTimeout(
    searchService.query(userQuery),
    TIMEOUT_BUDGETS.search_query,
    'product_search',
);

Protection Contre les Timeouts en Cascade

Quand le service A appelle le service B qui appelle le service C, chaque service doit soustraire son propre temps de traitement du budget restant :

API Gateway (budget de 5s)
  └── Verification auth (100ms utilise, 4.9s restant)
       └── Service produit (200ms utilise, 4.7s restant)
            └── Service pricing (timeout : 4.7s, pas les 5s d'origine)

Sans budgets en cascade, le service de pricing utilise un timeout complet de 3s meme si le gateway n'a plus que 4.7s. Si le pricing prend 3s, le gateway fait timeout avant que la reponse n'arrive, gaspillant tout le travail effectue.

Tester les Pannes

Chaos Engineering pour Petites Equipes

Tu n'as pas besoin du Chaos Monkey de Netflix pour tester la gestion des pannes. Commence par de l'injection de fautes simple :

// Middleware : injecter des pannes en staging
function chaosMiddleware(req: Request, res: Response, next: NextFunction) {
    if (process.env.NODE_ENV !== 'staging') return next();

    const chaos = req.headers['x-chaos'];
    if (chaos === 'latency') {
        setTimeout(next, 3000); // Ajouter 3s de latence
    } else if (chaos === 'error') {
        res.status(500).json({ error: 'Chaos: injected failure' });
    } else if (chaos === 'timeout') {
        // Ne pas repondre du tout (simuler un service bloque)
    } else {
        next();
    }
}

Scenarios de test qui comptent :

ScenarioComment testerQuoi verifier
Base de donnees downArreter la base de donnees en stagingLe circuit breaker s'ouvre, les donnees en cache sont servies
Dependance lenteInjecter 5s de latenceLe timeout se declenche, une reponse degradee est retournee
File d'attente pleineRemplir la file avec des messages de testLa backpressure s'applique, pas de perte de donnees
Pression memoireLimiter la memoire du conteneurGestion OOM, redemarrage gracieux
Expiration de certificatUtiliser un certificat court en stagingL'alerte se declenche avant l'expiration

Pieges Courants

  1. Meme strategie de retry pour toutes les pannes. Un timeout a besoin de backoff. Un input invalide a besoin de zero retries. Un rate limit a besoin d'un delai fixe.

  2. Circuit breaker sans fallback. Ouvrir le circuit et retourner 500, ce n'est pas de la tolerance aux pannes. Sers des donnees en cache, des resultats degrades ou une reponse mise en file.

  3. Pas de jitter sur les retries. Tous les clients retentent en meme temps, submergeant le service en cours de recuperation. Ajoute du jitter aleatoire.

  4. Timeouts infinis. Une requete qui attend indefiniment bloque une connexion et un thread. Chaque operation a besoin d'un budget de timeout.

  5. Tester uniquement le happy path. Si tu n'as jamais teste ce qui se passe quand la base de donnees est down, tu ne sais pas si tes fallbacks fonctionnent.

  6. Pannes en cascade a cause de dependances partagees. Si les services A, B et C dependent tous de la meme base de donnees, et que cette base est lente, les trois services deviennent lents. Des circuit breakers sur les dependances partagees empechent la cascade.

Points Cles

  • Classifie les pannes avant de reagir. Transitoire (retry), permanente (fail fast), partielle (degrader), en cascade (circuit break). Chaque type necessite une strategie differente.

  • Les circuit breakers ont besoin de fallbacks. Ouvrir le circuit n'est pas la solution. Servir des donnees en cache, des resultats alternatifs ou des reponses mises en file, ca c'est la solution.

  • Le jitter empeche le thundering herd. Randomise les delais de retry. Sans jitter, les retries synchronises rendent la recuperation plus difficile.

  • Des donnees perimees valent mieux que pas de donnees. Un prix en cache d'il y a 5 minutes vaut mieux qu'une erreur 500. Aie toujours un chemin de fallback.

  • Les budgets de timeout sont en cascade. Chaque service dans la chaine soustrait son temps de traitement du budget restant. Ne laisse pas les services internes utiliser plus de temps que le service externe n'en a.

  • Teste les pannes en staging. De l'injection de fautes simple (latence, erreurs, connexions bloquees) verifie que tes patterns de resilience fonctionnent vraiment.

Nous concevons des systemes resilients dans le cadre de nos pratiques de logiciels sur mesure et cloud. Si tu as besoin d'aide en ingenierie de fiabilite, parle a notre equipe ou demande un devis.

Sujets couverts

patterns de resiliencecircuit breakerstrategie de retrydegradation gracieusefiabilite systemebudget de timeoutchaos engineeringdead letter queue

Prêt à construire des systèmes IA prêts pour la production ?

Notre équipe est spécialisée dans les systèmes IA prêts pour la production. Discutons de comment nous pouvons aider.

Démarrer une conversation