--- name: api-client-patterns description: HTTP client patterns, API integration, request/response handling, error handling, retry logic, axios usage. Use when building API clients, integrating external services, handling API errors, or making HTTP requests. allowed-tools: Read, Grep, Glob --- # API Client Patterns ## Core Principles 1. **Single Responsibility** - One client per service 2. **Centralized Configuration** - Base URL, headers, timeouts in one place 3. **Comprehensive Error Handling** - Catch and transform errors appropriately 4. **Type Safety** - Define request/response interfaces 5. **Retry Logic** - Handle transient failures gracefully --- ## API Client Structure ### CORRECT: Well-Structured API Client ```javascript // client/src/utils/api.js import axios from 'axios'; // Configuration const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:3001/api'; const TIMEOUT = 30000; // 30 seconds // Create axios instance with defaults const apiClient = axios.create({ baseURL: API_BASE, timeout: TIMEOUT, headers: { 'Content-Type': 'application/json' } }); // Request interceptor (optional - for auth, logging, etc.) apiClient.interceptors.request.use( (config) => { // Add auth token if available const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }, (error) => Promise.reject(error) ); // Response interceptor (optional - for error transformation) apiClient.interceptors.response.use( (response) => response, (error) => { // Transform errors to consistent format const message = error.response?.data?.error || error.message || 'Unknown error'; return Promise.reject(new Error(message)); } ); // API Methods /** * Verify faculty password */ export const verifyPassword = async (password) => { const response = await apiClient.post('/auth/verify', { password }); return response.data; }; /** * Generate or update instructional page */ export const generatePage = async (config, message, history = []) => { const response = await apiClient.post('/generate', { config, message, history }); return response.data; }; /** * Generate AI image using DALL-E */ export const generateImage = async (prompt) => { const response = await apiClient.post('/images/generate', { prompt }); return response.data; }; /** * Upload image to Cloudinary */ export const uploadImage = async (file) => { const formData = new FormData(); formData.append('image', file); const response = await apiClient.post('/images/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }); return response.data; }; export default apiClient; ``` ### WRONG: Scattered API Calls ```javascript // ❌ DON'T DO THIS: Direct axios calls scattered across components import axios from 'axios'; // In Component 1 const handleLogin = async () => { const res = await axios.post('http://localhost:3001/api/auth/verify', { password: pwd }); }; // In Component 2 const handleGenerate = async () => { const res = await axios.post('http://localhost:3001/api/generate', data); }; // ❌ Issues: // - Repeated base URL strings // - No centralized error handling // - No configuration reuse // - Hard to test ``` --- ## Error Handling Patterns ### CORRECT: Comprehensive Error Handling ```javascript // API client with error transformation export const generatePage = async (config, message, history) => { try { const response = await apiClient.post('/generate', { config, message, history }); return response.data; } catch (error) { // Check different error types if (error.response) { // Server responded with error status const status = error.response.status; const message = error.response.data?.error || 'Server error'; if (status === 400) { throw new Error(`Validation error: ${message}`); } else if (status === 401) { throw new Error('Authentication failed'); } else if (status === 429) { throw new Error('Rate limit exceeded. Please try again later.'); } else if (status >= 500) { throw new Error('Server error. Please try again.'); } else { throw new Error(message); } } else if (error.request) { // Request made but no response received throw new Error('Network error. Please check your connection.'); } else { // Something else happened throw new Error(error.message || 'Request failed'); } } }; ``` ### Component Error Handling ```javascript // In component export default function ChatInterface() { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const handleSubmit = async () => { setLoading(true); setError(null); try { const result = await generatePage(config, message, history); // Handle success } catch (err) { // Error is already transformed by API client setError(err.message); // Optional: Report to error tracking service console.error('Generation failed:', err); } finally { setLoading(false); } }; return (
{error && (

{error}

)} {/* Rest of component */}
); } ``` --- ## Request Configuration Patterns ### With Timeout ```javascript export const generatePageWithTimeout = async (config, message, history, timeout = 60000) => { const response = await apiClient.post('/generate', { config, message, history }, { timeout // Override default timeout for long operations }); return response.data; }; ``` ### With Abort Controller (Cancellation) ```javascript export const generatePageCancellable = async (config, message, history, signal) => { const response = await apiClient.post('/generate', { config, message, history }, { signal // Pass AbortController signal }); return response.data; }; // Usage in component const abortController = useRef(null); const handleGenerate = async () => { // Cancel previous request if exists if (abortController.current) { abortController.current.abort(); } abortController.current = new AbortController(); try { const result = await generatePageCancellable( config, message, history, abortController.current.signal ); // Handle result } catch (err) { if (err.name === 'AbortError') { console.log('Request cancelled'); } else { setError(err.message); } } }; // Cleanup on unmount useEffect(() => { return () => { if (abortController.current) { abortController.current.abort(); } }; }, []); ``` --- ## Streaming Responses Pattern For long-running AI generation with streaming: ```javascript /** * Generate page with streaming response */ export const generatePageStream = async (config, message, history, onChunk) => { const response = await fetch(`${API_BASE}/generate/stream`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ config, message, history }) }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop(); // Keep incomplete line in buffer for (const line of lines) { if (line.trim()) { try { const data = JSON.parse(line); onChunk(data); } catch (err) { console.error('Failed to parse chunk:', err); } } } } }; // Usage in component const handleStreamingGenerate = async () => { let fullText = ''; try { await generatePageStream(config, message, history, (chunk) => { // Process each chunk as it arrives if (chunk.type === 'text') { fullText += chunk.content; setPreview(fullText); // Update UI in real-time } else if (chunk.type === 'done') { setComplete(true); } }); } catch (err) { setError(err.message); } }; ``` --- ## Retry Logic Pattern For handling transient failures: ```javascript /** * Retry a function with exponential backoff */ const retryWithBackoff = async (fn, maxRetries = 3, baseDelay = 1000) => { for (let attempt = 0; attempt < maxRetries; attempt++) { try { return await fn(); } catch (error) { const isLastAttempt = attempt === maxRetries - 1; // Don't retry on 4xx errors (client errors) if (error.response?.status >= 400 && error.response?.status < 500) { throw error; } if (isLastAttempt) { throw error; } // Exponential backoff: 1s, 2s, 4s, 8s, etc. const delay = baseDelay * Math.pow(2, attempt); console.log(`Retry attempt ${attempt + 1}/${maxRetries} after ${delay}ms`); await new Promise(resolve => setTimeout(resolve, delay)); } } }; // Usage export const generatePageWithRetry = async (config, message, history) => { return retryWithBackoff(async () => { const response = await apiClient.post('/generate', { config, message, history }); return response.data; }); }; ``` --- ## File Upload Pattern ### With Progress Tracking ```javascript export const uploadImageWithProgress = async (file, onProgress) => { const formData = new FormData(); formData.append('image', file); const response = await apiClient.post('/images/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' }, onUploadProgress: (progressEvent) => { const percentCompleted = Math.round( (progressEvent.loaded * 100) / progressEvent.total ); onProgress(percentCompleted); } }); return response.data; }; // Usage in component const handleFileUpload = async (file) => { setProgress(0); try { const result = await uploadImageWithProgress(file, (percent) => { setProgress(percent); }); setImageUrl(result.url); } catch (err) { setError(err.message); } finally { setProgress(0); } }; ``` --- ## Environment-Based Configuration ```javascript // client/src/utils/api.js // Different base URLs for different environments const getApiBase = () => { const env = import.meta.env.MODE; switch (env) { case 'production': return 'https://api.yourapp.com'; case 'staging': return 'https://staging-api.yourapp.com'; case 'development': default: return import.meta.env.VITE_API_BASE || 'http://localhost:3001/api'; } }; const apiClient = axios.create({ baseURL: getApiBase(), timeout: 30000 }); ``` --- ## Testing API Clients ```javascript // client/src/utils/api.test.js import { describe, it, expect, vi } from 'vitest'; import axios from 'axios'; import { generatePage, verifyPassword } from './api'; // Mock axios vi.mock('axios'); describe('API Client', () => { it('should call generatePage endpoint with correct data', async () => { const mockResponse = { data: { html: '
Test
', message: 'Generated successfully' } }; axios.create.mockReturnValue({ post: vi.fn().mockResolvedValue(mockResponse) }); const result = await generatePage( { depth: 2 }, 'Create a page about React', [] ); expect(result.html).toBe('
Test
'); }); it('should handle errors correctly', async () => { axios.create.mockReturnValue({ post: vi.fn().mockRejectedValue(new Error('Network error')) }); await expect( generatePage({}, 'Test', []) ).rejects.toThrow('Network error'); }); }); ``` --- ## Checklist ### Before Creating an API Client - [ ] What endpoints will this client access? - [ ] What base URL and configuration is needed? - [ ] What error cases need handling? - [ ] Does it need retry logic? - [ ] Does it need request/response interceptors? ### After Creating an API Client - [ ] Base URL configured correctly - [ ] Timeout set appropriately - [ ] Errors transformed to consistent format - [ ] All endpoints have JSDoc comments - [ ] Request/response types documented - [ ] Error cases handled in consuming components - [ ] Tested with mock responses --- ## Integration with Other Skills - **react-component-patterns**: Using API clients in components - **express-api-patterns**: Understanding backend endpoints - **systematic-debugging**: Debugging API integration issues - **testing-patterns**: Testing API clients --- ## Common Mistakes to Avoid 1. ❌ Scattered axios calls across components 2. ❌ Hard-coded API URLs 3. ❌ Not handling network errors 4. ❌ Not transforming error responses 5. ❌ Missing timeout configuration 6. ❌ Not cancelling requests on unmount 7. ❌ Retry logic on non-retryable errors (4xx) 8. ❌ Not using environment variables for base URL