profpowell/vanilla-breeze

api-client

Fetch API patterns with error handling, retry logic, and caching. Use when building API integrations, handling network failures, or implementing offline-first data fetching.

First seen Feb 15, 2026

Installation

$ npx skills add profpowell/vanilla-breeze --skill api-client

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from profpowell/vanilla-breeze · top by installs.

npx skills add profpowell/vanilla-breeze

Browse all from profpowell/vanilla-breeze

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 4
License LICENSE
Default branch main
Open issues 0
Status Active

Skill metadata

Parsed from SKILL.md frontmatter.

Allowed toolsRead, Write, Edit, Glob, Grep

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 15,398 B
  • docs SUMMARY.md 191 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 46 installs

SKILL.md

API Client Skill

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

Philosophy

  1. Native fetch() only - No axios or other libraries
  2. Errors should be explicit - No swallowed promises
  3. Requests should be cancellable - Use AbortController
  4. 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: '[email protected]' })
});

// 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.expires > Date.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

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...