At bygge robuste API'er: Mønstre til produktion

Introduktion

API’er fejler. Netværk er upålidelige, afhængigheder går ned, og uventet belastning sker. Forskellen mellem en god API og en fremragende API er, hvordan den håndterer disse fejl.

Denne guide gennemgår praktiske mønstre, jeg har brugt til at bygge robuste API’er i produktion.

Circuit breaker-mønstret

Problemet

Når en efterfølgende service fejler, medfører fortsatte kald:

  • Ressourcespild
  • Øget latens
  • Kan forårsage kaskadefejl

Løsningen

En circuit breaker overvåger fejl og “åbner”, når en grænseværdi nås, og returnerer straks fejl i stedet for at kalde den fejlende service.

class CircuitBreaker {
  private state: 'closed' | 'open' | 'half-open' = 'closed';
  private failureCount = 0;
  private lastFailureTime?: number;

  async execute<T>(fn: () => Promise<T>): Promise<T> {
    if (this.state === 'open') {
      if (this.shouldAttemptReset()) {
        this.state = 'half-open';
      } else {
        throw new Error('Circuit breaker is open');
      }
    }

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

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

  private onFailure() {
    this.failureCount++;
    this.lastFailureTime = Date.now();
    
    if (this.failureCount >= FAILURE_THRESHOLD) {
      this.state = 'open';
    }
  }

  private shouldAttemptReset(): boolean {
    return Date.now() - this.lastFailureTime! > RESET_TIMEOUT;
  }
}

Reel effekt

Efter implementering af circuit breakers til vores integrationer med betalingsudbydere:

  • 90 % reduktion i kaskadefejl
  • Forbedrede API-svartider under udfald hos udbydere
  • Bedre indsigt i afhængighedernes sundhedstilstand

Genforsøg med eksponentiel backoff

Problemet

Forbigående fejl er almindelige (netværksudsving, midlertidig overbelastning). Øjeblikkelige genforsøg kan forværre situationen.

Løsningen

Genforsøg med eksponentielt stigende forsinkelser plus jitter for at undgå en “thundering herd”-effekt.

async function retryWithBackoff<T>(
  fn: () => Promise<T>,
  maxRetries = 3
): Promise<T> {
  let lastError: Error;
  
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error;
      
      if (attempt < maxRetries - 1) {
        const delay = Math.min(
          1000 * Math.pow(2, attempt) + Math.random() * 1000,
          10000
        );
        await sleep(delay);
      }
    }
  }
  
  throw lastError!;
}

Hvornår man skal forsøge igen

Ikke alle fejl bør medføre genforsøg:

  • ✅ Netværkstimeouts
  • ✅ 503 Service Unavailable
  • ✅ 429 Too Many Requests
  • ❌ 400 Bad Request
  • ❌ 401 Unauthorized
  • ❌ 404 Not Found

Timeouts

Problemet

Uden timeouts kan en langsom afhængighed blokere hele din API.

Løsningen

Sæt aggressive timeouts, og fejl hurtigt.

async function withTimeout<T>(
  promise: Promise<T>,
  timeoutMs: number
): Promise<T> {
  const timeout = new Promise<never>((_, reject) => {
    setTimeout(() => reject(new Error('Timeout')), timeoutMs);
  });
  
  return Promise.race([promise, timeout]);
}

Valg af timeout-værdier

  • P95-latens + buffer: Hvis P95 er 200 ms, sæt timeout til 500 ms
  • Overvej kaskaderende timeouts: Hvert lag bør have en kortere timeout end laget over
  • Overvåg og justér: Brug målinger til at finjustere timeout-værdierne

Graceful degradation

Problemet

Når en ikke-kritisk afhængighed fejler, skal hele din API så fejle?

Løsningen

Identificér kritiske vs. ikke-kritiske afhængigheder, og degradér elegant.

async function getUserProfile(userId: string) {
  const [user, preferences, recommendations] = await Promise.allSettled([
    fetchUser(userId),           // Kritisk
    fetchPreferences(userId),    // Ikke-kritisk
    fetchRecommendations(userId) // Ikke-kritisk
  ]);

  if (user.status === 'rejected') {
    throw new Error('Failed to fetch user');
  }

  return {
    user: user.value,
    preferences: preferences.status === 'fulfilled' 
      ? preferences.value 
      : null,
    recommendations: recommendations.status === 'fulfilled'
      ? recommendations.value
      : []
  };
}

Rate limiting

Problemet

Ubegrænsede forespørgsler kan overvælde din API og efterfølgende services.

Løsningen

Implementér rate limiting på flere niveauer:

  1. Grænser pr. bruger: Forhindr individuelle brugere i at overvælde systemet
  2. Globale grænser: Beskyt den samlede systemkapacitet
  3. Afhængighedsgrænser: Respektér grænser hos efterfølgende services
class RateLimiter {
  private requests = new Map<string, number[]>();

  async checkLimit(key: string, limit: number, windowMs: number): Promise<boolean> {
    const now = Date.now();
    const windowStart = now - windowMs;
    
    const requests = this.requests.get(key) || [];
    const recentRequests = requests.filter(time => time > windowStart);
    
    if (recentRequests.length >= limit) {
      return false;
    }
    
    recentRequests.push(now);
    this.requests.set(key, recentRequests);
    return true;
  }
}

Overvågning og observability

Væsentlige målinger

Følg disse målinger for hvert API-endpoint:

  • Forespørgselsrate
  • Fejlrate
  • Latens (P50, P95, P99)
  • Afhængighedernes sundhedstilstand

Struktureret logging

Log nok kontekst til at kunne fejlsøge problemer:

logger.info('Payment processed', {
  userId,
  paymentId,
  amount,
  provider,
  duration: Date.now() - startTime,
  success: true
});

Konklusion

At bygge robuste API’er kræver, at man tænker over fejltilstande på forhånd. Mønstrene gennemgået her - circuit breakers, genforsøg, timeouts, graceful degradation og rate limiting - udgør et solidt fundament.

Husk: fejl vil ske. Målet er at håndtere dem elegant og bevare en god brugeroplevelse, selv når noget går galt.

Videre læsning