Building Reliable AI Pipelines: Lessons from 10M Daily Requests
Back to all articles
AI Engineering
24 min read11 min read

Building Reliable AI Pipelines: Lessons from 10M Daily Requests

Circuit breakers, graceful degradation, and the observability patterns that kept our AI system running at 99.9% uptime despite upstream failures.

Debasish Maji
Debasish Maji
AI Engineering Lead
April 1, 2026
ReliabilityProductionInfrastructureObservabilityError Handling

The Day OpenAI Went Down (And Our App Didn't)

March 15, 2025. 3:47 PM EST. OpenAI's API started returning 503 errors. Twitter exploded with developers sharing screenshots of their broken apps.

Our AI-powered document processing system? It kept running. Degraded, but running. Users saw slightly slower responses and occasionally fell back to simpler processing, but no one lost work.

This post documents the reliability patterns we built over 18 months of scaling from 10K to 10M daily AI requests. These aren't theoretical patterns - they're battle-tested techniques that have saved us during multiple upstream outages.

•••

The Reliability Stack

Our reliability architecture has four layers:

LayerPurposeKey Patterns
Circuit BreakersPrevent cascade failuresTrip on errors, auto-recover
Fallback ChainGraceful degradationPrimary > Secondary > Cache > Default
Request QueueHandle burst trafficPriority queues, backpressure
ObservabilityKnow what's happeningMetrics, tracing, alerting
Let me walk through each layer with the actual code we use.

•••

Layer 1: Circuit Breakers

A circuit breaker prevents your system from repeatedly calling a failing service. It's like an electrical circuit breaker - when too much "current" (errors) flows through, it trips.

The Three States

  1. 1Closed: Normal operation, requests flow through
  2. 2Open: Failures exceeded threshold, requests blocked
  3. 3Half-Open: Testing if service recovered

Here's our implementation:

Typescript
interface CircuitBreakerConfig {
  failureThreshold: number;    // Failures before opening
  successThreshold: number;    // Successes to close from half-open
  timeout: number;             // Time before trying half-open
  monitorWindow: number;       // Window for counting failures
}

class CircuitBreaker {
  private state: 'closed' | 'open' | 'half-open' = 'closed';
  private failures: number[] = [];
  private successes: number = 0;
  private lastFailure: number = 0;
  
  constructor(
    private name: string,
    private config: CircuitBreakerConfig
  ) {}
  
  async execute<T>(fn: () => Promise<T>): Promise<T> {
    // Check if we should try half-open
    if (this.state === 'open') {
      if (Date.now() - this.lastFailure > this.config.timeout) {
        this.state = 'half-open';
        this.successes = 0;
        metrics.circuitStateChange(this.name, 'half-open');
      } else {
        throw new CircuitOpenError(this.name);
      }
    }
    
    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }
  
  private onSuccess(): void {
    if (this.state === 'half-open') {
      this.successes++;
      if (this.successes >= this.config.successThreshold) {
        this.state = 'closed';
        this.failures = [];
        metrics.circuitStateChange(this.name, 'closed');
      }
    }
  }
  
  private onFailure(): void {
    const now = Date.now();
    this.lastFailure = now;
    
    // Remove old failures outside the monitor window
    this.failures = this.failures.filter(
      t => now - t < this.config.monitorWindow
    );
    this.failures.push(now);
    
    if (this.state === 'half-open') {
      // Single failure in half-open reopens the circuit
      this.state = 'open';
      metrics.circuitStateChange(this.name, 'open');
    } else if (this.failures.length >= this.config.failureThreshold) {
      this.state = 'open';
      metrics.circuitStateChange(this.name, 'open');
    }
  }
  
  getState(): string {
    return this.state;
  }
}

How We Configure Breakers

Different services need different thresholds:

ServiceFailure ThresholdTimeoutMonitor Window
OpenAI GPT-45 failures30s60s
OpenAI GPT-3.510 failures15s60s
Embedding API8 failures20s60s
Vector DB3 failures10s30s
The rationale:
  • GPT-4: Expensive and slow - trip fast to save costs
  • GPT-3.5: More tolerant - often used as fallback
  • Vector DB: Critical for retrieval - aggressive recovery
•••

Layer 2: Fallback Chain

When the primary service fails, we need alternatives. Our fallback chain has four levels:

Typescript
interface FallbackConfig {
  primary: () => Promise<AIResponse>;
  secondary?: () => Promise<AIResponse>;
  cached?: () => Promise<AIResponse | null>;
  default?: () => AIResponse;
}

async function executeWithFallbacks(
  config: FallbackConfig,
  context: RequestContext
): Promise<AIResponse> {
  const attempts: FallbackAttempt[] = [];
  
  // Try primary
  try {
    const result = await config.primary();
    metrics.fallbackUsed(context.requestId, 'primary');
    return result;
  } catch (error) {
    attempts.push({ level: 'primary', error });
    logger.warn('Primary failed, trying secondary', { error, context });
  }
  
  // Try secondary
  if (config.secondary) {
    try {
      const result = await config.secondary();
      metrics.fallbackUsed(context.requestId, 'secondary');
      return { ...result, degraded: true };
    } catch (error) {
      attempts.push({ level: 'secondary', error });
      logger.warn('Secondary failed, trying cache', { error, context });
    }
  }
  
  // Try cache
  if (config.cached) {
    try {
      const result = await config.cached();
      if (result) {
        metrics.fallbackUsed(context.requestId, 'cached');
        return { ...result, degraded: true, fromCache: true };
      }
    } catch (error) {
      attempts.push({ level: 'cached', error });
    }
  }
  
  // Return default
  if (config.default) {
    metrics.fallbackUsed(context.requestId, 'default');
    return { ...config.default(), degraded: true, isDefault: true };
  }
  
  // All fallbacks failed
  throw new AllFallbacksFailedError(attempts);
}

Real Example: Document Summarization

Here's how we configure fallbacks for document summarization:

Typescript
async function summarizeDocument(doc: Document): Promise<Summary> {
  return executeWithFallbacks({
    // Primary: GPT-4 for best quality
    primary: async () => {
      return circuitBreakers.gpt4.execute(() =>
        openai.chat.completions.create({
          model: 'gpt-4-turbo',
          messages: [
            { role: 'system', content: 'Summarize this document concisely.' },
            { role: 'user', content: doc.content }
          ],
          max_tokens: 500
        })
      );
    },
    
    // Secondary: GPT-3.5 (faster, cheaper, slightly lower quality)
    secondary: async () => {
      return circuitBreakers.gpt35.execute(() =>
        openai.chat.completions.create({
          model: 'gpt-3.5-turbo',
          messages: [
            { role: 'system', content: 'Summarize this document concisely.' },
            { role: 'user', content: doc.content }
          ],
          max_tokens: 500
        })
      );
    },
    
    // Cached: Return previous summary if available
    cached: async () => {
      const cached = await cache.getSummary(doc.id);
      if (cached && cached.age < 24 * 60 * 60 * 1000) {
        return cached.summary;
      }
      return null;
    },
    
    // Default: Extractive summary (no AI needed)
    default: () => ({
      text: extractFirstParagraphs(doc.content, 3),
      type: 'extractive',
      confidence: 0.3
    })
  }, { requestId: generateId(), docId: doc.id });
}

Fallback Success Rates

From our production data:

ScenarioPrimarySecondaryCacheDefault
Normal operation99.2%0.6%0.1%0.1%
Minor outage45%52%2%1%
Major outage0%15%35%50%
Key insight: Cache is more valuable during outages than you expect. We increased our cache TTL from 1 hour to 24 hours specifically for reliability.

•••

Layer 3: Request Queue with Backpressure

When traffic spikes or services slow down, you need a buffer. Our queue system handles this:

Typescript
interface QueueConfig {
  maxSize: number;
  maxWait: number;
  priorityLevels: number;
}

class PriorityRequestQueue {
  private queues: Map<number, RequestItem[]> = new Map();
  private processing: number = 0;
  private maxConcurrent: number;
  
  constructor(
    private config: QueueConfig,
    maxConcurrent: number
  ) {
    this.maxConcurrent = maxConcurrent;
    for (let i = 0; i < config.priorityLevels; i++) {
      this.queues.set(i, []);
    }
  }
  
  async enqueue<T>(
    priority: number,
    fn: () => Promise<T>,
    timeout?: number
  ): Promise<T> {
    const totalSize = this.getTotalSize();
    
    // Backpressure: reject if queue is full
    if (totalSize >= this.config.maxSize) {
      metrics.queueRejection(priority);
      throw new QueueFullError(totalSize, this.config.maxSize);
    }
    
    return new Promise((resolve, reject) => {
      const item: RequestItem = {
        fn,
        resolve,
        reject,
        priority,
        enqueuedAt: Date.now(),
        timeout: timeout || this.config.maxWait
      };
      
      this.queues.get(priority)!.push(item);
      metrics.queueSize(this.getTotalSize());
      
      this.processNext();
    });
  }
  
  private async processNext(): Promise<void> {
    if (this.processing >= this.maxConcurrent) {
      return;
    }
    
    const item = this.getHighestPriorityItem();
    if (!item) {
      return;
    }
    
    // Check if item has timed out while waiting
    const waitTime = Date.now() - item.enqueuedAt;
    if (waitTime > item.timeout) {
      metrics.queueTimeout(item.priority, waitTime);
      item.reject(new QueueTimeoutError(waitTime));
      this.processNext();
      return;
    }
    
    this.processing++;
    metrics.queueProcessing(this.processing);
    
    try {
      const result = await item.fn();
      item.resolve(result);
    } catch (error) {
      item.reject(error);
    } finally {
      this.processing--;
      metrics.queueProcessing(this.processing);
      this.processNext();
    }
  }
  
  private getHighestPriorityItem(): RequestItem | null {
    for (let i = 0; i < this.config.priorityLevels; i++) {
      const queue = this.queues.get(i)!;
      if (queue.length > 0) {
        return queue.shift()!;
      }
    }
    return null;
  }
  
  private getTotalSize(): number {
    let total = 0;
    this.queues.forEach(q => total += q.length);
    return total;
  }
}

Priority Levels

We use four priority levels:

PriorityUse CaseMax Wait% of Traffic
0 (Highest)Real-time chat5s15%
1Interactive requests15s40%
2Background processing60s35%
3 (Lowest)Batch jobs300s10%

Backpressure in Action

During a traffic spike last month:

  • Normal traffic: 150 req/s
  • Spike traffic: 890 req/s (6x normal)
  • Queue handled: 750 req/s
  • Rejected (backpressure): 140 req/s

Without the queue, we would have overwhelmed OpenAI's rate limits and gotten all requests throttled. With the queue, 84% of spike traffic was handled successfully.

•••

Layer 4: Observability

You can't fix what you can't see. Our observability stack:

Metrics We Track

Typescript
const aiMetrics = {
  // Latency
  requestDuration: new Histogram({
    name: 'ai_request_duration_seconds',
    help: 'AI request duration',
    labelNames: ['model', 'operation', 'status'],
    buckets: [0.1, 0.5, 1, 2, 5, 10, 30]
  }),
  
  // Tokens
  tokensUsed: new Counter({
    name: 'ai_tokens_total',
    help: 'Total tokens used',
    labelNames: ['model', 'type'] // type: input/output
  }),
  
  // Errors
  errors: new Counter({
    name: 'ai_errors_total',
    help: 'AI errors by type',
    labelNames: ['model', 'error_type', 'retryable']
  }),
  
  // Circuit breaker
  circuitState: new Gauge({
    name: 'ai_circuit_state',
    help: 'Circuit breaker state (0=closed, 1=half-open, 2=open)',
    labelNames: ['service']
  }),
  
  // Fallbacks
  fallbackUsage: new Counter({
    name: 'ai_fallback_total',
    help: 'Fallback usage by level',
    labelNames: ['operation', 'level']
  }),
  
  // Queue
  queueSize: new Gauge({
    name: 'ai_queue_size',
    help: 'Current queue size',
    labelNames: ['priority']
  }),
  
  // Cost
  estimatedCost: new Counter({
    name: 'ai_estimated_cost_dollars',
    help: 'Estimated cost in dollars',
    labelNames: ['model', 'operation']
  })
};

Alerting Rules

Our key alerts:

Yaml
# High error rate
- alert: AIHighErrorRate
  expr: rate(ai_errors_total[5m]) / rate(ai_request_duration_seconds_count[5m]) > 0.05
  for: 2m
  labels:
    severity: warning
  annotations:
    summary: "AI error rate above 5%"

# Circuit breaker opened
- alert: AICircuitOpen
  expr: ai_circuit_state > 1
  for: 1m
  labels:
    severity: critical
  annotations:
    summary: "Circuit breaker opened for {{ $labels.service }}"

# Queue backing up
- alert: AIQueueBacklog
  expr: ai_queue_size > 1000
  for: 5m
  labels:
    severity: warning
  annotations:
    summary: "AI request queue has {{ $value }} items"

# Cost spike
- alert: AICostSpike
  expr: rate(ai_estimated_cost_dollars[1h]) > 100
  for: 15m
  labels:
    severity: warning
  annotations:
    summary: "AI costs exceeding $100/hour"

The Dashboard That Matters

We have dozens of dashboards, but this is the one we actually use during incidents:

PanelMetricPurpose
Request Ratereq/s by modelTraffic distribution
Error Rateerrors/s by typeWhat's breaking
P50/P95/P99 LatencyDuration by modelPerformance
Circuit StatesState by serviceSystem health
Fallback Distribution% by levelDegradation level
Queue DepthItems by priorityBackpressure
Cost Rate$/hourBudget tracking
•••

Putting It All Together

Here's how a request flows through our system:

Typescript
async function processAIRequest(
  request: AIRequest
): Promise<AIResponse> {
  const context = createRequestContext(request);
  const span = tracer.startSpan('ai.process', { attributes: context });
  
  try {
    // 1. Enqueue with priority
    const priority = getPriority(request);
    
    return await requestQueue.enqueue(priority, async () => {
      // 2. Execute with fallbacks
      return await executeWithFallbacks({
        primary: () => circuitBreakers.gpt4.execute(() => 
          callGPT4(request)
        ),
        secondary: () => circuitBreakers.gpt35.execute(() => 
          callGPT35(request)
        ),
        cached: () => getFromCache(request),
        default: () => getDefaultResponse(request)
      }, context);
    }, getTimeout(priority));
    
  } catch (error) {
    // 3. Handle errors appropriately
    if (error instanceof CircuitOpenError) {
      metrics.errors.inc({ error_type: 'circuit_open', retryable: 'true' });
      throw new ServiceUnavailableError('AI service temporarily unavailable');
    }
    
    if (error instanceof QueueFullError) {
      metrics.errors.inc({ error_type: 'queue_full', retryable: 'true' });
      throw new TooManyRequestsError('System at capacity, please retry');
    }
    
    if (error instanceof QueueTimeoutError) {
      metrics.errors.inc({ error_type: 'timeout', retryable: 'true' });
      throw new TimeoutError('Request timed out in queue');
    }
    
    throw error;
  } finally {
    span.end();
  }
}
•••

Results: The Numbers

After implementing this reliability stack:

MetricBeforeAfterImprovement
Uptime99.1%99.94%10x fewer incidents
MTTR45 min8 min5.6x faster recovery
User-facing errors2.3%0.12%19x reduction
Revenue lost to outages~$45K/month~$3K/month93% reduction
During the March 2025 OpenAI outage (47 minutes):
  • Requests handled: 284,000
  • Primary success: 0%
  • Secondary success: 31%
  • Cache hits: 42%
  • Default responses: 27%
  • User complaints: 3 (down from 200+ in previous outages)
•••

Lessons Learned

  1. 1Circuit breakers are non-negotiable - Without them, one failing service takes down everything.
  1. 2Fallbacks need to be fast - A slow fallback isn't a fallback, it's just a different failure mode.
  1. 3Cache more aggressively than you think - During outages, stale data beats no data.
  1. 4Priority queues save your VIPs - Not all requests are equal. Treat real-time differently from batch.
  1. 5Observe everything, alert selectively - Metric everything, but only alert on actionable issues.
  1. 6Test your fallbacks - We run "chaos engineering" sessions monthly where we deliberately fail primary services.
  1. 7Document your degraded states - Users accept degradation better when you tell them what's happening.

The reliability stack isn't glamorous, but it's the difference between "our AI feature is down" and "our AI feature is slightly slower today." Your users (and your on-call engineers) will thank you.

Found this helpful?

Share it with others who might benefit

TweetShare

Related articles