/**
* HTTP client for UniSat API
*/
///
import type { ApiResponse, ClientConfig, RequestConfig } from '../types'
import {
ApiClientError,
NetworkError,
HttpError,
TimeoutError,
ParseError,
RateLimitError,
ServiceUnavailableError,
isRetryableError,
getRetryDelay,
} from '../utils/errors'
/**
* HTTP request options
*/
export interface RequestOptions extends RequestConfig {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
body?: any
query?: Record
}
export interface BaseHttpClient {
get(url: string, options?: RequestOptions): Promise
post(url: string, body?: any, options?: RequestOptions): Promise
setBaseURL(baseURL: string): void
setHeaders(headers: Record): void
}
/**
* HTTP client implementation
*/
export class HttpClient implements BaseHttpClient {
private readonly baseURL: string
private readonly defaultConfig: Required>
private readonly defaultHeaders: Record
constructor(config: ClientConfig = {}) {
this.baseURL = config.endpoint || 'https://api.unisat.io'
this.defaultConfig = {
timeout: config.timeout || 30000,
retries: config.retries || 3,
}
this.defaultHeaders = {
'Content-Type': 'application/json',
'User-Agent': config.userAgent || 'UniSat-API-Client/1.0',
'X-Client': 'UniSat Wallet',
...config.headers,
}
if (config.apiKey) {
this.defaultHeaders['Authorization'] = `Bearer ${config.apiKey}`
}
}
/**
* Make an HTTP request
*/
async request(path: string, options: RequestOptions = {}): Promise {
const {
method = 'GET',
body,
query,
timeout = this.defaultConfig.timeout,
retries = this.defaultConfig.retries,
headers = {},
} = options
const url = this.buildUrl(path, query)
const requestHeaders = { ...this.defaultHeaders, ...headers }
let lastError: Error
for (let attempt = 1; attempt <= retries + 1; attempt++) {
try {
const requestInit: RequestInit = {
method,
headers: requestHeaders,
signal: AbortSignal.timeout(timeout),
}
if (body) {
requestInit.body = JSON.stringify(body)
}
const response = await this.makeRequest(url, requestInit)
return await this.handleResponse(response)
} catch (error) {
lastError = error as Error
// Don't retry on the last attempt or non-retryable errors
if (attempt === retries + 1 || !isRetryableError(lastError)) {
break
}
// Wait before retrying
const delay = getRetryDelay(lastError, attempt)
await this.sleep(delay)
}
}
throw lastError!
}
/**
* Make a GET request
*/
async get(
path: string,
options: Omit = {}
): Promise {
return this.request(path, { ...options, method: 'GET' })
}
/**
* Make a POST request
*/
async post(
path: string,
body?: any,
options: Omit = {}
): Promise {
return this.request(path, { ...options, method: 'POST', body })
}
/**
* Make a PUT request
*/
async put(
path: string,
body?: any,
options: Omit = {}
): Promise {
return this.request(path, { ...options, method: 'PUT', body })
}
/**
* Make a DELETE request
*/
async delete(
path: string,
options: Omit = {}
): Promise {
return this.request(path, { ...options, method: 'DELETE' })
}
/**
* Build URL with query parameters
*/
private buildUrl(path: string, query?: Record): string {
// Ensure baseURL ends with / and path doesn't start with /
const normalizedBaseURL = this.baseURL.endsWith('/') ? this.baseURL : this.baseURL + '/'
const normalizedPath = path.startsWith('/') ? path.slice(1) : path
const url = new URL(normalizedPath, normalizedBaseURL)
if (query) {
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.append(key, String(value))
}
})
}
return url.toString()
}
/**
* Make the actual HTTP request
*/
private async makeRequest(url: string, init: RequestInit): Promise {
try {
const response = await fetch(url, init)
return response
} catch (error: any) {
if (error.name === 'AbortError') {
throw new TimeoutError(this.defaultConfig.timeout)
}
throw new NetworkError(`Network error: ${error.message}`, error)
}
}
/**
* Handle HTTP response
*/
private async handleResponse(response: Response): Promise {
// Handle rate limiting
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') || '60')
throw new RateLimitError('Rate limit exceeded', retryAfter)
}
// Handle other HTTP errors
if (!response.ok) {
if (response.status >= 500) {
throw new ServiceUnavailableError(
`Service unavailable: ${response.status} ${response.statusText}`
)
}
throw new HttpError(response.status, response.statusText)
}
// Parse response
let data: any
try {
data = await response.json()
} catch (error) {
throw new ParseError('Failed to parse JSON response', error)
}
// Handle API-level errors
if (this.isApiResponse(data)) {
if (data.code !== 0) {
// Assuming 0 is success
throw new ApiClientError(data.msg || 'API error', data.code)
}
return data.data
}
return data
}
/**
* Check if response is in API format
*/
private isApiResponse(data: any): data is ApiResponse {
return data && typeof data === 'object' && 'code' in data && 'msg' in data
}
/**
* Sleep utility for retries
*/
private sleep(ms: number): Promise {
return new Promise(resolve => setTimeout(resolve, ms))
}
/**
* Update base URL
*/
setBaseURL(baseURL: string): void {
;(this as any).baseURL = baseURL
}
/**
* Update default headers
*/
setHeaders(headers: Record): void {
Object.assign(this.defaultHeaders, headers)
}
/**
* Remove a header
*/
removeHeader(name: string): void {
delete this.defaultHeaders[name]
}
}