solver-sdk
Version:
SDK for WorkAI API - AI-powered code analysis with WorkCoins billing system
505 lines • 25.9 kB
JavaScript
import axios from 'axios';
import { ErrorMapper } from './error-mapper.js';
import { BaseSDKError } from '../errors/sdk-errors.js';
/**
* Определение типа среды выполнения
* @returns 'browser' | 'node'
*/
function getEnvironment() {
return (typeof window !== 'undefined' && typeof window.document !== 'undefined')
? 'browser'
: 'node';
}
export class HttpClient {
/**
* Создает новый HTTP клиент
* @param {string} baseURL Базовый URL для запросов
* @param {HttpClientOptions} [options] Опции для HTTP клиента
*/
constructor(baseURL, options = {}) {
/** 🔐 401 Interceptor: Request Queue для параллельных запросов */
this.requestQueue = [];
/** 🔐 401 Interceptor: Флаг выполнения refresh токена */
this.isRefreshing = false;
/** 🔐 401 Interceptor: Promise текущего refresh для синхронизации */
this.refreshPromise = null;
this.baseURL = baseURL;
this.options = {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
...(options.headers || {})
},
timeout: options.timeout || 30000,
retry: options.retry || {
maxRetries: 3,
retryDelay: 1000,
maxRetryDelay: 10000,
retryStatusCodes: [408, 429, 500, 502, 503, 504]
},
httpsAgent: options.httpsAgent,
// ✅ КРИТИЧЕСКОЕ ИСПРАВЛЕНИЕ: Сохраняем getAuthToken callback!
// Без этого SDK не может динамически обновлять токены
getAuthToken: options.getAuthToken
};
this.environment = getEnvironment();
// Создаем Axios инстанс с базовыми настройками
this.axiosInstance = axios.create({
baseURL: this.baseURL,
timeout: this.options.timeout,
headers: this.options.headers,
...(this.environment === 'node' && this.options.httpsAgent ? { httpsAgent: this.options.httpsAgent } : {})
});
// Добавляем интерцептор для обработки ошибок и повторных попыток
this.setupInterceptors();
}
/**
* 🔐 Retry всех запросов из очереди с новым токеном
* Вызывается после успешного refresh для обработки concurrent 401 requests
*/
processQueue(newToken) {
// WorkAI: Убрали лог - не критично, queue обрабатывается автоматически
// Для диагностики: включить debug: 'verbose' в SDK options
this.requestQueue.forEach((queuedRequest) => {
if (!queuedRequest.config.headers) {
queuedRequest.config.headers = {};
}
queuedRequest.config.headers['Authorization'] = `Bearer ${newToken}`;
this.axiosInstance(queuedRequest.config)
.then((response) => queuedRequest.resolve(response))
.catch((error) => queuedRequest.reject(error));
});
this.requestQueue = [];
}
/**
* 🔐 Reject всех запросов из очереди при ошибке refresh
*/
rejectQueue(error) {
console.error(`❌ [SDK] Rejecting ${this.requestQueue.length} queued requests due to refresh failure`);
this.requestQueue.forEach((queuedRequest) => {
queuedRequest.reject(error);
});
this.requestQueue = [];
}
/**
* Настраивает интерцепторы Axios
*/
setupInterceptors() {
// Интерцептор для ответов
this.axiosInstance.interceptors.response.use((response) => response.data, async (error) => {
const { config, response } = error;
// WorkAI: Убрали избыточную диагностику - ошибки маппятся через ErrorMapper
// Для диагностики: включить debug: 'verbose' в SDK options
// ✅ ЦЕНТРАЛИЗОВАННЫЙ МАППИНГ через ErrorMapper
const mappedError = ErrorMapper.mapError(error);
// ═══════════════════════════════════════════════════════════════════════════
// 🔐 401 INTERCEPTOR: Автоматический refresh + retry
// ═══════════════════════════════════════════════════════════════════════════
//
// МЕХАНИЗМ РАБОТЫ (стандартный OAuth 2.0 flow):
//
// 1. Клиент делает запрос с access token
// 2. Access token истёк → сервер вернул 401 Unauthorized
// 3. SDK перехватывает 401 → вызывает getAuthToken()
// 4. getAuthToken() делает refresh запрос (POST /oauth/token)
// 5. Сервер возвращает новую пару токенов (access + refresh)
// 6. SDK повторяет оригинальный запрос с новым access token
// 7. Запрос успешен → пользователь не видел ошибку
//
// ЗАЧЕМ: Без этого клиент получает 401 → показывает ошибку → требует re-login
//
// ЗАЩИТЫ:
// - config._retry → infinite loop protection (один retry на запрос)
// - isRefreshing + requestQueue → concurrent refresh protection (один refresh для N запросов)
// ═══════════════════════════════════════════════════════════════════════════
if (response?.status === 401 && config && !config._retry && this.options.getAuthToken) {
// WorkAI: Оставляем только в development - 401 важная, но редкая ошибка
if (process.env.NODE_ENV === 'development') {
console.log('🔐 [SDK] 401 Unauthorized detected, attempting token refresh...');
}
// ЗАЧЕМ config._retry: Без этого при втором 401 получим infinite loop (401→refresh→401→refresh→...)
// Пример: Если refresh не помог (токен протух на сервере) - должны выбросить ошибку, а не зациклиться
config._retry = true;
// ⏳ Concurrent refresh protection: добавляем в queue если refresh уже выполняется
// ЗАЧЕМ: 10 параллельных запросов получили 401 одновременно → без queue будет 10 refresh вызовов
// С queue: только 1 refresh, остальные 9 ждут и используют новый токен
if (this.isRefreshing) {
// WorkAI: Убрали лог - queue работает автоматически
return new Promise((resolve, reject) => {
this.requestQueue.push({ resolve, reject, config });
});
}
this.isRefreshing = true;
try {
// Вызываем getAuthToken для получения нового токена
// getAuthToken() внутри вызовет VS Code Auth API → тот вызовет provider.refreshToken()
// provider.refreshToken() → POST /api/v1/oauth/token → новая пара токенов
// WorkAI: Убрали лог - getAuthToken вызов не критичен для production
const newToken = await Promise.resolve(this.options.getAuthToken());
if (!newToken) {
throw new Error('Failed to get new auth token');
}
// WorkAI: Оставляем лог об успешном refresh - важно для диагностики
if (process.env.NODE_ENV === 'development') {
console.log(`✅ [SDK] Token refreshed successfully: ${newToken.substring(0, 10)}...`);
}
// Retry flow: обновляем header, обрабатываем queue, повторяем запрос
// ЗАЧЕМ processQueue ДО retry: Queued requests должны стартовать одновременно с текущим
// Иначе: текущий запрос выполнится → вернёт результат → только потом queue (медленно)
if (!config.headers) {
config.headers = {};
}
config.headers['Authorization'] = `Bearer ${newToken}`;
this.processQueue(newToken);
return this.axiosInstance(config);
}
catch (refreshError) {
console.error('❌ [SDK] Token refresh failed:', refreshError);
this.rejectQueue(refreshError);
throw new Error('Authentication failed. Please sign in again.');
}
finally {
// Cleanup: следующий 401 запустит новый refresh цикл
this.isRefreshing = false;
this.refreshPromise = null;
}
}
// RETRY LOGIC: Если нет конфига запроса или это уже повторный запрос, или skipRetry установлен
if (!config || config._retryCount || config.skipRetry) {
throw mappedError;
}
config._retryCount = config._retryCount || 0;
const retryConfig = this.options.retry;
// Проверяем, нужно ли делать повторную попытку для данного статуса
const shouldRetry = response &&
retryConfig.retryStatusCodes?.includes(response.status) &&
config._retryCount < (retryConfig.maxRetries || 3);
if (shouldRetry) {
config._retryCount += 1;
// Вычисляем время задержки перед повторной попыткой
const delay = Math.min((retryConfig.retryDelay || 1000) * Math.pow(2, config._retryCount - 1), retryConfig.maxRetryDelay || 10000);
// Ждем перед повторной попыткой
await new Promise(resolve => setTimeout(resolve, delay));
// Делаем повторную попытку
return this.axiosInstance(config);
}
// Если не нужно делать повторную попытку, выбрасываем типизированную ошибку
throw mappedError;
});
}
/**
* Выполняет HTTP запрос
* @param {RequestOptions} options Опции запроса
* @returns {Promise<T>} Ответ от API
*/
async request(options) {
// 🔑 Динамическое получение токена перед запросом
let authHeaders = {};
if (this.options.getAuthToken) {
try {
// WorkAI: Убрали логи getAuthToken - вызываются при каждом request, спамят
// Для диагностики: включить debug: 'verbose' в SDK options
const token = await Promise.resolve(this.options.getAuthToken());
if (token) {
authHeaders['Authorization'] = `Bearer ${token}`;
}
else {
console.warn(`⚠️ [SDK] request() getAuthToken returned null/undefined`);
}
}
catch (error) {
// Если не удалось получить токен, продолжаем без него
console.warn('⚠️ [SDK] request() Failed to get auth token:', error);
}
}
// WorkAI: Убрали лог "NO getAuthToken callback" - не нужен
const axiosConfig = {
url: options.url,
method: options.method,
data: options.data,
params: options.params,
headers: {
...this.options.headers,
...authHeaders, // ← Динамический токен имеет приоритет
...(options.headers || {})
},
timeout: options.timeout || this.options.timeout
};
// Если указано не делать повторные попытки, добавляем специальный флаг
if (options.noRetry) {
axiosConfig.skipRetry = true;
}
try {
return await this.axiosInstance.request(axiosConfig);
}
catch (error) {
// ✅ Если это уже SDK ошибка от interceptor - пробрасываем как есть
if (error instanceof BaseSDKError) {
throw error;
}
// ✅ Иначе маппим через ErrorMapper (для ошибок вне interceptor)
throw ErrorMapper.mapError(error);
}
}
/**
* Выполняет GET запрос
* @param {string} url URL для запроса
* @param {Record<string, any>} [params] Query параметры
* @param {Record<string, string>} [headers] HTTP заголовки
* @returns {Promise<T>} Ответ от API
*/
async get(url, params, headers) {
return this.request({
url,
method: 'GET',
params,
headers
});
}
/**
* Выполняет POST запрос
* @param {string} url URL для запроса
* @param {any} [data] Данные для отправки
* @param {Record<string, string>} [headers] HTTP заголовки
* @returns {Promise<T>} Ответ от API
*/
async post(url, data, headers) {
return this.request({
url,
method: 'POST',
data,
headers
});
}
/**
* Выполняет PUT запрос
* @param {string} url URL для запроса
* @param {any} [data] Данные для отправки
* @param {Record<string, string>} [headers] HTTP заголовки
* @returns {Promise<T>} Ответ от API
*/
async put(url, data, headers) {
return this.request({
url,
method: 'PUT',
data,
headers
});
}
/**
* Выполняет DELETE запрос
* @param {string} url URL для запроса
* @param {Record<string, any>} [params] Query параметры
* @param {Record<string, string>} [headers] HTTP заголовки
* @returns {Promise<T>} Ответ от API
*/
async delete(url, params, headers) {
return this.request({
url,
method: 'DELETE',
params,
headers
});
}
/**
* Выполняет PATCH запрос
* @param {string} url URL для запроса
* @param {any} [data] Данные для отправки
* @param {Record<string, string>} [headers] HTTP заголовки
* @returns {Promise<T>} Ответ от API
*/
async patch(url, data, headers) {
return this.request({
url,
method: 'PATCH',
data,
headers
});
}
/**
* Выполняет POST запрос с потоковым ответом
* @param {string} url URL для запроса
* @param {any} [data] Данные для отправки
* @param {Record<string, string>} [headers] HTTP заголовки
* @returns {Promise<Response>} Объект Response с потоком данных
*/
async postStream(url, data, headers) {
return this.executeStreamRequest(url, data, headers, false);
}
/**
* 🔐 Внутренний метод для выполнения stream запроса с поддержкой 401 retry
* @param isRetry - true если это повторная попытка после 401
*/
async executeStreamRequest(url, data, headers, isRetry = false) {
// Формируем полный URL правильно
const fullUrl = url.startsWith('http') ? url : `${this.baseURL}${url}`;
// 🔑 Динамическое получение токена перед stream запросом
let authHeaders = {};
if (this.options.getAuthToken) {
try {
// WorkAI: Убрали логи getAuthToken - вызываются при каждом postStream, спамят
const token = await Promise.resolve(this.options.getAuthToken());
if (token) {
authHeaders['Authorization'] = `Bearer ${token}`;
}
else {
console.warn('⚠️ [SDK postStream] getAuthToken returned null/undefined');
}
}
catch (error) {
console.warn('⚠️ [SDK postStream] Failed to get auth token:', error);
}
}
const requestHeaders = {
'Content-Type': 'application/json',
...this.options.headers,
...authHeaders, // ← Динамический токен имеет приоритет
...(headers || {})
};
// Универсальная функция для выполнения fetch
const doFetch = async () => {
// Для Node.js среды используем нативный fetch если доступен, иначе node-fetch
if (this.environment === 'node') {
// Проверяем доступность нативного fetch (Node.js 18+)
if (typeof globalThis.fetch !== 'undefined') {
return globalThis.fetch(fullUrl, {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify(data)
});
}
try {
// Fallback на node-fetch для старых версий Node.js
const { default: nodeFetch } = await import('node-fetch');
return nodeFetch(fullUrl, {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify(data)
});
}
catch (error) {
console.error('Ошибка при загрузке node-fetch:', error);
throw new Error('Для использования потоковой передачи в Node.js необходимо установить node-fetch или использовать Node.js 18+');
}
}
// Для браузера используем нативный fetch
return fetch(fullUrl, {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify(data)
});
};
const response = await doFetch();
// 🔐 401 INTERCEPTOR для stream запросов (fetch не проходит через axios interceptor)
if (response.status === 401 && !isRetry && this.options.getAuthToken) {
// WorkAI: Оставляем только в development - 401 важная, но редкая ошибка
if (process.env.NODE_ENV === 'development') {
console.log('🔐 [SDK postStream] 401 Unauthorized detected, attempting token refresh...');
}
// Рекурсивный вызов с флагом retry
return this.executeStreamRequest(url, data, headers, true);
}
return response;
}
/**
* Обрабатывает ошибки от Axios
* @param {any} error Ошибка от Axios
* @returns {Error} Обработанная ошибка
*/
handleError(error) {
let enhancedError;
let message;
if (error.response) {
// Ошибка от сервера с кодом ответа
const { status, data } = error.response;
// Улучшенное извлечение сообщения ошибки
if (typeof data === 'string') {
message = data;
}
else if (data && typeof data === 'object') {
// Пробуем извлечь сообщение из разных возможных полей
const rawMessage = data.message || data.error || data.errorMessage || data.msg;
if (rawMessage) {
// Если нашли сообщение, проверяем его тип
if (typeof rawMessage === 'string') {
message = rawMessage;
}
else if (typeof rawMessage === 'object') {
// Если это объект, сериализуем его
try {
message = JSON.stringify(rawMessage);
}
catch (e) {
message = String(rawMessage);
}
}
else {
// Для других типов просто конвертируем в строку
message = String(rawMessage);
}
}
else {
// Если сообщение не найдено, сериализуем весь объект data
try {
message = JSON.stringify(data);
}
catch (e) {
message = `[Ошибка сериализации ответа]`;
}
}
}
else {
message = `HTTP ошибка ${status}`;
}
enhancedError = new Error(`[HTTP ${status}] ${message}`);
enhancedError.status = status;
enhancedError.data = data;
enhancedError.isApiError = true;
}
else if (error.request) {
// Запрос был сделан, но ответ не получен
message = 'Нет ответа от сервера. Проверьте подключение к интернету.';
enhancedError = new Error(message);
enhancedError.request = error.request;
enhancedError.isNetworkError = true;
}
else {
// Произошла ошибка при настройке запроса
message = error.message || error.toString() || 'Произошла неизвестная ошибка';
enhancedError = new Error(message);
enhancedError.isUnknownError = true;
}
// Сохраняем оригинальную ошибку для отладки
enhancedError.originalError = error;
// Переопределяем toString для лучшего логирования
enhancedError.toString = function () {
return this.message;
};
// Пытаемся обработать ошибку через глобальный обработчик
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { CodeSolverSDK } = require('../code-solver-sdk.js');
if (typeof CodeSolverSDK.handleError === 'function') {
CodeSolverSDK.handleError(enhancedError);
}
}
catch (e) {
// Игнорируем ошибки при импорте или вызове обработчика
}
return enhancedError;
}
/**
* Получает экземпляр Axios для тестирования
*/
get axiosConfig() {
return this.axiosInstance;
}
/**
* Получает базовый URL API
* @returns {string} Базовый URL
*/
getBaseURL() {
return this.baseURL;
}
}
//# sourceMappingURL=http-client.js.map