UNPKG

@sudowealth/schwab-api

Version:

TypeScript client for Charles Schwab API with OAuth support, market data, trading functionality, and complete type safety

216 lines (215 loc) 11 kB
import { createSchwabApiError, SchwabError, SchwabRateLimitError, SchwabApiError, isAuthError, extractErrorMetadata, isCommunicationError, } from '../errors'; import { createLogger } from '../utils/secure-logger'; import { getMetadata, cloneRequestWithMetadata } from './middleware-metadata'; const logger = createLogger('Retry'); const DEFAULT_MAX_RETRIES = 3; const DEFAULT_BASE_MS = 1000; const JITTER_FACTOR = 0.1; // 10% jitter /** * Default retry options */ const DEFAULT_RETRY_OPTIONS = { maxAttempts: DEFAULT_MAX_RETRIES, baseDelayMs: DEFAULT_BASE_MS, advanced: { respectRetryAfter: true, maxDelayMs: 30000, skipRateLimitOnRetry: true, }, }; /** * Creates middleware that automatically retries failed requests * * @param options Options for configuring retry behavior * @returns Middleware function */ export function withRetry(options) { // Merge provided options with defaults const config = { ...DEFAULT_RETRY_OPTIONS, ...options, advanced: { ...DEFAULT_RETRY_OPTIONS.advanced, ...options?.advanced, }, }; // Extract configuration values const maxRetries = config.maxAttempts; const baseDelayMs = config.baseDelayMs; const respectRetryAfter = config.advanced?.respectRetryAfter ?? true; const maxDelayMs = config.advanced?.maxDelayMs ?? 30000; const skipRateLimitOnRetry = config.advanced?.skipRateLimitOnRetry ?? true; return async (req, next) => { // Get or initialize metadata const metadata = getMetadata(req); // Check if retry info already exists (this is a retry attempt) const isRetryAttempt = !!metadata.retry?.isRetry; // Set or increment attempt counter if (!metadata.retry) { metadata.retry = { isRetry: false, attemptNumber: 1, maxAttempts: maxRetries + 1, // +1 because the initial attempt counts skipRateLimit: false, }; } let attempts = metadata.retry.attemptNumber || 1; let lastError; while (attempts <= maxRetries + 1) { // +1 for initial attempt try { // Clone the request to preserve the body content and add retry metadata const requestClone = cloneRequestWithMetadata(req); const requestMetadata = getMetadata(requestClone); // Update retry metadata requestMetadata.retry = { isRetry: isRetryAttempt || attempts > 1, attemptNumber: attempts, maxAttempts: maxRetries + 1, skipRateLimit: skipRateLimitOnRetry && (isRetryAttempt || attempts > 1), }; const response = await next(requestClone); // Check for error status codes that should trigger a retry if (response.status === 429 || response.status >= 500) { // Extract metadata from response headers to make smarter retry decisions const responseMetadata = extractErrorMetadata(response); // Create appropriate error type based on status code let errorBody; try { errorBody = await response.clone().json(); } catch { // If we can't parse JSON, use text try { errorBody = await response.clone().text(); } catch { // If all else fails, use a simple object errorBody = { message: `Response status: ${response.status}` }; } } // Create appropriate typed error based on status with metadata const error = createSchwabApiError(response.status, errorBody, (() => { switch (response.status) { case 429: return `withRetry: Rate limit exceeded (attempt ${attempts}/${maxRetries + 1})`; case 503: return `withRetry: Service unavailable (attempt ${attempts}/${maxRetries + 1})`; case 502: return `withRetry: Bad gateway (attempt ${attempts}/${maxRetries + 1})`; case 504: return `withRetry: Gateway timeout (attempt ${attempts}/${maxRetries + 1})`; case 500: return `withRetry: Server error (attempt ${attempts}/${maxRetries + 1})`; default: return `withRetry: HTTP error ${response.status} (attempt ${attempts}/${maxRetries + 1})`; } })(), responseMetadata); // Check if the error is retryable if (!error.isRetryable({ ignoreRetryAfter: !respectRetryAfter })) { return response; // Not a retryable error } // Check if we've reached max retries if (attempts > maxRetries) { // Add retry information to response metadata const responseMetadata = getMetadata(response); responseMetadata.retry = { isRetry: true, attemptNumber: attempts, maxAttempts: maxRetries + 1, skipRateLimit: false, // No longer relevant }; return response; // Max retries reached, return the last response } lastError = error; // Consume the response body to free up resources, even if not used if (response.body) { try { await response.text(); } catch { // Ignore errors from consuming the body } } } else { // Success or non-retryable status code // Add retry information to response metadata const responseMetadata = getMetadata(response); responseMetadata.retry = { isRetry: isRetryAttempt || attempts > 1, attemptNumber: attempts, maxAttempts: maxRetries + 1, skipRateLimit: false, // No longer relevant }; return response; } } catch (error) { // Convert to SchwabError if it's not already one if (error instanceof SchwabError) { lastError = error; } else if (error instanceof Error) { lastError = new SchwabApiError(500, { message: error.message, stack: error.stack }, `withRetry: Request failed (attempt ${attempts}/${maxRetries + 1}): ${error.message}`); lastError.originalError = error; } else { lastError = new SchwabApiError(500, { message: String(error) }, `withRetry: Request failed (attempt ${attempts}/${maxRetries + 1}): ${String(error)}`); } // Handle authentication errors with their own isRetryable method if (isAuthError(lastError) && !lastError.isRetryable()) { throw lastError; // Non-retryable auth error, rethrow immediately } // Handle communication errors (network/timeout) specially else if (isCommunicationError(lastError)) { // Communication errors are always retryable, but we still check // to maintain consistent behavior if (!lastError.isRetryable()) { throw lastError; } // Add debug logging for communication errors const errorType = lastError.cause === 'network' ? 'Network' : 'Timeout'; logger.warn(`${errorType} error (${attempts}/${maxRetries + 1}). Will retry...`); } // Handle API errors with their built-in isRetryable method else if (lastError instanceof SchwabApiError && !lastError.isRetryable()) { throw lastError; // Non-retryable API error, rethrow immediately } if (attempts > maxRetries) { throw lastError; // Max retries reached, throw the last error } } attempts++; metadata.retry.attemptNumber = attempts; // Calculate delay - prefer server-provided retry information if available let serverSuggestedDelay = 0; if (lastError instanceof SchwabApiError && respectRetryAfter) { const retryDelayMs = lastError.getRetryDelayMs(); if (retryDelayMs !== null) { serverSuggestedDelay = retryDelayMs; } } // Use exponential backoff with jitter as fallback let calculatedDelay = baseDelayMs * Math.pow(2, attempts - 2); // -2 because we've already incremented attempts const jitter = calculatedDelay * JITTER_FACTOR * (Math.random() - 0.5) * 2; // -10% to +10% calculatedDelay = Math.max(0, calculatedDelay + jitter); // Use the greater of server-suggested or calculated delay, but cap at maxDelayMs const totalDelay = Math.min(Math.max(serverSuggestedDelay, calculatedDelay), maxDelayMs); // Check if there was rate-limiting involved to provide better logging if (lastError instanceof SchwabRateLimitError) { logger.warn(`Rate limit exceeded (${attempts - 1}/${maxRetries + 1}). Retrying in ${totalDelay}ms...`); } await new Promise((resolve) => setTimeout(resolve, totalDelay)); } // Should not be reached if logic is correct, but as a fallback: if (lastError) throw lastError; // This line should ideally not be hit if the loop and conditions are correct. // If it is, it means a state was reached that wasn't expected. // For instance, if maxRetries is 0, the loop might not behave as expected. // Consider maxRetries=0 means 1 attempt. throw createSchwabApiError(500, { message: 'Exited retry loop unexpectedly' }, 'withRetry: Exited retry loop unexpectedly.'); }; }