Features: - Zero configuration, just works out of the box - Runtime type inference and validation - Built-in caching with W-TinyLFU algorithm - Automatic retries with exponential backoff - Circuit breaker for resilience - Request deduplication - Offline support with queue - OpenAPI schema discovery - Full TypeScript support with type descriptors - Modular architecture - Configurable for advanced use cases Built with bun, ready for npm publishing
28 KiB
Chapter 8: Interceptors & Middleware
"Every request tells a story. Interceptors let you edit it."
The Authentication Nightmare
Sarah's Weather Buddy was type-safe, cached, and error-resistant. But then came the new requirement: user authentication for premium features.
"Every API call needs an auth token now," her PM announced. "And we need to track all requests for analytics. Oh, and can you add request signing for the payment endpoints?"
Sarah's heart sank. Would she have to modify every single API call?
"Not with interceptors," Marcus said, appearing with coffee. "Let me show you the power of middleware."
Understanding Interceptors: The Request Pipeline
Think of interceptors like airport security. Every request passes through checkpoints where you can inspect, modify, or reject it:
// Without interceptors - repetitive and error-prone
const token = getAuthToken()
const { data } = await tf.get('/api/user', {
headers: {
'Authorization': `Bearer ${token}`,
'X-Request-ID': generateId(),
'X-Client-Version': APP_VERSION
}
})
logRequest('/api/user')
// With interceptors - clean and automatic
tf.addRequestInterceptor(config => ({
...config,
headers: {
...config.headers,
'Authorization': `Bearer ${getAuthToken()}`,
'X-Request-ID': generateId(),
'X-Client-Version': APP_VERSION
}
}))
// Now every request is automatically enhanced!
const { data } = await tf.get('/api/user') // Auth added automatically
Request Interceptors: Transform Outgoing Requests
Request interceptors run before requests are sent:
// Add authentication to all requests
tf.addRequestInterceptor(config => {
const token = localStorage.getItem('authToken')
if (token && !config.headers['Authorization']) {
config.headers['Authorization'] = `Bearer ${token}`
}
return config
})
// Add request tracking
tf.addRequestInterceptor(config => {
config.metadata = {
...config.metadata,
requestId: crypto.randomUUID(),
timestamp: Date.now(),
userId: getCurrentUserId()
}
console.log(`[${config.metadata.requestId}] ${config.method} ${config.url}`)
return config
})
// Modify requests based on environment
tf.addRequestInterceptor(config => {
if (process.env.NODE_ENV === 'development') {
// Add debug headers in dev
config.headers['X-Debug-Mode'] = 'true'
config.headers['X-Developer'] = os.username()
}
if (config.url.includes('/payment')) {
// Extra security for payment endpoints
config.timeout = 30000 // 30 seconds
config.retries = 0 // No retries for payments
}
return config
})
// Request signing for secure endpoints
tf.addRequestInterceptor(async config => {
if (config.url.includes('/secure')) {
const signature = await signRequest(config)
config.headers['X-Signature'] = signature
config.headers['X-Timestamp'] = Date.now().toString()
}
return config
})
Response Interceptors: Transform Incoming Data
Response interceptors process data before it reaches your code:
// Transform snake_case to camelCase
tf.addResponseInterceptor(response => {
if (response.data) {
response.data = transformKeys(response.data, snakeToCamel)
}
return response
})
// Extract data from envelopes
tf.addResponseInterceptor(response => {
// API returns { success: true, data: {...}, meta: {...} }
// We just want the data
if (response.data?.success && response.data?.data) {
response.data = response.data.data
}
return response
})
// Add computed properties
tf.addResponseInterceptor(response => {
if (response.config.url.includes('/users')) {
// Add display name
if (response.data.firstName && response.data.lastName) {
response.data.displayName = `${response.data.firstName} ${response.data.lastName}`
}
// Add avatar URL
if (response.data.avatarId && !response.data.avatarUrl) {
response.data.avatarUrl = `https://cdn.example.com/avatars/${response.data.avatarId}`
}
}
return response
})
// Track response times
tf.addResponseInterceptor(response => {
const duration = Date.now() - response.config.metadata.timestamp
console.log(
`[${response.config.metadata.requestId}] ` +
`${response.status} ${response.config.url} - ${duration}ms`
)
// Add to metrics
metrics.recordApiCall({
endpoint: response.config.url,
method: response.config.method,
status: response.status,
duration
})
return response
})
Error Interceptors: Handle Failures Gracefully
Error interceptors catch and transform errors:
// Retry on auth failure
tf.addErrorInterceptor(async error => {
if (error.response?.status === 401) {
// Try refreshing the token
try {
const newToken = await refreshAuthToken()
localStorage.setItem('authToken', newToken)
// Retry the original request
error.config.headers['Authorization'] = `Bearer ${newToken}`
return tf.request(error.config)
} catch (refreshError) {
// Refresh failed, redirect to login
window.location.href = '/login'
throw error
}
}
throw error
})
// Transform error messages
tf.addErrorInterceptor(error => {
// Make errors more user-friendly
if (error.response?.status === 404) {
error.userMessage = 'The requested resource was not found.'
error.suggestions = ['Check the URL', 'Try again later']
} else if (error.response?.status === 500) {
error.userMessage = 'Something went wrong on our end.'
error.suggestions = ['Try again in a few minutes', 'Contact support']
}
// Log to error service
errorReporter.log(error)
throw error
})
// Handle network errors
tf.addErrorInterceptor(error => {
if (error.code === 'NETWORK_ERROR') {
// Check if we're offline
if (!navigator.onLine) {
error.userMessage = 'You appear to be offline.'
error.canRetry = true
// Queue for later
offlineQueue.add(error.config)
}
}
throw error
})
Building Complex Middleware Chains
Interceptors can work together to create powerful pipelines:
// 1. Authentication interceptor
class AuthInterceptor {
constructor(private auth: AuthService) {}
async request(config: RequestConfig) {
const token = await this.auth.getToken()
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
return config
}
async error(error: Error) {
if (error.response?.status === 401) {
await this.auth.refresh()
return tf.request(error.config)
}
throw error
}
}
// 2. Logging interceptor
class LoggingInterceptor {
private logger = new Logger('API')
request(config: RequestConfig) {
this.logger.info(`→ ${config.method} ${config.url}`)
config.metadata.startTime = performance.now()
return config
}
response(response: Response) {
const duration = performance.now() - response.config.metadata.startTime
this.logger.info(
`← ${response.status} ${response.config.url} (${duration.toFixed(2)}ms)`
)
return response
}
error(error: Error) {
this.logger.error(`✗ ${error.config.url}: ${error.message}`)
throw error
}
}
// 3. Retry interceptor
class RetryInterceptor {
private retryCount = new Map<string, number>()
async error(error: Error) {
const key = `${error.config.method}:${error.config.url}`
const attempts = this.retryCount.get(key) || 0
if (this.shouldRetry(error) && attempts < 3) {
this.retryCount.set(key, attempts + 1)
await this.delay(attempts)
return tf.request(error.config)
}
this.retryCount.delete(key)
throw error
}
private shouldRetry(error: Error) {
return (
error.response?.status >= 500 ||
error.code === 'NETWORK_ERROR' ||
error.code === 'TIMEOUT'
)
}
private delay(attempt: number) {
const ms = Math.min(1000 * Math.pow(2, attempt), 10000)
return new Promise(resolve => setTimeout(resolve, ms))
}
}
// Register all interceptors
const auth = new AuthInterceptor(authService)
const logging = new LoggingInterceptor()
const retry = new RetryInterceptor()
tf.addRequestInterceptor(config => auth.request(config))
tf.addRequestInterceptor(config => logging.request(config))
tf.addResponseInterceptor(response => logging.response(response))
tf.addErrorInterceptor(error => auth.error(error))
tf.addErrorInterceptor(error => retry.error(error))
tf.addErrorInterceptor(error => logging.error(error))
Weather Buddy 8.0: Enterprise Ready
Let's add professional-grade interceptors to Weather Buddy:
// weather-buddy-8.ts
import { tf } from 'typedfetch'
// Configuration
interface Config {
apiKey: string
analyticsId: string
environment: 'development' | 'staging' | 'production'
}
const config: Config = {
apiKey: process.env.WEATHER_API_KEY!,
analyticsId: process.env.ANALYTICS_ID!,
environment: (process.env.NODE_ENV as any) || 'development'
}
// Analytics tracking
class AnalyticsInterceptor {
private queue: AnalyticsEvent[] = []
private flushInterval = 5000
constructor(private analyticsId: string) {
setInterval(() => this.flush(), this.flushInterval)
}
request(config: RequestConfig) {
config.metadata.analyticsId = crypto.randomUUID()
config.metadata.timestamp = Date.now()
this.track({
type: 'api_request',
id: config.metadata.analyticsId,
endpoint: config.url,
method: config.method,
timestamp: config.metadata.timestamp
})
return config
}
response(response: Response) {
const duration = Date.now() - response.config.metadata.timestamp
this.track({
type: 'api_response',
id: response.config.metadata.analyticsId,
endpoint: response.config.url,
status: response.status,
duration,
cached: response.cached || false
})
return response
}
error(error: Error) {
this.track({
type: 'api_error',
id: error.config.metadata.analyticsId,
endpoint: error.config.url,
error: error.message,
status: error.response?.status
})
throw error
}
private track(event: AnalyticsEvent) {
this.queue.push({
...event,
analyticsId: this.analyticsId,
sessionId: getSessionId(),
userId: getCurrentUserId()
})
}
private async flush() {
if (this.queue.length === 0) return
const events = [...this.queue]
this.queue = []
try {
await fetch('https://analytics.example.com/events', {
method: 'POST',
body: JSON.stringify({ events })
})
} catch (error) {
// Re-queue on failure
this.queue.unshift(...events)
}
}
}
// API versioning
class VersioningInterceptor {
constructor(private version: string) {}
request(config: RequestConfig) {
// Add version to URL
if (config.url.includes('/api/')) {
config.url = config.url.replace('/api/', `/api/${this.version}/`)
}
// Add version header
config.headers['API-Version'] = this.version
return config
}
response(response: Response) {
// Check if API version is deprecated
const deprecation = response.headers.get('X-API-Deprecation')
if (deprecation) {
console.warn(`API deprecation warning: ${deprecation}`)
// Show user notification
showNotification({
type: 'warning',
message: 'This app needs to be updated soon.',
action: 'Update Now',
onAction: () => window.location.href = '/update'
})
}
return response
}
}
// Request signing for premium features
class SigningInterceptor {
constructor(private secret: string) {}
async request(config: RequestConfig) {
if (config.url.includes('/premium')) {
const timestamp = Date.now()
const payload = `${config.method}:${config.url}:${timestamp}`
const signature = await this.sign(payload)
config.headers['X-Signature'] = signature
config.headers['X-Timestamp'] = timestamp.toString()
}
return config
}
private async sign(payload: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(payload)
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(this.secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const signature = await crypto.subtle.sign('HMAC', key, data)
return btoa(String.fromCharCode(...new Uint8Array(signature)))
}
}
// Rate limiting with backpressure
class RateLimitInterceptor {
private requests = new Map<string, number[]>()
private limits = {
'/api/weather': { requests: 60, window: 60000 }, // 60/minute
'/api/premium': { requests: 100, window: 60000 }, // 100/minute
'default': { requests: 120, window: 60000 } // 120/minute
}
async request(config: RequestConfig) {
const endpoint = this.getEndpoint(config.url)
const limit = this.limits[endpoint] || this.limits.default
const now = Date.now()
const requests = this.requests.get(endpoint) || []
// Clean old requests
const recent = requests.filter(time => now - time < limit.window)
if (recent.length >= limit.requests) {
const oldestRequest = recent[0]
const waitTime = limit.window - (now - oldestRequest)
console.warn(`Rate limit approaching, waiting ${waitTime}ms`)
await new Promise(resolve => setTimeout(resolve, waitTime))
}
recent.push(now)
this.requests.set(endpoint, recent)
return config
}
response(response: Response) {
// Check rate limit headers
const remaining = response.headers.get('X-RateLimit-Remaining')
const reset = response.headers.get('X-RateLimit-Reset')
if (remaining && parseInt(remaining) < 10) {
console.warn(`Low API quota: ${remaining} requests remaining`)
// Slow down requests
tf.configure({
requestDelay: 1000 // Add 1s delay between requests
})
}
return response
}
private getEndpoint(url: string): string {
const match = url.match(/\/api\/\w+/)
return match ? match[0] : 'default'
}
}
// Development tools
class DevToolsInterceptor {
private enabled = config.environment === 'development'
private requests: RequestLog[] = []
request(config: RequestConfig) {
if (!this.enabled) return config
const log: RequestLog = {
id: config.metadata.requestId,
method: config.method,
url: config.url,
headers: config.headers,
data: config.data,
timestamp: Date.now(),
stack: new Error().stack
}
this.requests.push(log)
// Dev UI integration
window.postMessage({
type: 'API_REQUEST',
payload: log
}, '*')
return config
}
response(response: Response) {
if (!this.enabled) return response
const log = this.requests.find(r => r.id === response.config.metadata.requestId)
if (log) {
log.response = {
status: response.status,
headers: Object.fromEntries(response.headers.entries()),
data: response.data,
duration: Date.now() - log.timestamp
}
window.postMessage({
type: 'API_RESPONSE',
payload: log
}, '*')
}
return response
}
getRequests() {
return this.requests
}
clear() {
this.requests = []
}
}
// Register all interceptors
const analytics = new AnalyticsInterceptor(config.analyticsId)
const versioning = new VersioningInterceptor('v2')
const signing = new SigningInterceptor(config.apiKey)
const rateLimit = new RateLimitInterceptor()
const devTools = new DevToolsInterceptor()
// Request pipeline
tf.addRequestInterceptor(config => versioning.request(config))
tf.addRequestInterceptor(config => analytics.request(config))
tf.addRequestInterceptor(config => rateLimit.request(config))
tf.addRequestInterceptor(config => signing.request(config))
tf.addRequestInterceptor(config => devTools.request(config))
// Response pipeline
tf.addResponseInterceptor(response => devTools.response(response))
tf.addResponseInterceptor(response => analytics.response(response))
tf.addResponseInterceptor(response => versioning.response(response))
tf.addResponseInterceptor(response => rateLimit.response(response))
// Error pipeline
tf.addErrorInterceptor(error => analytics.error(error))
// Weather Buddy Premium Features
class WeatherBuddyPremium {
private subscriptionActive = false
async checkSubscription(): Promise<boolean> {
try {
const { data } = await tf.get('/api/premium/subscription')
this.subscriptionActive = data.active
return data.active
} catch {
return false
}
}
async getDetailedForecast(city: string): Promise<DetailedForecast> {
if (!this.subscriptionActive) {
throw new Error('Premium subscription required')
}
const { data } = await tf.get(`/api/premium/forecast/${city}`, {
params: {
days: 14,
hourly: true,
includes: ['uv', 'pollen', 'airQuality', 'astronomy']
}
})
return data
}
async getWeatherAlerts(location: { lat: number, lon: number }): Promise<Alert[]> {
const { data } = await tf.get('/api/premium/alerts', {
params: location
})
return data.alerts
}
async getHistoricalWeather(city: string, date: Date): Promise<HistoricalData> {
const { data } = await tf.get(`/api/premium/historical/${city}`, {
params: {
date: date.toISOString().split('T')[0]
}
})
return data
}
}
// Export for DevTools extension
if (config.environment === 'development') {
(window as any).__WEATHER_BUDDY_DEV__ = {
tf,
interceptors: {
analytics,
versioning,
signing,
rateLimit,
devTools
},
getRequests: () => devTools.getRequests(),
clearRequests: () => devTools.clear(),
config
}
}
Creating Plugin Systems with Interceptors
Build extensible applications with interceptor-based plugins:
// Plugin system
interface Plugin {
name: string
version: string
init?(tf: TypedFetch): void
request?(config: RequestConfig): RequestConfig | Promise<RequestConfig>
response?(response: Response): Response | Promise<Response>
error?(error: Error): Error | Promise<Error>
}
class PluginManager {
private plugins: Plugin[] = []
register(plugin: Plugin) {
console.log(`Loading plugin: ${plugin.name} v${plugin.version}`)
this.plugins.push(plugin)
if (plugin.init) {
plugin.init(tf)
}
if (plugin.request) {
tf.addRequestInterceptor(config => plugin.request!(config))
}
if (plugin.response) {
tf.addResponseInterceptor(response => plugin.response!(response))
}
if (plugin.error) {
tf.addErrorInterceptor(error => plugin.error!(error))
}
}
unregister(pluginName: string) {
this.plugins = this.plugins.filter(p => p.name !== pluginName)
// Note: Interceptors can't be removed in this example
// In real implementation, track interceptor IDs
}
list() {
return this.plugins.map(p => ({
name: p.name,
version: p.version
}))
}
}
// Example plugins
const compressionPlugin: Plugin = {
name: 'compression',
version: '1.0.0',
request(config) {
if (config.data && config.method !== 'GET') {
const json = JSON.stringify(config.data)
if (json.length > 1024) {
// Compress large payloads
config.headers['Content-Encoding'] = 'gzip'
config.data = gzip(json)
}
}
return config
},
response(response) {
if (response.headers.get('Content-Encoding') === 'gzip') {
response.data = JSON.parse(gunzip(response.data))
}
return response
}
}
const cacheSyncPlugin: Plugin = {
name: 'cache-sync',
version: '1.0.0',
init(tf) {
// Sync cache across tabs
window.addEventListener('storage', (e) => {
if (e.key?.startsWith('tf-cache-')) {
tf.cache.sync(e.key, e.newValue)
}
})
},
response(response) {
if (response.config.method === 'GET' && response.cached === false) {
// Share across tabs
localStorage.setItem(
`tf-cache-${response.config.url}`,
JSON.stringify({
data: response.data,
timestamp: Date.now()
})
)
}
return response
}
}
// Use plugins
const plugins = new PluginManager()
plugins.register(compressionPlugin)
plugins.register(cacheSyncPlugin)
Advanced Interceptor Patterns
1. Conditional Interceptors
Apply interceptors based on conditions:
// Only for specific endpoints
tf.addRequestInterceptor(config => {
if (config.url.includes('/admin')) {
config.headers['X-Admin-Token'] = getAdminToken()
}
return config
})
// Only in certain environments
if (process.env.NODE_ENV === 'production') {
tf.addRequestInterceptor(config => {
config.headers['X-Source'] = 'production'
return config
})
}
// Based on feature flags
tf.addRequestInterceptor(config => {
if (featureFlags.get('new-api')) {
config.url = config.url.replace('/api/v1', '/api/v2')
}
return config
})
2. Interceptor Composition
Combine multiple interceptors elegantly:
function compose(...interceptors: RequestInterceptor[]) {
return async (config: RequestConfig) => {
let result = config
for (const interceptor of interceptors) {
result = await interceptor(result)
}
return result
}
}
// Combine auth + logging + metrics
const combined = compose(
addAuth,
addLogging,
addMetrics
)
tf.addRequestInterceptor(combined)
3. Stateful Interceptors
Interceptors that maintain state:
class SequenceInterceptor {
private sequence = 0
request(config: RequestConfig) {
config.headers['X-Sequence'] = (++this.sequence).toString()
return config
}
response(response: Response) {
const expected = response.config.headers['X-Sequence']
const received = response.headers.get('X-Sequence-Echo')
if (expected !== received) {
console.warn('Response out of sequence!')
}
return response
}
}
4. Priority-Based Interceptors
Control interceptor execution order:
class PriorityInterceptorManager {
private interceptors: Array<{
priority: number
handler: Function
}> = []
add(handler: Function, priority = 0) {
this.interceptors.push({ priority, handler })
this.interceptors.sort((a, b) => b.priority - a.priority)
}
async execute(config: RequestConfig) {
let result = config
for (const { handler } of this.interceptors) {
result = await handler(result)
}
return result
}
}
// Usage
const manager = new PriorityInterceptorManager()
manager.add(authInterceptor, 100) // Run first
manager.add(loggingInterceptor, 50) // Run second
manager.add(metricsInterceptor, 10) // Run last
Testing with Interceptors
Use interceptors to make testing easier:
// Mock interceptor for testing
class MockInterceptor {
private mocks = new Map<string, any>()
mock(url: string, response: any) {
this.mocks.set(url, response)
}
clear() {
this.mocks.clear()
}
request(config: RequestConfig) {
const mock = this.mocks.get(config.url)
if (mock) {
// Bypass network, return mock
return Promise.reject({
config,
mockResponse: mock
})
}
return config
}
error(error: any) {
if (error.mockResponse) {
// Convert to response
return {
config: error.config,
data: error.mockResponse,
status: 200,
headers: new Headers(),
mocked: true
}
}
throw error
}
}
// Testing
describe('WeatherAPI', () => {
const mock = new MockInterceptor()
beforeAll(() => {
tf.addRequestInterceptor(config => mock.request(config))
tf.addErrorInterceptor(error => mock.error(error))
})
beforeEach(() => {
mock.clear()
})
test('getWeather returns data', async () => {
mock.mock('/api/weather/london', {
temp: 15,
condition: 'Cloudy'
})
const weather = await getWeather('london')
expect(weather.temp).toBe(15)
})
})
Best Practices for Interceptors 🎯
1. Keep Interceptors Focused
// ✅ Good: Single responsibility
tf.addRequestInterceptor(addAuthentication)
tf.addRequestInterceptor(addRequestId)
tf.addRequestInterceptor(addVersioning)
// ❌ Bad: Doing too much
tf.addRequestInterceptor(config => {
// Add auth
// Add logging
// Add versioning
// Transform data
// 100 lines of code...
})
2. Handle Errors Gracefully
// ✅ Good: Safe interceptor
tf.addRequestInterceptor(async config => {
try {
const token = await getToken()
config.headers['Authorization'] = `Bearer ${token}`
} catch (error) {
console.error('Failed to get token:', error)
// Continue without auth rather than failing
}
return config
})
3. Make Interceptors Configurable
// ✅ Good: Configurable behavior
function createLoggingInterceptor(options = {}) {
const {
logLevel = 'info',
includeHeaders = false,
includeBody = false
} = options
return (config: RequestConfig) => {
const log = {
method: config.method,
url: config.url,
...(includeHeaders && { headers: config.headers }),
...(includeBody && { body: config.data })
}
logger[logLevel]('API Request:', log)
return config
}
}
4. Document Side Effects
/**
* Adds authentication to requests
* Side effects:
* - Reads from localStorage
* - May redirect to /login on 401
* - Refreshes token automatically
*/
function authInterceptor(config: RequestConfig) {
// Implementation
}
Practice Time! 🏋️
Exercise 1: Build a Caching Interceptor
Create an interceptor that caches responses:
class CacheInterceptor {
// Your code here:
// - Cache GET responses
// - Respect cache headers
// - Handle cache invalidation
// - Add cache info to response
}
Exercise 2: Create a Retry Queue
Build an interceptor that queues failed requests:
class RetryQueueInterceptor {
// Your code here:
// - Queue failed requests
// - Retry with backoff
// - Handle offline/online
// - Prevent duplicates
}
Exercise 3: Request Batching
Implement request batching:
class BatchInterceptor {
// Your code here:
// - Batch multiple requests
// - Send as single request
// - Distribute responses
// - Handle partial failures
}
Key Takeaways 🎯
- Interceptors modify requests and responses in flight - Transform data automatically
- Request interceptors run before sending - Add auth, headers, tracking
- Response interceptors run after receiving - Transform data, add computed fields
- Error interceptors handle failures - Retry, refresh tokens, queue offline
- Chain interceptors for complex behaviors - Compose simple functions
- Use interceptors for cross-cutting concerns - Auth, logging, analytics
- Interceptors enable plugin systems - Extensible architectures
- Keep interceptors focused and safe - Single responsibility, graceful errors
Common Pitfalls 🚨
- Modifying config without cloning - Always return new object
- Forgetting async interceptors - Handle promises properly
- Interceptor order matters - Auth before signing
- Infinite loops in error handlers - Detect retry loops
- Heavy processing in interceptors - Keep them fast
- Not documenting side effects - Hidden behaviors confuse
What's Next?
You've mastered interceptors! But what about real-time data? In Chapter 9, we'll explore streaming and real-time features:
- Server-Sent Events (SSE)
- WebSocket integration
- Streaming JSON responses
- Real-time data synchronization
- Live updates and subscriptions
- Handling connection states
Ready to make your app real-time? See you in Chapter 9! 🚀
Chapter Summary
- Interceptors form a pipeline that every request/response passes through
- Request interceptors add auth, headers, and modify outgoing data
- Response interceptors transform data and add computed properties
- Error interceptors handle failures, retry logic, and token refresh
- Chain multiple interceptors for complex behaviors like analytics + auth + versioning
- Interceptors enable plugin systems and extensible architectures
- Keep interceptors focused, safe, and well-documented
- Weather Buddy 8.0 is enterprise-ready with auth, analytics, versioning, and rate limiting
Next Chapter Preview: Real-Time & Streaming - Server-Sent Events, WebSockets, streaming responses, and building live-updating applications.