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