UNPKG

solver-sdk

Version:

SDK for WorkAI API - AI-powered code analysis with WorkCoins billing system

505 lines 25.9 kB
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