api-client

Patterns for fetch-based API communication with robust error handling, retry logic, and response caching.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "api-client" with this command: npx skills add profpowell/vanilla-breeze/profpowell-vanilla-breeze-api-client

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 &#x26;&#x26; !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 &#x26;&#x26; 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' &#x26;&#x26; !skipCache) {
  const cached = cache.get(url);
  if (cached &#x26;&#x26; cached.expires > Date.now()) {
    return cached.data;
  }
}

// Dedupe GET requests
if (method === 'GET' &#x26;&#x26; 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

Related Skills

  • state-management - Client-side state patterns for Web Components

  • authentication - Implement secure authentication with JWT, sessions, OAuth...

  • rest-api - Write REST API endpoints with HTTP methods, status codes,...

  • observability - Implement error tracking, performance monitoring, and use...

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

validation

No summary provided by upstream source.

Repository SourceNeeds Review
General

fake-content

No summary provided by upstream source.

Repository SourceNeeds Review
General

service-worker

No summary provided by upstream source.

Repository SourceNeeds Review
General

i18n

No summary provided by upstream source.

Repository SourceNeeds Review