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:
| Layer | Purpose | Key Patterns |
|---|---|---|
| Circuit Breakers | Prevent cascade failures | Trip on errors, auto-recover |
| Fallback Chain | Graceful degradation | Primary > Secondary > Cache > Default |
| Request Queue | Handle burst traffic | Priority queues, backpressure |
| Observability | Know what's happening | Metrics, tracing, alerting |
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
- 1Closed: Normal operation, requests flow through
- 2Open: Failures exceeded threshold, requests blocked
- 3Half-Open: Testing if service recovered
Here's our implementation:
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:
| Service | Failure Threshold | Timeout | Monitor Window |
|---|---|---|---|
| OpenAI GPT-4 | 5 failures | 30s | 60s |
| OpenAI GPT-3.5 | 10 failures | 15s | 60s |
| Embedding API | 8 failures | 20s | 60s |
| Vector DB | 3 failures | 10s | 30s |
- •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:
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:
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:
| Scenario | Primary | Secondary | Cache | Default |
|---|---|---|---|---|
| Normal operation | 99.2% | 0.6% | 0.1% | 0.1% |
| Minor outage | 45% | 52% | 2% | 1% |
| Major outage | 0% | 15% | 35% | 50% |
Layer 3: Request Queue with Backpressure
When traffic spikes or services slow down, you need a buffer. Our queue system handles this:
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:
| Priority | Use Case | Max Wait | % of Traffic |
|---|---|---|---|
| 0 (Highest) | Real-time chat | 5s | 15% |
| 1 | Interactive requests | 15s | 40% |
| 2 | Background processing | 60s | 35% |
| 3 (Lowest) | Batch jobs | 300s | 10% |
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
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:
# 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:
| Panel | Metric | Purpose |
|---|---|---|
| Request Rate | req/s by model | Traffic distribution |
| Error Rate | errors/s by type | What's breaking |
| P50/P95/P99 Latency | Duration by model | Performance |
| Circuit States | State by service | System health |
| Fallback Distribution | % by level | Degradation level |
| Queue Depth | Items by priority | Backpressure |
| Cost Rate | $/hour | Budget tracking |
Putting It All Together
Here's how a request flows through our system:
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:
| Metric | Before | After | Improvement |
|---|---|---|---|
| Uptime | 99.1% | 99.94% | 10x fewer incidents |
| MTTR | 45 min | 8 min | 5.6x faster recovery |
| User-facing errors | 2.3% | 0.12% | 19x reduction |
| Revenue lost to outages | ~$45K/month | ~$3K/month | 93% reduction |
- •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
- 1Circuit breakers are non-negotiable - Without them, one failing service takes down everything.
- 2Fallbacks need to be fast - A slow fallback isn't a fallback, it's just a different failure mode.
- 3Cache more aggressively than you think - During outages, stale data beats no data.
- 4Priority queues save your VIPs - Not all requests are equal. Treat real-time differently from batch.
- 5Observe everything, alert selectively - Metric everything, but only alert on actionable issues.
- 6Test your fallbacks - We run "chaos engineering" sessions monthly where we deliberately fail primary services.
- 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.
