import * as path from 'path'; import {getActiveCredential, runtimeConfig} from './Config'; import {die} from './die'; export type Method = 'POST' | 'PUT' | 'PATCH' | 'GET' | 'DELETE'; /** * Low level http handler for interacting with the Rivendell api. */ export namespace RivendellApi { interface ApiResponse { response: Response; body: T; } export class ApiError extends Error { constructor(message: string, public responseText: string, public response?: Response) { super(message); } } export async function get(uri: string): Promise> { return request('GET', uri); } export async function post(uri: string, body: any): Promise> { return request('POST', uri, body); } export async function put(uri: string, body: any): Promise> { return request('PUT', uri, body); } export async function delete_(uri: string): Promise> { return request('DELETE', uri); } const MAX_RETRIES = 2; function isSocketError(error: unknown): boolean { if (error instanceof Error && 'cause' in error) { const cause = error.cause as any; return cause?.code === 'UND_ERR_SOCKET' || cause?.code === 'ECONNRESET' || cause?.code === 'EPIPE'; } return false; } function shouldRetryException(method: Method, error: unknown): boolean { // Check if error is an ApiError with a status code if (error instanceof ApiError && error.response?.status) { const status = error.response.status; // Only retry 5xx errors for GET requests, never retry 3xx/4xx return method === 'GET' && status >= 500; } // For non-ApiError exceptions (network failures, etc.) if (method === 'GET') { // For GET: retry on network exceptions return true; } else { // For other methods: only retry on socket errors (idempotent concern) return isSocketError(error); } } function shouldRetryResponse(method: Method, status: number): boolean { // Only retry 5xx responses for GET requests return method === 'GET' && status >= 500; } export async function request(method: Method, uri: string, body?: any): Promise> { const url = `${runtimeConfig().rivendell}/${uri}`; const requestPayload: RequestInit = { method }; requestPayload.headers = { 'x-api-key': loadApiKey(), 'x-cli-version': require(path.join(__dirname, '../oo-cli.manifest.json'))['package']['version'] }; if (body && method !== 'GET') { // @ts-ignore requestPayload.headers['content-type'] = 'application/json'; requestPayload.body = JSON.stringify(body); } let lastError: unknown; let lastResponseText: string | undefined; let lastStatus: number | undefined; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { try { debug(`[RivendellApi] ${method} ${url}${attempt > 0 ? ` (retry ${attempt})` : ''}`); const response = await fetch(url, requestPayload); debug(`[RivendellApi] Response ${response.status}`); const responseText = await response.text(); // Check for 5xx and retry if applicable if (shouldRetryResponse(method, response.status) && attempt < MAX_RETRIES) { debug(`[RivendellApi] Server error ${response.status}, retrying...`); lastStatus = response.status; lastResponseText = responseText; continue; } if (response.status < 200 || response.status >= 300) { const errorMsg = `Received a ${response.status} from OCP: ${getErrorMessage(response.status)}`; throw new ApiError(errorMsg, responseText, response); } return { response, body: responseText ? JSON.parse(responseText) : '' }; } catch (error) { lastError = error; if (shouldRetryException(method, error) && attempt < MAX_RETRIES) { debug(`[RivendellApi] ${isSocketError(error) ? 'Socket error' : 'Request failed'}, retrying...`); continue; } throw error; } } // Exhausted retries - throw appropriate error if (lastError) { debug(lastError); throw lastError; } if (lastStatus !== undefined) { const errorMsg = `Received a ${lastStatus} from OCP after ${MAX_RETRIES} retries: ${getErrorMessage(lastStatus)}`; throw new ApiError(errorMsg, lastResponseText || '', undefined); } throw new Error('Unexpected: no error or response after retries'); } function loadApiKey(): string { const apiKey = getActiveCredential().apiKey; if (apiKey == null) { die( 'Your API key is not configured in ~/.ocp/credentials.json. ' + 'Please assign your API key to the property apiKey in the config file.' ); } return apiKey!; } function getErrorMessage(code: number): string { switch (code) { case 400: return 'Bad request.'; case 403: return 'Access denied.'; case 404: return 'Not found.'; case 500: return 'Internal service error.'; case 502: return 'Bad gateway'; case 503: return 'Service unavailable. Please try again.'; case 504: return 'Request timed out. Please try again.'; default: return 'Unhandled error.'; } } function debug(...args: any[]) { if (process.env.OCP_DEBUG === '1') { console.debug(...args); } } }