@j03fr0st/pubg-ts
Version:
A comprehensive TypeScript wrapper for the PUBG API
459 lines (418 loc) • 15.2 kB
text/typescript
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios';
import { performance } from 'node:perf_hooks';
import {
PubgApiError,
PubgAuthenticationError,
PubgCacheError,
PubgConfigurationError,
PubgNetworkError,
PubgNotFoundError,
PubgRateLimitError,
PubgValidationError,
} from '../errors';
import type { PubgClientConfig } from '../types/api';
import { createCacheKey, globalCache, type MemoryCache } from '../utils/cache';
import { logger, withTiming } from '../utils/logger';
import { monitoringSystem } from '../utils/monitoring';
import { RateLimiter } from '../utils/rate-limiter';
import { RequestDeduplicator } from '../utils/request';
/**
* A robust HTTP client for interacting with the PUBG API.
*
* @remarks
* This client handles rate limiting, caching, request retries, and error handling.
* It is the core component for all API interactions.
*
* @internal
*/
export class HttpClient {
private axios: AxiosInstance;
private rateLimiter: RateLimiter;
private config: PubgClientConfig;
private cache: MemoryCache;
private deduplicator: RequestDeduplicator;
constructor(config: PubgClientConfig) {
this.validateConfig(config);
this.config = config;
this.rateLimiter = new RateLimiter(10, 60000);
this.cache = globalCache;
this.deduplicator = new RequestDeduplicator();
this.axios = axios.create({
baseURL: config.baseUrl || 'https://api.pubg.com',
timeout: config.timeout || 10000,
headers: {
Authorization: `Bearer ${config.apiKey}`,
Accept: 'application/vnd.api+json',
'Content-Type': 'application/json',
},
});
this.setupInterceptors();
logger.client('HTTP client initialized', { shard: config.shard, timeout: config.timeout });
}
private validateConfig(config: PubgClientConfig): void {
if (!config.apiKey || typeof config.apiKey !== 'string') {
throw new PubgConfigurationError(
'API key is required and must be a valid string',
'apiKey',
'string',
config.apiKey
);
}
if (!config.shard || typeof config.shard !== 'string') {
throw new PubgConfigurationError(
'Shard is required and must be a valid string',
'shard',
'string',
config.shard
);
}
if (config.timeout && (typeof config.timeout !== 'number' || config.timeout <= 0)) {
throw new PubgConfigurationError(
'Timeout must be a positive number',
'timeout',
'positive number',
config.timeout
);
}
if (
config.retryAttempts &&
(typeof config.retryAttempts !== 'number' || config.retryAttempts < 0)
) {
throw new PubgConfigurationError(
'Retry attempts must be a non-negative number',
'retryAttempts',
'non-negative number',
config.retryAttempts
);
}
if (config.retryDelay && (typeof config.retryDelay !== 'number' || config.retryDelay < 0)) {
throw new PubgConfigurationError(
'Retry delay must be a non-negative number',
'retryDelay',
'non-negative number',
config.retryDelay
);
}
}
private setupInterceptors(): void {
this.axios.interceptors.request.use(
async (config) => {
await this.rateLimiter.waitForSlot();
// Add request start time for monitoring
(config as any).metadata = {
startTime: performance.now(),
span: monitoringSystem.startSpan('http_request', {
method: config.method?.toUpperCase(),
url: config.url,
endpoint: config.url
})
};
return config;
},
(error) => Promise.reject(error)
);
this.axios.interceptors.response.use(
(response) => {
// Record successful request metrics
const startTime = (response.config as any).metadata?.startTime;
const span = (response.config as any).metadata?.span;
if (startTime) {
const duration = performance.now() - startTime;
monitoringSystem.recordRequestMetrics({
duration,
statusCode: response.status,
endpoint: response.config.url || 'unknown',
method: response.config.method?.toUpperCase() || 'unknown',
error: false
});
}
if (span) {
span.setStatus({ code: 1 }); // OK
span.end();
}
// Update rate limit metrics if available
const remaining = response.headers['x-ratelimit-remaining'];
if (remaining) {
monitoringSystem.updateRateLimitMetrics(parseInt(remaining));
}
return response;
},
async (error) => {
const status = error.response?.status;
const message = error.response?.data?.errors?.[0]?.detail || error.message;
const url = error.config?.url || 'unknown';
// Record error metrics
const startTime = (error.config as any)?.metadata?.startTime;
const span = (error.config as any)?.metadata?.span;
if (startTime) {
const duration = performance.now() - startTime;
monitoringSystem.recordRequestMetrics({
duration,
statusCode: status || 0,
endpoint: url,
method: error.config?.method?.toUpperCase() || 'unknown',
error: true
});
}
if (span) {
span.recordException(error);
span.setStatus({ code: 2, message: error.message }); // ERROR
span.end();
}
// Record error in monitoring system
monitoringSystem.recordError(error, {
endpoint: url,
method: error.config?.method,
statusCode: status
});
// Handle network-level errors (no response received)
if (!error.response) {
return this.handleNetworkError(error, url);
}
switch (status) {
case 401:
throw new PubgAuthenticationError(message, {
operation: 'http_request',
metadata: { url, method: error.config?.method },
});
case 404:
throw new PubgNotFoundError(message, {
operation: 'http_request',
metadata: { url, method: error.config?.method },
});
case 400:
throw new PubgValidationError(message, {
operation: 'http_request',
metadata: { url, method: error.config?.method },
});
case 429: {
const retryAfter = parseInt(error.response?.headers?.['retry-after'] || '60');
throw new PubgRateLimitError(message, retryAfter, {
operation: 'http_request',
metadata: { url, method: error.config?.method, retryAfter },
});
}
case 500:
case 502:
case 503:
case 504:
if (this.config.retryAttempts && this.config.retryAttempts > 0) {
return this.retryRequest(error);
}
throw new PubgNetworkError(`Server error: ${message}`, 'request', error, {
operation: 'http_request',
metadata: { url, method: error.config?.method, statusCode: status },
});
default:
throw new PubgApiError(message, status, error.response?.data, {
operation: 'http_request',
metadata: { url, method: error.config?.method },
});
}
}
);
}
private handleNetworkError(error: any, url: string): never {
const code = error.code;
const message = error.message;
switch (code) {
case 'ECONNREFUSED':
throw new PubgNetworkError(`Connection refused: ${message}`, 'connect', error, {
operation: 'network_connect',
metadata: { url, errorCode: code },
});
case 'ENOTFOUND':
case 'EAI_AGAIN':
throw new PubgNetworkError(`DNS lookup failed: ${message}`, 'dns', error, {
operation: 'network_dns',
metadata: { url, errorCode: code },
});
case 'ECONNRESET':
case 'ECONNABORTED':
throw new PubgNetworkError(`Connection reset: ${message}`, 'connect', error, {
operation: 'network_connect',
metadata: { url, errorCode: code },
});
case 'ETIMEDOUT':
throw new PubgNetworkError(`Request timeout: ${message}`, 'timeout', error, {
operation: 'network_timeout',
metadata: { url, errorCode: code, timeout: this.config.timeout },
});
case 'CERT_HAS_EXPIRED':
case 'UNABLE_TO_VERIFY_LEAF_SIGNATURE':
throw new PubgNetworkError(`SSL certificate error: ${message}`, 'ssl', error, {
operation: 'network_ssl',
metadata: { url, errorCode: code },
});
default:
throw new PubgNetworkError(`Network error: ${message}`, 'unknown', error, {
operation: 'network_unknown',
metadata: { url, errorCode: code },
});
}
}
private async retryRequest(error: any, attempt: number = 1): Promise<AxiosResponse> {
if (attempt > (this.config.retryAttempts || 3)) {
// Convert to network error if it's a network issue
if (!error.response) {
const url = error.config?.url || 'unknown';
throw new PubgNetworkError(
`Max retry attempts reached: ${error.message}`,
'request',
error,
{
operation: 'network_retry_exhausted',
metadata: { url, maxAttempts: this.config.retryAttempts || 3, attempt },
}
);
}
throw error;
}
const delay = (this.config.retryDelay || 1000) * 2 ** (attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delay));
try {
return await this.axios.request(error.config);
} catch (retryError) {
return this.retryRequest(retryError, attempt + 1);
}
}
/**
* Performs a GET request.
*
* @param url - The URL to request.
* @param config - Optional request configuration, including cache control.
* @returns A promise that resolves with the response data.
* @template T - The expected response data type.
*/
async get<T>(url: string, config?: AxiosRequestConfig & { useCache?: boolean }): Promise<T> {
const useCache = config?.useCache !== false; // Default to true
const cacheKey = createCacheKey('http', 'GET', url, JSON.stringify(config?.params || {}));
// Check cache first for GET requests
if (useCache) {
try {
const cached = this.cache.get<T>(cacheKey);
if (cached !== undefined) {
return cached;
}
} catch (error) {
throw new PubgCacheError(
`Failed to retrieve from cache: ${error instanceof Error ? error.message : 'Unknown error'}`,
cacheKey,
'get',
{
operation: 'cache_get',
metadata: { url, method: 'GET' },
}
);
}
}
return this.deduplicator.deduplicate(cacheKey, async () => {
const response = await withTiming(logger.http, `GET ${url}`, async () => {
return await this.axios.get<T>(url, config);
});
// Cache successful GET responses
if (useCache && response && response.status === 200) {
try {
this.cache.set(cacheKey, response.data, 5 * 60 * 1000); // 5 minute cache
} catch (error) {
// Log cache set error but don't fail the request
logger.http(
`Cache set failed for ${cacheKey}: ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
}
return response?.data;
});
}
/**
* Performs a POST request.
*
* @param url - The URL to request.
* @param data - The data to send in the request body.
* @param config - Optional request configuration.
* @returns A promise that resolves with the response data.
* @template T - The expected response data type.
*/
async post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await this.axios.post<T>(url, data, config);
return response.data;
}
/**
* Performs a PUT request.
*
* @param url - The URL to request.
* @param data - The data to send in the request body.
* @param config - Optional request configuration.
* @returns A promise that resolves with the response data.
* @template T - The expected response data type.
*/
async put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await this.axios.put<T>(url, data, config);
return response.data;
}
/**
* Performs a DELETE request.
*
* @param url - The URL to request.
* @param config - Optional request configuration.
* @returns A promise that resolves with the response data.
* @template T - The expected response data type.
*/
async delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await this.axios.delete<T>(url, config);
return response.data;
}
/**
* Gets the current rate limit status.
*
* @returns An object containing the remaining requests and the reset time.
*/
getRateLimitStatus() {
return {
remaining: this.rateLimiter.getRemainingRequests(),
resetTime: this.rateLimiter.getResetTime(),
};
}
/**
* Gets statistics about the cache performance.
*
* @returns An object containing cache statistics, including size, hits, misses, and hit rate.
*/
getCacheStats() {
return this.cache.getStats();
}
/**
* Performs a GET request to an external URL (not using the base URL).
*
* @param url - The full URL to request.
* @param config - Optional request configuration.
* @returns A promise that resolves with the response data.
* @template T - The expected response data type.
*/
async getExternal<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await axios.get<T>(url, {
...config,
timeout: this.config.timeout || 10000,
});
return response.data;
}
/**
* Clears the entire cache.
*/
clearCache() {
try {
this.cache.clear();
} catch (error) {
throw new PubgCacheError(
`Failed to clear cache: ${error instanceof Error ? error.message : 'Unknown error'}`,
'all',
'cleanup',
{
operation: 'cache_clear',
}
);
}
}
}