API Client Skill
Patterns for fetch-based API communication with robust error handling, retry logic, and response caching.
Philosophy
Native
fetch()
only
- No axios or other libraries
Errors should be explicit
- No swallowed promises
Requests should be cancellable
- Use AbortController
Fail gracefully
- Handle network issues elegantly
Base Fetch Wrapper
A typed wrapper around fetch with consistent error handling:
/
*
@typedef
{
Object
}
ApiError
*
@property
{
number
}
status
- HTTP status code
*
@property
{
string
}
statusText
- HTTP status text
*
@property
{
Object
}
[
body
]
- Parsed error response body
*
@property
{
string
}
message
- Error message
*/
/
* Custom error class for API failures
/
class
ApiError
extends
Error
{
/
*
@param
{
Response
}
response
*
@param
{
Object
}
[
body
]
/
constructor
(
response
,
body
=
null
)
{
super
(
API Error:
${
response
.
status
}
${
response
.
statusText
}
)
;
this
.
name
=
'ApiError'
;
this
.
status
=
response
.
status
;
this
.
statusText
=
response
.
statusText
;
this
.
body
=
body
;
}
}
/**
* Base fetch wrapper with consistent error handling
*
@template
T
*
@param
{
string
}
endpoint
*
@param
{
RequestInit
}
[
options
=
{
}
]
*
@returns
{
Promise
<
T
} * @throws { ApiError } / async function api ( endpoint , options = { } ) { const response = await fetch ( endpoint , { headers : { 'Content-Type' : 'application/json' , ... options . headers } , ... options } ) ; if ( ! response . ok ) { let body = null ; try { body = await response . json ( ) ; } catch { // Response may not be JSON } throw new ApiError ( response , body ) ; } // Handle empty responses (204 No Content) if ( response . status === 204 ) { return / @type { T } / ( null ) ; } return response . json ( ) ; } Usage // GET request const user = await api ( '/api/users/123' ) ; // POST request const newUser = await api ( '/api/users' , { method : 'POST' , body : JSON . stringify ( { name : 'Alice' , email : 'alice@example.com' } ) } ) ; // With error handling try { const data = await api ( '/api/protected' ) ; } catch ( error ) { if ( error instanceof ApiError ) { if ( error . status === 401 ) { // Redirect to login } else if ( error . status === 404 ) { // Show not found } } throw error ; } Error Handling Patterns Typed Error Responses / * @typedef { 'validation' | 'auth' | 'not_found' | 'server' } ErrorType */ / * @typedef { Object } ValidationError * @property { 'validation' } type * @property { Object < string , string [ ]
} errors - Field-level errors / / * Check error type and handle appropriately * @param { ApiError } error / function handleApiError ( error ) { const body = error . body ; if ( error . status === 400 && body ?. type === 'validation' ) { // Show field-level errors Object . entries ( body . errors ) . forEach ( ( [ field , messages ] ) => { console . error (
${ field } : ${ messages . join ( ', ' ) }) ; } ) ; return ; } if ( error . status === 401 ) { // Redirect to login window . location . href = '/login' ; return ; } if ( error . status === 403 ) { // Show permission denied showNotification ( 'You do not have permission for this action' ) ; return ; } if ( error . status = 500 ) { // Show generic server error showNotification ( 'Something went wrong. Please try again later.' ) ; return ; } // Unknown error - log and show generic message console . error ( 'Unexpected API error:' , error ) ; showNotification ( 'An unexpected error occurred' ) ; } Error Boundary Integration / * Report error to monitoring service * @param { Error } error * @param { Object } [ context ] */ function reportError ( error , context = { } ) { // Use sendBeacon for reliability navigator . sendBeacon ( '/api/errors' , JSON . stringify ( { message : error . message , stack : error . stack , status : error instanceof ApiError ? error . status : undefined , url : window . location . href , timestamp : Date . now ( ) , ... context } ) ) ; } Retry with Exponential Backoff / * @typedef { Object } RetryOptions * @property { number } [ retries = 3 ] - Maximum retry attempts * @property { number } [ delay = 1000 ] - Initial delay in ms * @property { ( status : number ) => boolean } [ retryOn ] - Function to determine if status should retry / /* * Fetch with automatic retry on failure * @template T * @param { string } url * @param { RequestInit } [ options = { } ] * @param { RetryOptions } [ retryOptions = { } ] * @returns { Promise < T} / async function fetchWithRetry ( url , options = { } , retryOptions = { } ) { const { retries = 3 , delay = 1000 , retryOn = ( status ) => status = 500 && status <= 504 } = retryOptions ; let lastError ; for ( let attempt = 0 ; attempt <= retries ; attempt ++ ) { try { return await api ( url , options ) ; } catch ( error ) { lastError = error ; // Don't retry on client errors or if not a retryable status if ( error instanceof ApiError && ! retryOn ( error . status ) ) { throw error ; } // Don't retry if we've exhausted attempts if ( attempt === retries ) { throw error ; } // Exponential backoff: 1s, 2s, 4s, 8s... const backoff = delay * Math . pow ( 2 , attempt ) ; await new Promise ( resolve => setTimeout ( resolve , backoff ) ) ; } } throw lastError ; } Usage // Retry up to 3 times with exponential backoff const data = await fetchWithRetry ( '/api/unstable-endpoint' , { } , { retries : 3 , delay : 1000 , retryOn : ( status ) => status === 503 || status === 429 } ) ; Request Cancellation Timeout Pattern /* * Fetch with automatic timeout * @template T * @param { string } url * @param { RequestInit & { timeout ? : number } } [ options = { } ] * @returns { Promise < T
} / async function fetchWithTimeout ( url , options = { } ) { const { timeout = 10000 , ... fetchOptions } = options ; const controller = new AbortController ( ) ; const timeoutId = setTimeout ( ( ) => controller . abort ( ) , timeout ) ; try { return await api ( url , { ... fetchOptions , signal : controller . signal } ) ; } catch ( error ) { if ( error . name === 'AbortError' ) { throw new Error (
Request timed out after ${ timeout } ms) ; } throw error ; } finally { clearTimeout ( timeoutId ) ; } } Component Cleanup class DataComponent extends HTMLElement { / @type { AbortController | null } /
controller
= null ; async connectedCallback ( ) { this .
controller
= new AbortController ( ) ; try { const data = await api ( '/api/data' , { signal : this .
controller
. signal } ) ; this . render ( data ) ; } catch ( error ) { if ( error . name === 'AbortError' ) { // Component was disconnected - ignore return ; } this . renderError ( error ) ; } } disconnectedCallback ( ) { // Cancel any in-flight requests this .
controller
?. abort ( ) ; } } Response Caching In-Memory Cache with TTL / * @typedef { Object } CacheEntry * @property { * } data * @property { number } expires */ / * Create a cached fetch function * @param { number } ttl - Time to live in milliseconds * @returns { < T
( url : string , options ? : RequestInit ) => Promise < T
} / function createCachedFetch ( ttl = 60000 ) { /* @type { Map < string , CacheEntry
} */ const cache = new Map ( ) ; return async function cachedFetch ( url , options = { } ) { // Only cache GET requests const method = options . method ?. toUpperCase ( ) || 'GET' ; if ( method !== 'GET' ) { return api ( url , options ) ; } const cacheKey = url ; const cached = cache . get ( cacheKey ) ; if ( cached && cached . expires
Date . now ( ) ) { return cached . data ; } const data = await api ( url , options ) ; cache . set ( cacheKey , { data , expires : Date . now ( ) + ttl } ) ; return data ; } ; } Cache Invalidation / * Create a cache manager with invalidation */ function createCacheManager ( ttl = 60000 ) { const cache = new Map ( ) ; return { async fetch ( url , options = { } ) { // Same caching logic as above } , / * Invalidate specific URL * @param { string } url / invalidate ( url ) { cache . delete ( url ) ; } , / * Invalidate URLs matching pattern * @param { RegExp } pattern / invalidatePattern ( pattern ) { for ( const key of cache . keys ( ) ) { if ( pattern . test ( key ) ) { cache . delete ( key ) ; } } } , / * Clear entire cache */ clear ( ) { cache . clear ( ) ; } } ; } Request Deduplication Prevent duplicate in-flight requests: / * Create a deduplicated fetch function * @returns { < T
( url : string , options ? : RequestInit ) => Promise < T
} / function createDedupedFetch ( ) { /* @type { Map < string , Promise < *
} / const pending = new Map ( ) ; return async function dedupedFetch ( url , options = { } ) { const method = options . method ?. toUpperCase ( ) || 'GET' ; // Only dedupe GET requests if ( method !== 'GET' ) { return api ( url , options ) ; } const key = url ; // Return existing promise if request is in-flight if ( pending . has ( key ) ) { return pending . get ( key ) ; } // Create new request const promise = api ( url , options ) . finally ( ( ) => { pending . delete ( key ) ; } ) ; pending . set ( key , promise ) ; return promise ; } ; } Usage const dedupedFetch = createDedupedFetch ( ) ; // These will only make ONE network request const [ user1 , user2 , user3 ] = await Promise . all ( [ dedupedFetch ( '/api/users/123' ) , dedupedFetch ( '/api/users/123' ) , dedupedFetch ( '/api/users/123' ) ] ) ; Offline Queue Queue failed mutations for retry when online: / * @typedef { Object } QueuedRequest * @property { string } url * @property { RequestInit } options * @property { number } timestamp / / * Create an offline-capable mutation queue */ function createOfflineQueue ( ) { const STORAGE_KEY = 'offline-queue' ; / * Load queue from storage * @returns { QueuedRequest [ ] } / function loadQueue ( ) { const stored = localStorage . getItem ( STORAGE_KEY ) ; return stored ? JSON . parse ( stored ) : [ ] ; } / * Save queue to storage * @param { QueuedRequest [ ] } queue / function saveQueue ( queue ) { localStorage . setItem ( STORAGE_KEY , JSON . stringify ( queue ) ) ; } / * Add request to queue * @param { string } url * @param { RequestInit } options */ function enqueue ( url , options ) { const queue = loadQueue ( ) ; queue . push ( { url , options , timestamp : Date . now ( ) } ) ; saveQueue ( queue ) ; } / * Process all queued requests / async function flush ( ) { const queue = loadQueue ( ) ; const failed = [ ] ; for ( const request of queue ) { try { await api ( request . url , request . options ) ; } catch ( error ) { // Keep failed requests for next attempt failed . push ( request ) ; } } saveQueue ( failed ) ; } // Auto-flush when coming online window . addEventListener ( 'online' , flush ) ; return { /* * Execute request, queue if offline * @template T * @param { string } url * @param { RequestInit } options * @returns { Promise < T
} / async execute ( url , options ) { if ( ! navigator . onLine ) { enqueue ( url , options ) ; throw new Error ( 'Request queued - you are offline' ) ; } try { return await api ( url , options ) ; } catch ( error ) { // Queue network errors for retry if ( error . name === 'TypeError' ) { enqueue ( url , options ) ; throw new Error ( 'Request queued - network error' ) ; } throw error ; } } , flush , getQueue : loadQueue } ; } Complete API Client Combining all patterns: / * Create a full-featured API client * @param { Object } config * @param { string } config . baseUrl * @param { number } [ config . timeout = 10000 ] * @param { number } [ config . cacheTtl = 60000 ] * @param { number } [ config . retries = 3 ] / function createApiClient ( config ) { const { baseUrl , timeout = 10000 , cacheTtl = 60000 , retries = 3 } = config ; const cache = new Map ( ) ; const pending = new Map ( ) ; /** * @template T * @param { string } endpoint * @param { RequestInit & { skipCache ? : boolean } } [ options = { } ] * @returns { Promise < T
} */ async function request ( endpoint , options = { } ) { const url =
${ baseUrl } ${ endpoint }; const method = options . method ?. toUpperCase ( ) || 'GET' ; const { skipCache = false , ... fetchOptions } = options ; // Check cache for GET requests if ( method === 'GET' && ! skipCache ) { const cached = cache . get ( url ) ; if ( cached && cached . expiresDate . now ( ) ) { return cached . data ; } } // Dedupe GET requests if ( method === 'GET' && pending . has ( url ) ) { return pending . get ( url ) ; } // Set up abort controller for timeout const controller = new AbortController ( ) ; const timeoutId = setTimeout ( ( ) => controller . abort ( ) , timeout ) ; const promise = fetchWithRetry ( url , { ... fetchOptions , signal : controller . signal } , { retries } ) . then ( data => { // Cache GET responses if ( method === 'GET' ) { cache . set ( url , { data , expires : Date . now ( ) + cacheTtl } ) ; } return data ; } ) . finally ( ( ) => { clearTimeout ( timeoutId ) ; pending . delete ( url ) ; } ) ; if ( method === 'GET' ) { pending . set ( url , promise ) ; } return promise ; } return { get : ( endpoint , options ) => request ( endpoint , options ) , post : ( endpoint , body , options ) => request ( endpoint , { method : 'POST' , body : JSON . stringify ( body ) , ... options } ) , put : ( endpoint , body , options ) => request ( endpoint , { method : 'PUT' , body : JSON . stringify ( body ) , ... options } ) , delete : ( endpoint , options ) => request ( endpoint , { method : 'DELETE' , ... options } ) , invalidateCache : ( pattern ) => { for ( const key of cache . keys ( ) ) { if ( key . includes ( pattern ) ) { cache . delete ( key ) ; } } } } ; } API Client Checklist When implementing API calls: All requests have a timeout AbortController used for cancellation Errors include status code and parsed body Retry only on safe/idempotent operations Cache keys include all relevant parameters Component cleanup aborts in-flight requests Consider offline queue for mutations Use JSDoc types for request/response shapes