From Fetch API to Online API Tester: Request Wrapper and Error Handling
From Fetch API to Online API Tester: Request Wrapper and Error Handling#
Debugging third-party APIs, I found Postman too heavy and curl commands not intuitive enough. So I built a lightweight online testing tool using the browser’s native Fetch API. Here’s what I learned about request wrapping.
The Essence of Fetch API#
Browser-native fetch looks simple:
const response = await fetch('https://api.example.com/data')
const data = await response.json()
But in real projects, this approach hits many pitfalls.
Timeout Control: AbortController#
fetch has no built-in timeout. Requests can wait indefinitely. Use AbortController:
async function fetchWithTimeout(
url: string,
options: RequestInit = {},
timeout = 10000
): Promise<Response> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
})
return response
} finally {
clearTimeout(timeoutId)
}
}
// Usage
try {
const res = await fetchWithTimeout('https://api.example.com/data', {}, 5000)
const data = await res.json()
} catch (err) {
if (err.name === 'AbortError') {
console.error('Request timeout')
}
}
AbortController.abort() triggers a DOMException with name AbortError. Remember to clear the timeout, or it will fire even after a successful request.
Error Handling: HTTP Status vs Network Errors#
fetch design is counterintuitive: HTTP 4xx/5xx doesn’t throw, only network errors reject.
// Wrong approach
try {
const res = await fetch('/api/not-found') // 404
const data = await res.json() // Executes normally
} catch (err) {
// Never reaches here
}
The correct approach is checking response.ok:
async function safeFetch<T>(
url: string,
options: RequestInit = {}
): Promise<{ data: T; status: number }> {
const response = await fetch(url, options)
if (!response.ok) {
// Try to parse error response
let errorMessage = `HTTP ${response.status}`
try {
const errorData = await response.json()
errorMessage = errorData.message || errorMessage
} catch {
// JSON parse failed, use default message
}
throw new Error(errorMessage)
}
const data = await response.json()
return { data, status: response.status }
}
Header Management: Defaults and Overrides#
API testing tools need flexible header management. Common pitfalls:
1. Content-Type Auto-Inference#
fetch doesn’t auto-set Content-Type, you must specify it:
const headers: Record<string, string> = {
'Accept': 'application/json',
'Content-Type': 'application/json',
}
// User headers override defaults
userHeaders.forEach(h => {
if (h.key) headers[h.key] = h.value
})
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(data),
})
2. CORS Preflight Requests#
Custom headers trigger CORS preflight (OPTIONS request). If the server doesn’t support it, requests fail:
// Simple request, no preflight
fetch('/api/data', {
headers: {
'Accept': 'application/json',
'Content-Type': 'text/plain',
}
})
// Triggers preflight
fetch('/api/data', {
headers: {
'Authorization': 'Bearer token', // Custom header
'Content-Type': 'application/json',
}
})
In development, use a proxy. In production, the server must configure Access-Control-Allow-Headers.
Response Parsing: Multiple Formats#
APIs don’t always return JSON. Parse based on Content-Type:
async function parseResponse(response: Response): Promise<unknown> {
const contentType = response.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
return response.json()
}
if (contentType.includes('text/')) {
return response.text()
}
if (contentType.includes('application/octet-stream')) {
const blob = await response.blob()
return URL.createObjectURL(blob) // Return download link
}
// Unknown type, try JSON, fallback to text
try {
return await response.json()
} catch {
return response.text()
}
}
Request Timing: Performance Monitoring#
API testers need to show response time. Date.now() works, but performance.now() is more precise:
async function timedFetch(
url: string,
options: RequestInit = {}
): Promise<{ response: Response; time: number }> {
const startTime = performance.now() // Higher precision than Date.now()
const response = await fetch(url, options)
const endTime = performance.now()
const time = Math.round(endTime - startTime)
return { response, time }
}
performance.now() returns millisecond-level floats, more precise than Date.now(), ideal for performance measurement.
Response Header Extraction: Headers Object#
response.headers is a Headers object, not a plain object:
const resHeaders: Record<string, string> = {}
response.headers.forEach((value, key) => {
resHeaders[key] = value
})
// Or use entries()
const headersObj = Object.fromEntries(response.headers.entries())
Note: Some headers (like Set-Cookie) are not accessible to JavaScript due to browser security restrictions.
Complete Wrapper Example#
Combining all points, a complete request wrapper:
interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
headers?: Record<string, string>
body?: unknown
timeout?: number
}
interface ApiResponse<T> {
data: T
status: number
statusText: string
headers: Record<string, string>
time: number
}
async function request<T>(
url: string,
options: RequestOptions = {}
): Promise<ApiResponse<T>> {
const { method = 'GET', headers = {}, body, timeout = 10000 } = options
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
const startTime = performance.now()
try {
const response = await fetch(url, {
method,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
...headers,
},
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
})
const time = Math.round(performance.now() - startTime)
if (!response.ok) {
const errorText = await response.text()
throw new Error(errorText || `HTTP ${response.status}`)
}
const data = await response.json()
const resHeaders: Record<string, string> = {}
response.headers.forEach((v, k) => resHeaders[k] = v)
return {
data,
status: response.status,
statusText: response.statusText,
headers: resHeaders,
time,
}
} finally {
clearTimeout(timeoutId)
}
}
Real Application: Online API Tester#
Based on this wrapper, I built: API Tester
Features:
- GET/POST/PUT/DELETE/PATCH methods
- Custom header management
- Request body editing (JSON)
- Response time tracking
- Response header viewer
- Error messages
Implementation isn’t complex, but getting the details right requires handling many edge cases. Hope this helps.
Related: cURL Converter | JSON Formatter