feat: implement base adapter with retry logic and caching

- Add BaseAdapter class with request/response transformation
- Implement comprehensive error handling and normalization
- Add retry logic with exponential backoff for network/server errors
- Support request cancellation and deduplication
- Include response caching with configurable TTL
- Add full test coverage for adapter functionality
This commit is contained in:
Justin Edmund 2025-09-19 23:03:36 -07:00
parent ea00cecd68
commit 51c30edc50
7 changed files with 1909 additions and 0 deletions

View file

@ -0,0 +1,595 @@
/**
* Tests for the BaseAdapter class
*
* These tests verify the core functionality of the adapter system,
* including request/response transformation, error handling, and caching.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { BaseAdapter } from '../base.adapter'
import { ApiError, NetworkError } from '../errors'
import type { AdapterOptions } from '../types'
/**
* Test adapter implementation for testing BaseAdapter functionality
*/
class TestAdapter extends BaseAdapter {
constructor(options?: AdapterOptions) {
super(options)
}
// Expose protected methods for testing
async testRequest<T>(path: string, options?: any): Promise<T> {
return this.request<T>(path, options)
}
testTransformResponse<T>(data: any): T {
return this.transformResponse<T>(data)
}
testTransformRequest(data: any): any {
return this.transformRequest(data)
}
testClearCache(pattern?: string): void {
this.clearCache(pattern)
}
}
/**
* Fast test adapter with minimal retry delays for testing
*/
class FastRetryAdapter extends BaseAdapter {
constructor(options?: AdapterOptions) {
super(options)
}
// Override delay for instant retries in tests
protected delay(ms: number): Promise<void> {
// Instant return for fast tests
return Promise.resolve()
}
async testRequest<T>(path: string, options?: any): Promise<T> {
return this.request<T>(path, options)
}
}
describe('BaseAdapter', () => {
let adapter: TestAdapter
let originalFetch: typeof global.fetch
beforeEach(() => {
// Save original fetch
originalFetch = global.fetch
// Create a new adapter instance for each test
adapter = new TestAdapter({
baseURL: 'https://api.example.com',
timeout: 5000
})
})
afterEach(() => {
// Restore original fetch
global.fetch = originalFetch
// Cancel any pending requests
adapter.cancelAll()
})
describe('constructor', () => {
it('should initialize with default options', () => {
const defaultAdapter = new TestAdapter()
expect(defaultAdapter).toBeDefined()
})
it('should accept custom options', () => {
const customAdapter = new TestAdapter({
baseURL: 'https://custom.api.com',
timeout: 10000,
retries: 5,
cacheTime: 60000
})
expect(customAdapter).toBeDefined()
})
})
describe('transformResponse', () => {
it('should transform snake_case to camelCase', () => {
const input = {
user_name: 'test',
created_at: '2024-01-01',
nested_object: {
inner_field: 'value'
}
}
const result = adapter.testTransformResponse(input)
expect(result).toEqual({
userName: 'test',
createdAt: '2024-01-01',
nestedObject: {
innerField: 'value'
}
})
})
it('should handle null and undefined values', () => {
expect(adapter.testTransformResponse(null)).toBeNull()
expect(adapter.testTransformResponse(undefined)).toBeUndefined()
})
it('should transform arrays', () => {
const input = [
{ user_id: 1, user_name: 'Alice' },
{ user_id: 2, user_name: 'Bob' }
]
const result = adapter.testTransformResponse(input)
expect(result).toEqual([
{ userId: 1, userName: 'Alice' },
{ userId: 2, userName: 'Bob' }
])
})
})
describe('transformRequest', () => {
it('should transform camelCase to snake_case', () => {
const input = {
userName: 'test',
createdAt: '2024-01-01',
nestedObject: {
innerField: 'value'
}
}
const result = adapter.testTransformRequest(input)
expect(result).toEqual({
user_name: 'test',
created_at: '2024-01-01',
nested_object: {
inner_field: 'value'
}
})
})
it('should handle null and undefined values', () => {
expect(adapter.testTransformRequest(null)).toBeNull()
expect(adapter.testTransformRequest(undefined)).toBeUndefined()
})
})
describe('request', () => {
it('should make a successful GET request', async () => {
// Mock successful response
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, user_name: 'test' })
})
const result = await adapter.testRequest('/users/1')
expect(global.fetch).toHaveBeenCalledWith(
'https://api.example.com/users/1',
expect.objectContaining({
credentials: 'include',
headers: expect.objectContaining({
'Content-Type': 'application/json'
})
})
)
// Should transform the response
expect(result).toEqual({ success: true, userName: 'test' })
})
it('should make a POST request with body', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: 1, created: true })
})
const body = { userName: 'test', email: 'test@example.com' }
await adapter.testRequest('/users', {
method: 'POST',
body: JSON.stringify(body)
})
expect(global.fetch).toHaveBeenCalledWith(
'https://api.example.com/users',
expect.objectContaining({
method: 'POST',
// Body should be transformed to snake_case
body: JSON.stringify({ user_name: 'test', email: 'test@example.com' })
})
)
})
it('should add query parameters to URL', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ results: [] })
})
await adapter.testRequest('/search', {
params: {
query: 'test',
page: 2,
filters: [1, 2, 3]
}
})
const calledUrl = (global.fetch as any).mock.calls[0][0]
expect(calledUrl).toContain('query=test')
expect(calledUrl).toContain('page=2')
expect(calledUrl).toContain('filters=1')
expect(calledUrl).toContain('filters=2')
expect(calledUrl).toContain('filters=3')
})
it('should handle error responses', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
json: async () => ({ error: 'User not found' })
})
await expect(adapter.testRequest('/users/999')).rejects.toMatchObject({
name: 'AdapterError',
code: 'NOT_FOUND',
status: 404,
message: 'User not found'
})
})
it('should handle network errors', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'))
await expect(adapter.testRequest('/users')).rejects.toMatchObject({
name: 'AdapterError',
code: 'UNKNOWN_ERROR',
status: 0
})
})
it('should handle request cancellation', async () => {
// Mock a delayed response that respects AbortSignal
global.fetch = vi.fn().mockImplementation((url, options) =>
new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
resolve({
ok: true,
json: async () => ({ data: 'test' })
})
}, 100)
// Listen for abort signal
if (options?.signal) {
options.signal.addEventListener('abort', () => {
clearTimeout(timeout)
const error = new Error('The operation was aborted')
error.name = 'AbortError'
reject(error)
})
}
})
)
// Start request
const promise = adapter.testRequest('/slow')
// Cancel immediately
adapter.cancelAll()
// Should throw cancelled error
await expect(promise).rejects.toMatchObject({
code: 'CANCELLED',
message: 'Request was cancelled'
})
})
it('should cancel duplicate requests to the same endpoint', async () => {
let abortHandlers: Array<() => void> = []
// Mock sequential requests with proper abort handling
let callCount = 0
global.fetch = vi.fn().mockImplementation((url, options) => {
callCount++
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
resolve({
ok: true,
json: async () => ({ data: `response-${callCount}` })
})
}, 50)
// Store abort handler
if (options?.signal) {
const handler = () => {
clearTimeout(timeout)
const error = new Error('The operation was aborted')
error.name = 'AbortError'
reject(error)
}
options.signal.addEventListener('abort', handler)
abortHandlers[callCount - 1] = handler
}
})
})
// Start first request
const promise1 = adapter.testRequest('/api/data')
// Wait a bit to ensure first request is in progress
await new Promise(resolve => setTimeout(resolve, 10))
// Start second request to same endpoint (should cancel first)
const promise2 = adapter.testRequest('/api/data')
// First should be cancelled
await expect(promise1).rejects.toMatchObject({
code: 'CANCELLED'
})
// Second should succeed
const result = await promise2
expect(result).toEqual({ data: 'response-2' })
})
})
describe('retry logic', () => {
it('should retry on network errors', async () => {
let attempts = 0
global.fetch = vi.fn().mockImplementation(async () => {
attempts++
if (attempts < 3) {
// First two attempts: network error
const error = new Error('Network error')
error.name = 'NetworkError'
throw error
} else {
// Third attempt: succeed
return {
ok: true,
json: async () => ({ success: true })
}
}
})
const quickAdapter = new FastRetryAdapter({
baseURL: 'https://api.example.com',
retries: 3
})
// This should retry and eventually succeed
const result = await quickAdapter.testRequest('/retry')
// Verify it retried
expect(attempts).toBe(3)
expect(result).toEqual({ success: true })
})
it('should not retry on client errors', async () => {
let attempts = 0
global.fetch = vi.fn().mockImplementation(() => {
attempts++
return Promise.resolve({
ok: false,
status: 400,
statusText: 'Bad Request',
json: async () => ({ error: 'Invalid input' })
})
})
await expect(adapter.testRequest('/bad')).rejects.toMatchObject({
code: 'BAD_REQUEST',
status: 400
})
// Should not retry on 400 error
expect(attempts).toBe(1)
})
it('should retry on server errors', async () => {
let attempts = 0
global.fetch = vi.fn().mockImplementation(async () => {
attempts++
if (attempts < 2) {
return {
ok: false,
status: 503,
statusText: 'Service Unavailable',
json: async () => ({ error: 'Server down' })
}
}
return {
ok: true,
json: async () => ({ success: true })
}
})
const quickAdapter = new FastRetryAdapter({
baseURL: 'https://api.example.com',
retries: 2
})
// This should retry and eventually succeed
const result = await quickAdapter.testRequest('/server')
// Verify it retried
expect(attempts).toBe(2)
expect(result).toEqual({ success: true })
})
})
describe('caching', () => {
it('should cache GET requests when cacheTime is set', async () => {
let fetchCount = 0
global.fetch = vi.fn().mockImplementation(() => {
fetchCount++
return Promise.resolve({
ok: true,
json: async () => ({ data: 'cached', count: fetchCount })
})
})
// Make first request with caching
const result1 = await adapter.testRequest('/cached', {
cache: 60000 // 1 minute
})
// Make second request (should use cache)
const result2 = await adapter.testRequest('/cached', {
cache: 60000
})
// Only one fetch should have been made
expect(fetchCount).toBe(1)
expect(result1).toEqual(result2)
})
it('should cache POST requests with same body when cache is enabled', async () => {
let fetchCount = 0
global.fetch = vi.fn().mockImplementation(() => {
fetchCount++
return Promise.resolve({
ok: true,
json: async () => ({ count: fetchCount })
})
})
// Make first POST request with cache
const result1 = await adapter.testRequest('/data', {
method: 'POST',
cache: 60000,
body: JSON.stringify({ test: true })
})
// Make second POST request with same body (should use cache)
const result2 = await adapter.testRequest('/data', {
method: 'POST',
cache: 60000,
body: JSON.stringify({ test: true })
})
// Only one request should have been made
expect(fetchCount).toBe(1)
expect(result1).toEqual(result2)
// Make POST request with different body (should not use cache)
await adapter.testRequest('/data', {
method: 'POST',
cache: 60000,
body: JSON.stringify({ test: false })
})
// Now two requests should have been made
expect(fetchCount).toBe(2)
})
it('should clear cache', async () => {
let fetchCount = 0
global.fetch = vi.fn().mockImplementation(() => {
fetchCount++
return Promise.resolve({
ok: true,
json: async () => ({ count: fetchCount })
})
})
// Make cached request
await adapter.testRequest('/cached', { cache: 60000 })
// Clear cache
adapter.testClearCache()
// Make request again (should fetch again)
await adapter.testRequest('/cached', { cache: 60000 })
expect(fetchCount).toBe(2)
})
it('should clear cache by pattern', async () => {
let fetchCount = 0
global.fetch = vi.fn().mockImplementation(() => {
fetchCount++
return Promise.resolve({
ok: true,
json: async () => ({ data: 'test' })
})
})
// Cache multiple endpoints
await adapter.testRequest('/users/1', { cache: 60000 })
await adapter.testRequest('/posts/1', { cache: 60000 })
// Clear only user cache
adapter.testClearCache('users')
// Users should refetch, posts should use cache
await adapter.testRequest('/users/1', { cache: 60000 })
await adapter.testRequest('/posts/1', { cache: 60000 })
// 3 total fetches: initial 2 + 1 refetch for users
expect(fetchCount).toBe(3)
})
})
describe('error handling', () => {
it('should call global error handler', async () => {
const errorHandler = vi.fn()
const adapterWithHandler = new TestAdapter({
baseURL: 'https://api.example.com',
onError: errorHandler
})
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: async () => ({ error: 'Server error' })
})
await expect(adapterWithHandler.testRequest('/error')).rejects.toThrow()
expect(errorHandler).toHaveBeenCalledWith(
expect.objectContaining({
code: 'SERVER_ERROR',
status: 500,
message: 'Server error'
})
)
})
it('should handle non-JSON error responses', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: async () => {
throw new Error('Invalid JSON')
}
})
await expect(adapter.testRequest('/error')).rejects.toMatchObject({
code: 'SERVER_ERROR',
status: 500,
message: 'Internal Server Error'
})
})
})
})

View file

@ -0,0 +1,6 @@
/**
* Mock for SvelteKit's $env module
* Used in tests to provide environment variables
*/
export const PUBLIC_SIERO_API_URL = 'http://localhost:3000/api/v1'

View file

@ -0,0 +1,484 @@
/**
* Base Adapter for API Communication
*
* This class provides the foundation for all API adapters in the application.
* It handles common concerns like request/response transformation, error handling,
* request cancellation, and retry logic.
*
* @module adapters/base
*/
import { snakeToCamel, camelToSnake } from '../schemas/transforms'
import { API_BASE } from '../core'
import type { AdapterOptions, RequestOptions, AdapterError } from './types'
import {
createErrorFromStatus,
normalizeError,
isRetryableError,
calculateRetryDelay,
CancelledError
} from './errors'
/**
* Base adapter class that all resource-specific adapters extend from.
* Provides core functionality for API communication with built-in features:
* - Automatic snake_case to camelCase transformation
* - Request cancellation and deduplication
* - Exponential backoff retry logic
* - Normalized error handling
*
* @example
* ```typescript
* class UserAdapter extends BaseAdapter {
* async getUser(id: string) {
* return this.request(`/users/${id}`)
* }
* }
* ```
*/
export abstract class BaseAdapter {
/** Map of request IDs to their abort controllers for cancellation */
protected abortControllers = new Map<string, AbortController>()
/** Cache for storing request responses */
protected cache = new Map<string, { data: any; expires: number }>()
/** Configuration options for the adapter */
protected options: Required<AdapterOptions>
/**
* Creates a new adapter instance
*
* @param options - Configuration options for the adapter
* @param options.baseURL - Base URL for API requests (defaults to API_BASE)
* @param options.timeout - Default timeout for requests in milliseconds
* @param options.retries - Number of retry attempts for failed requests
* @param options.cacheTime - Default cache duration in milliseconds
* @param options.onError - Global error handler callback
*/
constructor(options: AdapterOptions = {}) {
this.options = {
baseURL: options.baseURL ?? API_BASE,
timeout: options.timeout ?? 30000,
retries: options.retries ?? 3,
cacheTime: options.cacheTime ?? 0,
onError: options.onError
}
}
/**
* Makes an HTTP request with automatic transformation and error handling
*
* @template T - The expected response type
* @param path - The API endpoint path (relative to baseURL)
* @param options - Request configuration options
* @returns Promise resolving to the transformed response data
* @throws {AdapterError} When the request fails or returns an error status
*
* @example
* ```typescript
* const data = await this.request<User>('/users/123', {
* method: 'GET',
* cache: 60000 // Cache for 1 minute
* })
* ```
*/
protected async request<T>(
path: string,
options: RequestOptions = {}
): Promise<T> {
// Build the full URL with query parameters
const url = this.buildURL(path, options.params)
// Generate a unique ID for this request (used for cancellation and caching)
const requestId = this.generateRequestId(path, options.method, options.body as string)
// Check cache first if caching is enabled
const cacheTime = options.cache ?? this.options.cacheTime
// Allow caching for any method if explicitly set
if (cacheTime > 0) {
const cached = this.getFromCache(requestId)
if (cached !== null) {
return cached as T
}
}
// Cancel any existing request to the same endpoint
this.cancelRequest(requestId)
// Create new abort controller for this request
const controller = new AbortController()
this.abortControllers.set(requestId, controller)
// Prepare request options
const fetchOptions: RequestInit = {
credentials: 'include', // Default: Include cookies for authentication
...options, // Allow overriding defaults
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
...(options.headers || {})
}
}
// Transform request body from camelCase to snake_case if present
if (options.body && typeof options.body === 'string') {
try {
const bodyData = JSON.parse(options.body)
fetchOptions.body = JSON.stringify(this.transformRequest(bodyData))
} catch {
// If body is not valid JSON, use as-is
fetchOptions.body = options.body
}
}
try {
// Make the request with retry logic (errors handled inside fetchWithRetry)
const response = await this.fetchWithRetry(url, fetchOptions, options.retries)
// Parse and transform the response
const data = await response.json()
const transformed = this.transformResponse<T>(data)
// Cache the successful response if caching is enabled
if (cacheTime > 0) {
this.setCache(requestId, transformed, cacheTime)
}
return transformed
} catch (error: any) {
// Handle request cancellation
if (error.name === 'AbortError') {
throw new CancelledError().toJSON()
}
// Error is already normalized from fetchWithRetry (or handleErrorResponse)
// Only normalize if it's not already an AdapterError structure
const normalizedError = error.name === 'AdapterError' ? error : normalizeError(error)
// Call global error handler if provided
if (this.options.onError) {
this.options.onError(normalizedError)
}
throw normalizedError
} finally {
// Clean up the abort controller
this.abortControllers.delete(requestId)
}
}
/**
* Transforms response data from snake_case to camelCase
*
* @template T - The expected response type
* @param data - Raw response data from the API
* @returns Transformed data with camelCase property names
*/
protected transformResponse<T>(data: any): T {
if (data === null || data === undefined) {
return data
}
// Apply snake_case to camelCase transformation
return snakeToCamel(data) as T
}
/**
* Transforms request data from camelCase to snake_case
*
* @param data - Request data with camelCase property names
* @returns Transformed data with snake_case property names
*/
protected transformRequest(data: any): any {
if (data === null || data === undefined) {
return data
}
// Apply camelCase to snake_case transformation
return camelToSnake(data)
}
/**
* Cancels a pending request by its ID
*
* @param requestId - The unique identifier of the request to cancel
*/
protected cancelRequest(requestId: string): void {
const controller = this.abortControllers.get(requestId)
if (controller) {
controller.abort()
this.abortControllers.delete(requestId)
}
}
/**
* Cancels all pending requests
* Useful for cleanup when unmounting components or changing views
*/
cancelAll(): void {
// Abort all pending requests
this.abortControllers.forEach(controller => controller.abort())
this.abortControllers.clear()
}
/**
* Performs a fetch request with automatic retry on failure
*
* @param url - The URL to fetch
* @param options - Fetch options
* @param maxRetries - Maximum number of retry attempts
* @param attempt - Current attempt number (internal use)
* @returns Promise resolving to the fetch Response
*/
private async fetchWithRetry(
url: string,
options: RequestInit,
maxRetries?: number,
attempt = 1
): Promise<Response> {
const retries = maxRetries ?? this.options.retries
try {
// Add timeout to the request if specified
let response: Response
if (this.options.timeout > 0) {
const timeoutId = setTimeout(() => {
const controller = Array.from(this.abortControllers.values())
.find(c => c.signal === options.signal)
controller?.abort()
}, this.options.timeout)
response = await fetch(url, options)
clearTimeout(timeoutId)
} else {
response = await fetch(url, options)
}
// Check if response has an error status that should be retried
if (!response.ok) {
const error = await this.handleErrorResponse(response)
// Check if this error is retryable
if (attempt < retries && isRetryableError(error)) {
// Calculate delay with exponential backoff and jitter
const delay = calculateRetryDelay(attempt, error)
// Wait before retrying
await this.delay(delay)
// Recursive retry
return this.fetchWithRetry(url, options, retries, attempt + 1)
}
// Not retryable or max retries reached
throw error
}
return response
} catch (error: any) {
// Don't retry on abort
if (error.name === 'AbortError') {
throw error
}
// Normalize the error to check if it's retryable
const normalizedError = normalizeError(error)
// Check if we should retry (handles both network errors and HTTP errors)
if (attempt < retries && isRetryableError(normalizedError)) {
// Calculate delay with exponential backoff and jitter
const delay = calculateRetryDelay(attempt, normalizedError)
// Wait before retrying
await this.delay(delay)
// Recursive retry
return this.fetchWithRetry(url, options, retries, attempt + 1)
}
// Max retries reached or non-retryable error
// Throw the normalized error
throw normalizedError
}
}
/**
* Delays execution for a specified duration
* Used for retry backoff
*
* @param ms - Milliseconds to delay
* @returns Promise that resolves after the delay
*/
protected delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
/**
* Builds a complete URL from a path and optional query parameters
*
* @param path - The API endpoint path
* @param params - Optional query parameters
* @returns The complete URL string
*/
private buildURL(path: string, params?: Record<string, any>): string {
// Handle absolute URLs
if (path.startsWith('http://') || path.startsWith('https://')) {
const url = new URL(path)
this.addQueryParams(url, params)
return url.toString()
}
// Build URL from base URL and path
const baseURL = this.options.baseURL.replace(/\/$/, '') // Remove trailing slash
const cleanPath = path.startsWith('/') ? path : `/${path}`
const url = new URL(`${baseURL}${cleanPath}`)
this.addQueryParams(url, params)
return url.toString()
}
/**
* Adds query parameters to a URL object
*
* @param url - The URL object to modify
* @param params - Query parameters to add
*/
private addQueryParams(url: URL, params?: Record<string, any>): void {
if (!params) return
Object.entries(params).forEach(([key, value]) => {
// Skip undefined and null values
if (value === undefined || value === null) return
// Handle arrays by adding multiple params with the same key
if (Array.isArray(value)) {
value.forEach(item => {
url.searchParams.append(key, String(item))
})
} else {
url.searchParams.set(key, String(value))
}
})
}
/**
* Generates a unique identifier for a request
* Used for request cancellation and caching
*
* @param path - The request path
* @param method - The HTTP method
* @param body - Optional request body for cache key generation
* @returns A unique request identifier
*/
private generateRequestId(path: string, method = 'GET', body?: string): string {
const base = `${this.constructor.name}:${method}:${path}`
// For POST/PUT/PATCH requests with body, include body hash in cache key
if (body && ['POST', 'PUT', 'PATCH'].includes(method)) {
// Simple hash of body for cache key
const bodyHash = this.simpleHash(body)
return `${base}:${bodyHash}`
}
return base
}
/**
* Generates a simple hash of a string for cache key generation
* Not cryptographically secure, just for cache differentiation
*
* @param str - String to hash
* @returns Hash string
*/
private simpleHash(str: string): string {
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash = hash & hash // Convert to 32bit integer
}
return hash.toString(36)
}
/**
* Handles error responses from the API
* Attempts to parse error details from the response body
*
* @param response - The error response
* @returns An AdapterError with normalized error information
*/
private async handleErrorResponse(response: Response): Promise<AdapterError> {
let details: any = undefined
let message = response.statusText
try {
// Try to parse error details from response body
const errorData = await response.json()
// Extract error message from various possible formats
message = errorData.message ||
errorData.error ||
errorData.errors?.[0]?.message ||
response.statusText
details = errorData
} catch {
// If response body is not JSON, use status text
}
// Use our error utility to create the appropriate error type
return createErrorFromStatus(response.status, message, details).toJSON()
}
/**
* Gets data from cache if it exists and hasn't expired
*
* @param key - The cache key
* @returns The cached data or null if not found/expired
*/
private getFromCache(key: string): any | null {
const entry = this.cache.get(key)
if (!entry) return null
// Check if cache has expired
if (Date.now() > entry.expires) {
this.cache.delete(key)
return null
}
return entry.data
}
/**
* Stores data in cache with expiration time
*
* @param key - The cache key
* @param data - The data to cache
* @param ttl - Time to live in milliseconds
*/
private setCache(key: string, data: any, ttl: number): void {
this.cache.set(key, {
data,
expires: Date.now() + ttl
})
}
/**
* Clears the cache
*
* @param pattern - Optional pattern to match keys for selective clearing
*/
clearCache(pattern?: string): void {
if (pattern) {
// Clear only matching keys
for (const key of this.cache.keys()) {
if (key.includes(pattern)) {
this.cache.delete(key)
}
}
} else {
// Clear all cache
this.cache.clear()
}
}
}

View file

@ -0,0 +1,403 @@
/**
* Error handling utilities for the adapter system
*
* This module provides custom error classes and utility functions
* for consistent error handling across all adapters.
*
* @module adapters/errors
*/
import type { AdapterError } from './types'
/**
* Custom error class for adapter-specific errors
* Extends the native Error class with additional properties
*/
export class ApiError extends Error implements AdapterError {
name: 'AdapterError' = 'AdapterError'
code: string
status: number
details?: any
/**
* Creates a new ApiError instance
*
* @param code - Error code (e.g., 'NOT_FOUND', 'UNAUTHORIZED')
* @param status - HTTP status code
* @param message - Human-readable error message
* @param details - Additional error details from the API
*/
constructor(code: string, status: number, message: string, details?: any) {
super(message)
this.code = code
this.status = status
this.details = details
// Maintains proper stack trace for where error was thrown
if (Error.captureStackTrace) {
Error.captureStackTrace(this, ApiError)
}
}
/**
* Converts the error to a plain object
* Useful for serialization and logging
*/
toJSON(): AdapterError {
return {
name: this.name,
code: this.code,
status: this.status,
message: this.message,
details: this.details
}
}
/**
* Creates an ApiError from a plain object
* Useful for deserializing errors from API responses
*/
static fromJSON(json: any): ApiError {
return new ApiError(
json.code || 'UNKNOWN_ERROR',
json.status || 0,
json.message || 'An unknown error occurred',
json.details
)
}
}
/**
* Error class for network-related failures
*/
export class NetworkError extends ApiError {
constructor(message = 'Network request failed', details?: any) {
super('NETWORK_ERROR', 0, message, details)
this.name = 'NetworkError' as any
}
}
/**
* Error class for request timeout
*/
export class TimeoutError extends ApiError {
constructor(timeout: number, details?: any) {
super('TIMEOUT', 0, `Request timed out after ${timeout}ms`, details)
this.name = 'TimeoutError' as any
}
}
/**
* Error class for request cancellation
*/
export class CancelledError extends ApiError {
constructor(details?: any) {
super('CANCELLED', 0, 'Request was cancelled', details)
this.name = 'CancelledError' as any
}
}
/**
* Error class for validation failures
*/
export class ValidationError extends ApiError {
constructor(message: string, details?: any) {
super('VALIDATION_ERROR', 422, message, details)
this.name = 'ValidationError' as any
}
}
/**
* Error class for authentication failures
*/
export class AuthenticationError extends ApiError {
constructor(message = 'Authentication required', details?: any) {
super('UNAUTHORIZED', 401, message, details)
this.name = 'AuthenticationError' as any
}
}
/**
* Error class for authorization failures
*/
export class AuthorizationError extends ApiError {
constructor(message = 'Access denied', details?: any) {
super('FORBIDDEN', 403, message, details)
this.name = 'AuthorizationError' as any
}
}
/**
* Error class for resource not found
*/
export class NotFoundError extends ApiError {
constructor(resource?: string, details?: any) {
const message = resource ? `${resource} not found` : 'Resource not found'
super('NOT_FOUND', 404, message, details)
this.name = 'NotFoundError' as any
}
}
/**
* Error class for conflict errors (e.g., duplicate resources)
*/
export class ConflictError extends ApiError {
constructor(message = 'Resource conflict', details?: any) {
super('CONFLICT', 409, message, details)
this.name = 'ConflictError' as any
}
}
/**
* Error class for rate limiting
*/
export class RateLimitError extends ApiError {
retryAfter?: number
constructor(retryAfter?: number, details?: any) {
const message = retryAfter
? `Rate limit exceeded. Retry after ${retryAfter} seconds`
: 'Rate limit exceeded'
super('RATE_LIMITED', 429, message, details)
this.name = 'RateLimitError' as any
this.retryAfter = retryAfter
}
}
/**
* Maps HTTP status codes to specific error classes
*
* @param status - HTTP status code
* @param message - Error message
* @param details - Additional error details
* @returns Appropriate error instance based on status code
*/
export function createErrorFromStatus(
status: number,
message?: string,
details?: any
): ApiError {
switch (status) {
case 400:
return new ApiError('BAD_REQUEST', status, message || 'Bad request', details)
case 401:
return new AuthenticationError(message, details)
case 403:
return new AuthorizationError(message, details)
case 404:
// Pass the message to NotFoundError if provided
return message ? new ApiError('NOT_FOUND', 404, message, details) : new NotFoundError(undefined, details)
case 409:
return new ConflictError(message, details)
case 422:
return new ValidationError(message || 'Validation failed', details)
case 429:
// Try to extract retry-after header from details
const retryAfter = details?.headers?.['retry-after']
return new RateLimitError(retryAfter, details)
case 500:
return new ApiError('SERVER_ERROR', status, message || 'Internal server error', details)
case 502:
return new ApiError('BAD_GATEWAY', status, message || 'Bad gateway', details)
case 503:
return new ApiError('SERVICE_UNAVAILABLE', status, message || 'Service unavailable', details)
case 504:
return new ApiError('GATEWAY_TIMEOUT', status, message || 'Gateway timeout', details)
default:
// For any other status codes
if (status >= 400 && status < 500) {
return new ApiError('CLIENT_ERROR', status, message || 'Client error', details)
} else if (status >= 500) {
return new ApiError('SERVER_ERROR', status, message || 'Server error', details)
}
return new ApiError('UNKNOWN_ERROR', status, message || 'Unknown error', details)
}
}
/**
* Determines if an error is retryable based on its type and code
*
* @param error - The error to check
* @returns True if the error is retryable
*/
export function isRetryableError(error: any): boolean {
// Network-like conditions are retryable
// Handle both class instances and normalized plain objects
if (
error instanceof NetworkError ||
error instanceof TimeoutError ||
error?.name === 'NetworkError' ||
error?.code === 'NETWORK_ERROR' ||
// Some environments normalize to status 0 without specific codes
error?.status === 0
) {
return true
}
// Rate limit errors are retryable after delay
if (error instanceof RateLimitError) {
return true
}
// Check by error code (handles both ApiError instances and plain objects)
// Note: NetworkError sets name to 'NetworkError' but still has AdapterError structure
if (error instanceof ApiError || error?.name === 'AdapterError' || error?.name === 'NetworkError') {
const retryableCodes = [
'NETWORK_ERROR',
'TIMEOUT',
'GATEWAY_TIMEOUT',
'SERVICE_UNAVAILABLE',
'BAD_GATEWAY',
'SERVER_ERROR'
]
if (retryableCodes.includes(error.code)) {
return true
}
// Server errors (5xx) are generally retryable
if (error.status >= 500 && error.status < 600) {
return true
}
}
// Check for specific error properties
if (error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
return true
}
// Client errors (4xx) are not retryable except rate limits
if (error.status >= 400 && error.status < 500) {
return error.status === 429 // Only rate limit is retryable
}
return false
}
/**
* Normalizes various error types into a consistent AdapterError structure
*
* @param error - Any error type
* @returns Normalized AdapterError
*/
export function normalizeError(error: any): AdapterError {
// Already an AdapterError
if (error?.name === 'AdapterError') {
return error
}
// ApiError instance
if (error instanceof ApiError) {
return error.toJSON()
}
// Fetch abort error
if (error?.name === 'AbortError') {
return new CancelledError().toJSON()
}
// Network error
if (error?.name === 'NetworkError' || error?.name === 'TypeError') {
return new NetworkError(error.message).toJSON()
}
// Timeout error
if (error?.name === 'TimeoutError') {
return new TimeoutError(0, error).toJSON()
}
// Generic Error with status
if (error?.status) {
return createErrorFromStatus(
error.status,
error.message || error.statusText,
error
).toJSON()
}
// Fallback to generic error
return new ApiError(
error?.code || 'UNKNOWN_ERROR',
error?.status || 0,
error?.message || 'An unknown error occurred',
error
).toJSON()
}
/**
* Extracts user-friendly error message from an error
*
* @param error - The error to extract message from
* @returns User-friendly error message
*/
export function getErrorMessage(error: any): string {
if (!error) {
return 'An unknown error occurred'
}
// Try to get message from various error formats
const message = error.message ||
error.error ||
error.errors?.[0]?.message ||
error.statusText ||
'An unknown error occurred'
// Make network errors more user-friendly
if (message.includes('NetworkError') || message.includes('Failed to fetch')) {
return 'Unable to connect to the server. Please check your internet connection.'
}
if (message.includes('TimeoutError')) {
return 'The request took too long. Please try again.'
}
if (message.includes('AbortError') || message.includes('cancelled')) {
return 'The request was cancelled.'
}
return message
}
/**
* Calculates retry delay based on attempt number and error type
*
* @param attempt - Current attempt number (1-indexed)
* @param error - The error that triggered the retry
* @param baseDelay - Base delay in milliseconds (default: 1000)
* @param maxDelay - Maximum delay in milliseconds (default: 30000)
* @returns Delay in milliseconds before next retry
*/
export function calculateRetryDelay(
attempt: number,
error: any,
baseDelay = 1000,
maxDelay = 30000
): number {
// Use retry-after header for rate limit errors
if (error instanceof RateLimitError && error.retryAfter) {
return error.retryAfter * 1000
}
// Exponential backoff: 1s, 2s, 4s, 8s, ...
let delay = Math.pow(2, attempt - 1) * baseDelay
// Add jitter (±25%) to prevent thundering herd
const jitter = delay * 0.25
delay = delay + (Math.random() * jitter * 2 - jitter)
// Cap at maximum delay
return Math.min(delay, maxDelay)
}

View file

@ -0,0 +1,41 @@
import { beforeAll, afterAll, afterEach } from 'vitest'
// Optional MSW setup to support future adapter tests without adding a hard dependency
let mockServer: any = null
let http: any = null
let HttpResponse: any = null
async function ensureMSW() {
if (mockServer) return
try {
const mswNode = await import('msw/node')
const msw = await import('msw')
mockServer = mswNode.setupServer()
http = msw.http
HttpResponse = msw.HttpResponse
} catch (e) {
// MSW is not installed; skip server wiring
mockServer = null
}
}
beforeAll(async () => {
await ensureMSW()
if (mockServer) mockServer.listen({ onUnhandledRequest: 'error' })
})
afterEach(() => {
if (mockServer) mockServer.resetHandlers()
})
afterAll(() => {
if (mockServer) mockServer.close()
})
// Helper to add mock handlers for POST endpoints under /api/v1
export function mockAPI(path: string, response: any, status = 200) {
if (!mockServer || !http || !HttpResponse) return
mockServer.use(
http.post(`*/api/v1${path}`, () => {
return HttpResponse.json(response, { status })
})
)
}

View file

@ -0,0 +1,347 @@
/**
* Type definitions for the adapter layer
*
* This module contains all the TypeScript interfaces and types used
* throughout the adapter system. These types ensure type safety and
* provide clear contracts for adapter implementations.
*
* @module adapters/types
*/
/**
* Configuration options for adapter instances
*/
export interface AdapterOptions {
/** Base URL for API requests. Defaults to the app's API base URL */
baseURL?: string
/** Default timeout for requests in milliseconds. Defaults to 30000 (30 seconds) */
timeout?: number
/** Number of retry attempts for failed requests. Defaults to 3 */
retries?: number
/** Default cache duration in milliseconds. Set to 0 to disable caching */
cacheTime?: number
/** Global error handler callback. Called when any request fails */
onError?: (error: AdapterError) => void
}
/**
* Options for individual HTTP requests
* Extends the standard RequestInit interface with additional features
*/
export interface RequestOptions extends Omit<RequestInit, 'body'> {
/** Query parameters to append to the URL */
params?: Record<string, any>
/** Request timeout in milliseconds. Overrides the adapter's default timeout */
timeout?: number
/** Number of retry attempts for this specific request */
retries?: number
/** Cache duration for this request in milliseconds. Only applies to GET requests */
cache?: number
/** Request body. Can be any serializable value */
body?: any
}
/**
* Normalized error structure for all adapter errors
* Provides consistent error handling across the application
*/
export interface AdapterError {
/** Error name, always 'AdapterError' for identification */
name: 'AdapterError'
/** Normalized error code (e.g., 'NOT_FOUND', 'UNAUTHORIZED') */
code: string
/** HTTP status code if applicable, 0 otherwise */
status: number
/** Human-readable error message */
message: string
/** Additional error details from the API response */
details?: any
}
/**
* Generic paginated response structure
* Used for endpoints that return paginated data
*/
export interface PaginatedResponse<T> {
/** Array of items for the current page */
items: T[]
/** Total number of items across all pages */
total: number
/** Current page number (1-indexed) */
page: number
/** Total number of pages */
totalPages: number
/** Number of items per page */
perPage: number
/** Whether there are more pages available */
hasMore: boolean
/** Cursor or page number for the next page, if available */
nextCursor?: string | number
/** Cursor or page number for the previous page, if available */
prevCursor?: string | number
}
/**
* Base response wrapper for API responses
* Some endpoints wrap their data in a standard structure
*/
export interface ApiResponse<T> {
/** The actual data payload */
data: T
/** Metadata about the response */
meta?: {
/** Pagination information if applicable */
pagination?: {
page: number
perPage: number
total: number
totalPages: number
}
/** Response timestamp */
timestamp?: number
/** API version */
version?: string
}
/** Error information if the request failed */
error?: {
code: string
message: string
details?: any
}
}
/**
* Search filter options used across different search endpoints
*/
export interface SearchFilters {
/** Filter by element IDs */
element?: number[]
/** Filter by rarity levels */
rarity?: number[]
/** Filter by primary proficiency (weapons and characters) */
proficiency1?: number[]
/** Filter by secondary proficiency (characters only) */
proficiency2?: number[]
/** Filter by series */
series?: number[]
/** Include extra/seasonal variants */
extra?: boolean
/** Filter summons with sub-aura */
subaura?: boolean
/** Filter special characters */
special?: boolean
/** Custom filters for specific use cases */
[key: string]: any
}
/**
* Parameters for search operations
*/
export interface SearchParams {
/** The type of entity to search */
type: 'weapon' | 'character' | 'summon'
/** Search query string */
query?: string
/** Filters to apply to the search */
filters?: SearchFilters
/** Page number for pagination (1-indexed) */
page?: number
/** Number of items per page */
perPage?: number
/** Locale for localized content */
locale?: 'en' | 'ja'
/** Items to exclude from results (by ID) */
exclude?: string[]
/** AbortSignal for request cancellation */
signal?: AbortSignal
}
/**
* Unified search result structure
*/
export interface SearchResult<T = any> {
/** Unique identifier */
id: string
/** Granblue Fantasy game ID */
granblueId: string
/** Item name (localized or string) */
name: { en?: string; ja?: string } | string
/** Display name (computed from name) */
displayName?: string
/** Element ID (0-6) */
element?: number
/** Rarity level */
rarity?: number
/** Primary proficiency (weapons) */
proficiency?: number
/** Series ID */
series?: number
/** URL to the item's image */
imageUrl?: string
/** Type of the search result */
type: 'weapon' | 'character' | 'summon'
/** The full entity data */
data?: T
}
/**
* Cache entry structure
*/
export interface CacheEntry<T = any> {
/** The cached data */
data: T
/** Timestamp when the cache entry expires */
expires: number
/** Optional tags for cache invalidation */
tags?: string[]
}
/**
* Request deduplication entry
*/
export interface PendingRequest {
/** The promise for the pending request */
promise: Promise<any>
/** AbortController for cancelling the request */
controller: AbortController
/** Timestamp when the request started */
startTime: number
}
/**
* Retry configuration for specific error types
*/
export interface RetryConfig {
/** Maximum number of retry attempts */
maxAttempts: number
/** Base delay in milliseconds for exponential backoff */
baseDelay: number
/** Maximum delay in milliseconds */
maxDelay: number
/** Jitter factor (0-1) to randomize delays */
jitter?: number
/** Error codes that should trigger a retry */
retryableCodes?: string[]
/** HTTP status codes that should trigger a retry */
retryableStatuses?: number[]
}
/**
* Transformation options for request/response data
*/
export interface TransformOptions {
/** Whether to transform snake_case to camelCase */
transformCase?: boolean
/** Whether to rename 'object' fields to entity names */
renameObjectFields?: boolean
/** Custom transformation function */
customTransform?: (data: any) => any
}
/**
* Resource state for Runed integration
*/
export interface ResourceState<T> {
/** The current data */
data: T | null
/** Whether the resource is currently loading */
loading: boolean
/** Error if the resource failed to load */
error: Error | null
/** Whether the resource is being refreshed */
refreshing: boolean
/** Timestamp of the last successful fetch */
lastFetch?: number
}
/**
* Options for creating reactive resources with Runed
*/
export interface ResourceOptions<T> {
/** Initial data value */
initialData?: T
/** Whether to fetch immediately on creation */
immediate?: boolean
/** Debounce delay for reactive updates */
debounce?: number
/** Cache time in milliseconds */
cacheTime?: number
/** Whether to keep previous data while fetching */
keepPreviousData?: boolean
/** Callback when data is fetched successfully */
onSuccess?: (data: T) => void
/** Callback when fetch fails */
onError?: (error: Error) => void
/** Dependencies that trigger refetch when changed */
dependencies?: any[]
}

33
vitest.config.adapter.ts Normal file
View file

@ -0,0 +1,33 @@
import { defineConfig } from 'vitest/config'
import { resolve } from 'path'
export default defineConfig({
test: {
name: 'adapters',
include: ['src/lib/api/adapters/**/*.test.ts'],
environment: 'node',
globals: true,
setupFiles: ['./src/lib/api/adapters/test-setup.ts'],
testTimeout: 5000,
// Use forked pool in CI/sandbox to avoid worker kill EPERM issues
pool: (process.env.VITEST_POOL as any) || (process.env.CI || process.env.SEATBELT ? 'forks' : undefined),
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/lib/api/adapters/**/*.ts'],
exclude: [
'**/*.test.ts',
'**/__tests__/**',
'**/__fixtures__/**',
'**/test-setup.ts'
]
}
},
resolve: {
alias: {
'$lib': resolve('./src/lib'),
'$types': resolve('./src/lib/types'),
'$env/static/public': resolve('./src/lib/api/adapters/__tests__/env-mock.ts')
}
}
})