borsa / nextjs-app /src /lib /http.ts
veteroner's picture
feat: live position monitoring with charts + trading system production ready
656ac31
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD'
export type JsonRecord = Record<string, unknown>
export class ApiError extends Error {
name = 'ApiError'
url: string
status?: number
details?: unknown
constructor(message: string, opts: { url: string; status?: number; details?: unknown }) {
super(message)
this.url = opts.url
this.status = opts.status
this.details = opts.details
}
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function isRetryableStatus(status?: number) {
if (!status) return true
return status === 408 || status === 425 || status === 429 || (status >= 500 && status <= 599)
}
export type FetchJsonOptions = {
timeoutMs?: number
retries?: number
retryDelayMs?: number
retryMethods?: HttpMethod[]
headers?: HeadersInit
/** If provided, body will be JSON-stringified and content-type set. */
jsonBody?: unknown
/** If true, sends cookies/credentials (only for same-site or allowed CORS). */
credentials?: RequestCredentials
}
export async function fetchJson<T>(url: string, init?: RequestInit, opts?: FetchJsonOptions): Promise<T> {
const timeoutMs = opts?.timeoutMs ?? 12000
const retries = opts?.retries ?? 1
const retryDelayMs = opts?.retryDelayMs ?? 600
const retryMethods = opts?.retryMethods ?? ['GET', 'HEAD']
const method = ((init?.method ?? 'GET').toUpperCase() as HttpMethod) || 'GET'
const shouldRetryMethod = retryMethods.includes(method)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
const headers: HeadersInit = {
...(opts?.jsonBody !== undefined ? { 'Content-Type': 'application/json' } : null),
...(opts?.headers ?? null),
...(init?.headers ?? null),
}
const requestInit: RequestInit = {
...init,
headers,
signal: controller.signal,
credentials: opts?.credentials ?? init?.credentials,
body:
opts?.jsonBody !== undefined
? JSON.stringify(opts.jsonBody)
: init?.body,
}
let lastError: unknown
try {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const res = await fetch(url, requestInit)
if (!res.ok) {
let details: unknown = undefined
const contentType = res.headers.get('content-type') || ''
try {
details = contentType.includes('application/json') ? await res.json() : await res.text()
} catch {
// ignore
}
const detailMessage =
details && typeof details === 'object' && 'error' in (details as Record<string, unknown>)
? String((details as Record<string, unknown>).error)
: undefined
const err = new ApiError(detailMessage || `API Error: ${res.status}`, { url, status: res.status, details })
if (attempt < retries && shouldRetryMethod && isRetryableStatus(res.status)) {
await sleep(retryDelayMs * (attempt + 1))
continue
}
throw err
}
const contentType = res.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
return (await res.json()) as T
}
// Allow endpoints returning plain text
return (await res.text()) as unknown as T
} catch (error) {
lastError = error
const isAbort = error instanceof DOMException && error.name === 'AbortError'
if (attempt < retries && shouldRetryMethod && !isAbort) {
await sleep(retryDelayMs * (attempt + 1))
continue
}
if (isAbort) {
throw new ApiError(`API Timeout after ${timeoutMs}ms`, { url })
}
throw error
}
}
throw lastError instanceof Error
? lastError
: new ApiError('Unknown API error', { url })
} finally {
clearTimeout(timeout)
}
}