feat: enhanced Magnit scraper with streaming mode and retry logic

- Add streaming mode for memory-efficient large catalog scraping
- Implement retry logic with exponential backoff
- Add auto session reinitialization on 403 errors
- Add configurable options (pageSize, maxProducts, rateLimitDelay)
- Add maxIterations protection against infinite loops
- Add retry.ts utility module with withRetry and withRetryAndReinit
- Update .env.example with new scraping options
- Add pgAdmin and CloudBeaver to docker-compose

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-21 22:14:04 +05:00
parent 19c0426cdc
commit 9164527f58
5 changed files with 585 additions and 74 deletions

View File

@@ -6,3 +6,14 @@ MAGNIT_STORE_CODE="992301"
MAGNIT_STORE_TYPE="6" MAGNIT_STORE_TYPE="6"
MAGNIT_CATALOG_TYPE="1" MAGNIT_CATALOG_TYPE="1"
# Scraping Options
MAGNIT_USE_STREAMING=true # true = streaming mode (рекомендуется), false = legacy
MAGNIT_PAGE_SIZE=50 # Размер страницы API (max 50)
MAGNIT_MAX_PRODUCTS= # Лимит товаров (пусто = без лимита)
MAGNIT_RATE_LIMIT_DELAY=300 # Задержка между запросами (ms)
MAGNIT_MAX_ITERATIONS=10000 # Защита от бесконечного цикла
MAGNIT_HEADLESS=true # Headless режим браузера
# Resilience Options
MAGNIT_RETRY_ATTEMPTS=3 # Количество попыток retry
MAGNIT_REQUEST_TIMEOUT=30000 # Timeout запросов (ms)

View File

@@ -19,6 +19,36 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
pgadmin:
image: dpage/pgadmin4:latest
container_name: supermarket-pgadmin
restart: unless-stopped
environment:
PGADMIN_DEFAULT_EMAIL: admin@admin.com
PGADMIN_DEFAULT_PASSWORD: admin
PGADMIN_CONFIG_SERVER_MODE: 'False'
ports:
- "5050:80"
volumes:
- pgadmin_data:/var/lib/pgadmin
depends_on:
postgres:
condition: service_healthy
cloudbeaver:
image: dbeaver/cloudbeaver:latest
container_name: supermarket-cloudbeaver
restart: unless-stopped
ports:
- "8978:8978"
volumes:
- cloudbeaver_data:/opt/cloudbeaver/workspace
depends_on:
postgres:
condition: service_healthy
volumes: volumes:
postgres_data: postgres_data:
pgadmin_data:
cloudbeaver_data:

View File

@@ -11,14 +11,37 @@ import {
import { ProductService } from '../../../services/product/ProductService.js'; import { ProductService } from '../../../services/product/ProductService.js';
import { ProductParser } from '../../../services/parser/ProductParser.js'; import { ProductParser } from '../../../services/parser/ProductParser.js';
import { PrismaClient } from '../../../../generated/prisma/client.js'; import { PrismaClient } from '../../../../generated/prisma/client.js';
import { withRetryAndReinit } from '../../../utils/retry.js';
export interface MagnitScraperConfig { export interface MagnitScraperConfig {
storeCode: string; storeCode: string;
storeType?: string; storeType?: string;
catalogType?: string; catalogType?: string;
headless?: boolean; headless?: boolean;
// Параметры пагинации
pageSize?: number; // default: 50
maxProducts?: number; // default: undefined (без лимита)
rateLimitDelay?: number; // default: 300ms
maxIterations?: number; // default: 10000 (защита от бесконечного цикла)
// Resilience настройки
retryOptions?: {
maxAttempts?: number; // default: 3
initialDelay?: number; // default: 1000ms
maxDelay?: number; // default: 30000ms
backoffMultiplier?: number; // default: 2
};
requestTimeout?: number; // default: 30000ms
autoReinitOn403?: boolean; // default: true
} }
export type ProductBatchCallback = (
batch: ProductItem[],
batchIndex: number,
totalProcessed: number
) => Promise<void>;
export class MagnitApiScraper { export class MagnitApiScraper {
private browser: Browser | null = null; private browser: Browser | null = null;
private context: BrowserContext | null = null; private context: BrowserContext | null = null;
@@ -26,15 +49,14 @@ export class MagnitApiScraper {
private httpClient: AxiosInstance; private httpClient: AxiosInstance;
private deviceId: string = ''; private deviceId: string = '';
private cookies: string = ''; private cookies: string = '';
private config: Required<MagnitScraperConfig>; private config: MagnitScraperConfig;
private readonly ACTUAL_API_PAGE_SIZE = 50; // Реальный лимит API
private readonly MAX_SAFE_ITERATIONS = 10000;
private seenProductIds = new Set<string>(); // Для deduplication
constructor(config: MagnitScraperConfig) { constructor(config: MagnitScraperConfig) {
this.config = { this.config = config;
storeCode: config.storeCode,
storeType: config.storeType || '6',
catalogType: config.catalogType || '1',
headless: config.headless !== false,
};
this.httpClient = axios.create({ this.httpClient = axios.create({
baseURL: 'https://magnit.ru', baseURL: 'https://magnit.ru',
@@ -55,7 +77,7 @@ export class MagnitApiScraper {
Logger.info('Инициализация браузера через Playwright...'); Logger.info('Инициализация браузера через Playwright...');
this.browser = await chromium.launch({ this.browser = await chromium.launch({
headless: this.config.headless, headless: this.config.headless !== false,
}); });
this.context = await this.browser.newContext({ this.context = await this.browser.newContext({
@@ -105,12 +127,13 @@ export class MagnitApiScraper {
} }
/** /**
* Поиск товаров через API * Поиск товаров через API с retry логикой
*/ */
async searchGoods( async searchGoods(
pagination: { limit: number; offset: number } = { limit: 100, offset: 0 }, pagination: { limit: number; offset: number } = { limit: 50, offset: 0 },
categories: number[] = [] categories: number[] = []
): Promise<SearchGoodsResponse> { ): Promise<SearchGoodsResponse> {
const operation = async () => {
try { try {
const requestBody: SearchGoodsRequest = { const requestBody: SearchGoodsRequest = {
sort: { sort: {
@@ -121,15 +144,18 @@ export class MagnitApiScraper {
categories, categories,
includeAdultGoods: true, includeAdultGoods: true,
storeCode: this.config.storeCode, storeCode: this.config.storeCode,
storeType: this.config.storeType, storeType: this.config.storeType || '6',
catalogType: this.config.catalogType, catalogType: this.config.catalogType || '1',
}; };
Logger.debug(`Запрос товаров: offset=${pagination.offset}, limit=${pagination.limit}`); Logger.debug(`Запрос товаров: offset=${pagination.offset}, limit=${pagination.limit}`);
const response = await this.httpClient.post<SearchGoodsResponse>( const response = await this.httpClient.post<SearchGoodsResponse>(
ENDPOINTS.SEARCH_GOODS, ENDPOINTS.SEARCH_GOODS,
requestBody requestBody,
{
timeout: this.config.requestTimeout ?? 30000
}
); );
Logger.debug(`Получено товаров: ${response.data.items.length}`); Logger.debug(`Получено товаров: ${response.data.items.length}`);
@@ -150,46 +176,138 @@ export class MagnitApiScraper {
} }
throw error; throw error;
} }
};
// Retry с автоматической переинициализацией при 403
return withRetryAndReinit(operation, {
...this.config.retryOptions,
reinitOn403: this.config.autoReinitOn403 ?? true,
onReinit: async () => {
await this.reinitializeSession();
}
});
}
/**
* Переинициализация сессии (при 403 или истечении cookies)
* ВАЖНО: Не закрываем браузер, только обновляем cookies
*/
private async reinitializeSession(): Promise<void> {
try {
if (!this.page || !this.context) {
Logger.warn('Браузер не инициализирован, выполняем полную инициализацию');
await this.initialize();
return;
}
Logger.info('Обновление сессии через повторный визит на magnit.ru...');
// Переход на главную страницу для обновления cookies
await this.page.goto('https://magnit.ru/', {
waitUntil: 'networkidle',
timeout: 30000,
});
// Получение обновленных cookies
const cookies = await this.context.cookies();
this.cookies = cookies.map(c => `${c.name}=${c.value}`).join('; ');
// Обновление device-id
const mgUdiCookie = cookies.find(c => c.name === 'mg_udi');
if (mgUdiCookie) {
this.deviceId = mgUdiCookie.value;
Logger.info(`Device ID обновлен: ${this.deviceId.substring(0, 20)}...`);
}
// Обновление заголовков HTTP клиента
this.httpClient.defaults.headers.common['Cookie'] = this.cookies;
if (this.deviceId) {
this.httpClient.defaults.headers.common['x-device-id'] = this.deviceId;
}
Logger.info('✅ Сессия успешно обновлена');
} catch (error) {
Logger.error('Ошибка переинициализации сессии:', error);
// Fallback: полная переинициализация
Logger.info('Попытка полной переинициализации...');
await this.close();
await this.initialize();
}
} }
/** /**
* Получение всех товаров без фильтрации по категориям * Получение всех товаров без фильтрации по категориям
*/ */
async scrapeAllProducts( async scrapeAllProducts(
limit: number = 100 limit: number = 50
): Promise<ProductItem[]> { ): Promise<ProductItem[]> {
try { try {
Logger.info('Начало скрапинга всех товаров...'); Logger.info('Начало скрапинга всех товаров...');
const allProducts: ProductItem[] = []; const allProducts: ProductItem[] = [];
let offset = 0; let offset = 0;
let hasMore = true; let hasMore = true;
let iterations = 0;
const maxIterations = this.config.maxIterations || this.MAX_SAFE_ITERATIONS;
const maxProducts = this.config.maxProducts;
// Валидация: limit должен быть <= ACTUAL_API_PAGE_SIZE
const effectiveLimit = Math.min(limit, this.ACTUAL_API_PAGE_SIZE);
if (limit > this.ACTUAL_API_PAGE_SIZE) {
Logger.warn(
`Запрошенный limit=${limit} превышает максимум API=${this.ACTUAL_API_PAGE_SIZE}. ` +
`Используется limit=${effectiveLimit}`
);
}
while (hasMore) { while (hasMore) {
Logger.info(`Получение товаров: offset=${offset}, limit=${limit}`); // Защита от бесконечного цикла
iterations++;
if (iterations > maxIterations) {
Logger.error(
`Достигнут максимум итераций (${maxIterations}). ` +
`Возможно, API возвращает некорректные данные. Остановка скрапинга.`
);
break;
}
const response = await this.searchGoods({ limit, offset }, []); // Проверка лимита товаров
if (maxProducts && allProducts.length >= maxProducts) {
Logger.info(`Достигнут лимит товаров: ${maxProducts}. Остановка скрапинга.`);
break;
}
if (response.items.length === 0) { Logger.info(
`[${iterations}] Получение товаров: offset=${offset}, limit=${effectiveLimit}`
);
const response = await this.searchGoods({ limit: effectiveLimit, offset }, []);
// КЛЮЧЕВОЕ ИЗМЕНЕНИЕ: Проверяем пустой массив, а не сравнение с limit
if (!response.items || response.items.length === 0) {
hasMore = false; hasMore = false;
Logger.info('Товары закончились, скрапинг завершен'); Logger.info('API вернул пустой массив товаров. Скрапинг завершен.');
break; break;
} }
allProducts.push(...response.items); allProducts.push(...response.items);
Logger.info(`Всего получено товаров: ${allProducts.length}`); Logger.info(
`Получено: ${response.items.length} | ` +
`Всего собрано: ${allProducts.length} | ` +
`Итерация: ${iterations}`
);
// Если получили меньше товаров, чем запрашивали, значит это последняя страница // НОВАЯ ЛОГИКА: Продолжаем пока API возвращает данные
if (response.items.length < limit) { // Увеличиваем offset на ФАКТИЧЕСКОЕ количество полученных товаров
hasMore = false; offset += response.items.length;
Logger.info('Получена последняя страница товаров');
} else { // Rate limiting
offset += limit; const delay = this.config.rateLimitDelay ?? 300;
// Задержка между запросами для rate limiting await this.delay(delay);
await this.delay(300);
}
} }
Logger.info(`✅ Скрапинг завершен. Всего товаров: ${allProducts.length}`); Logger.info(
`✅ Скрапинг завершен. Всего товаров: ${allProducts.length}, итераций: ${iterations}`
);
return allProducts; return allProducts;
} catch (error) { } catch (error) {
Logger.error('Ошибка при скрапинге всех товаров:', error); Logger.error('Ошибка при скрапинге всех товаров:', error);
@@ -197,6 +315,106 @@ export class MagnitApiScraper {
} }
} }
/**
* Скрапинг с потоковой обработкой (streaming)
* Не накапливает все товары в памяти, обрабатывает батчами
*/
async scrapeAllProductsStreaming(
onBatch: ProductBatchCallback,
options: {
batchSize?: number; // default: 50
maxProducts?: number; // default: undefined
pageSize?: number; // default: 50
} = {}
): Promise<{ totalProducts: number; batches: number }> {
try {
const batchSize = options.batchSize ?? 50;
const pageSize = Math.min(options.pageSize ?? 50, this.ACTUAL_API_PAGE_SIZE);
const maxProducts = options.maxProducts ?? this.config.maxProducts;
Logger.info(
`Начало потокового скрапинга. ` +
`Batch size: ${batchSize}, Page size: ${pageSize}, Max products: ${maxProducts ?? 'unlimited'}`
);
let offset = 0;
let hasMore = true;
let iterations = 0;
let totalProcessed = 0;
let batchIndex = 0;
let currentBatch: ProductItem[] = [];
const maxIterations = this.config.maxIterations || this.MAX_SAFE_ITERATIONS;
while (hasMore) {
// Защита от бесконечного цикла
iterations++;
if (iterations > maxIterations) {
Logger.error(`Достигнут максимум итераций (${maxIterations}). Остановка.`);
break;
}
// Проверка лимита товаров
if (maxProducts && totalProcessed >= maxProducts) {
Logger.info(`Достигнут лимит товаров: ${maxProducts}.`);
break;
}
Logger.debug(`[${iterations}] Запрос: offset=${offset}, limit=${pageSize}`);
const response = await this.searchGoods({ limit: pageSize, offset }, []);
if (!response.items || response.items.length === 0) {
hasMore = false;
Logger.info('API вернул пустой массив. Скрапинг завершен.');
break;
}
// Добавляем товары в текущий батч
currentBatch.push(...response.items);
totalProcessed += response.items.length;
// Когда батч достигает нужного размера - обрабатываем
while (currentBatch.length >= batchSize) {
const batch = currentBatch.splice(0, batchSize);
batchIndex++;
Logger.info(
`Обработка батча #${batchIndex} (${batch.length} товаров). ` +
`Всего обработано: ${totalProcessed - currentBatch.length}`
);
await onBatch(batch, batchIndex, totalProcessed - currentBatch.length);
}
offset += response.items.length;
// Rate limiting
const delay = this.config.rateLimitDelay ?? 300;
await this.delay(delay);
}
// Обрабатываем остаток (последний неполный батч)
if (currentBatch.length > 0) {
batchIndex++;
Logger.info(
`Обработка финального батча #${batchIndex} (${currentBatch.length} товаров)`
);
await onBatch(currentBatch, batchIndex, totalProcessed);
}
Logger.info(
`✅ Потоковый скрапинг завершен. ` +
`Товаров: ${totalProcessed}, Батчей: ${batchIndex}, Итераций: ${iterations}`
);
return { totalProducts: totalProcessed, batches: batchIndex };
} catch (error) {
Logger.error('Ошибка при потоковом скрапинге:', error);
throw error;
}
}
/** /**
* Задержка в миллисекундах * Задержка в миллисекундах
*/ */
@@ -205,7 +423,7 @@ export class MagnitApiScraper {
} }
/** /**
* Сохранение товаров в базу данных * Сохранение товаров в базу данных (legacy режим)
*/ */
async saveToDatabase( async saveToDatabase(
products: ProductItem[], products: ProductItem[],
@@ -252,6 +470,78 @@ export class MagnitApiScraper {
} }
} }
/**
* Потоковое сохранение в БД с обработкой батчами
* Более эффективно для больших каталогов
*/
async saveToDatabaseStreaming(
prisma: PrismaClient,
options: {
batchSize?: number; // default: 50
maxProducts?: number;
} = {}
): Promise<number> {
const productService = new ProductService(prisma);
let totalSaved = 0;
// Получаем магазин один раз в начале
const store = await productService.getOrCreateStore(
this.config.storeCode,
'Магнит'
);
// Глобальная map категорий (кеш)
const globalCategoryMap = new Map<number, number>();
const result = await this.scrapeAllProductsStreaming(
async (batch, batchIndex, totalProcessed) => {
Logger.info(
`Сохранение батча #${batchIndex} (${batch.length} товаров) в БД...`
);
// Собираем категории из текущего батча
const batchCategories = new Map<number, { id: number; title: string }>();
for (const product of batch) {
if (product.category?.id && !globalCategoryMap.has(product.category.id)) {
batchCategories.set(product.category.id, {
id: product.category.id,
title: product.category.title
});
}
}
// Создаем новые категории
for (const [externalId, cat] of batchCategories) {
const category = await productService.getOrCreateCategory(externalId, cat.title);
globalCategoryMap.set(externalId, category.id);
}
// Парсим и сохраняем товары батча
const parsedProducts = ProductParser.parseProductItems(
batch,
store.id,
globalCategoryMap
);
const saved = await productService.saveProducts(parsedProducts);
totalSaved += saved;
Logger.info(
`Батч #${batchIndex} сохранен: ${saved} товаров. ` +
`Всего сохранено: ${totalSaved}`
);
},
options
);
Logger.info(
`✅ Потоковое сохранение завершено. ` +
`Всего сохранено: ${totalSaved} товаров за ${result.batches} батчей`
);
return totalSaved;
}
/** /**
* Закрытие браузера и очистка ресурсов * Закрытие браузера и очистка ресурсов
*/ */

View File

@@ -11,13 +11,44 @@ async function main() {
process.exit(1); process.exit(1);
} }
// Выбор режима: streaming или legacy
const useStreaming = process.env.MAGNIT_USE_STREAMING !== 'false';
const maxProducts = process.env.MAGNIT_MAX_PRODUCTS
? parseInt(process.env.MAGNIT_MAX_PRODUCTS, 10)
: undefined;
Logger.info(`🚀 Запуск скрапинга для магазина: ${storeCode}`); Logger.info(`🚀 Запуск скрапинга для магазина: ${storeCode}`);
Logger.info(`Режим: ${useStreaming ? 'STREAMING' : 'LEGACY'}`);
if (maxProducts) {
Logger.info(`Лимит товаров: ${maxProducts}`);
}
const scraper = new MagnitApiScraper({ const scraper = new MagnitApiScraper({
storeCode, storeCode,
storeType: process.env.MAGNIT_STORE_TYPE || '6', storeType: process.env.MAGNIT_STORE_TYPE || '6',
catalogType: process.env.MAGNIT_CATALOG_TYPE || '1', catalogType: process.env.MAGNIT_CATALOG_TYPE || '1',
headless: true, headless: process.env.MAGNIT_HEADLESS !== 'false',
// Новые параметры
pageSize: process.env.MAGNIT_PAGE_SIZE ? parseInt(process.env.MAGNIT_PAGE_SIZE, 10) : 50,
maxProducts,
rateLimitDelay: process.env.MAGNIT_RATE_LIMIT_DELAY
? parseInt(process.env.MAGNIT_RATE_LIMIT_DELAY, 10)
: 300,
maxIterations: process.env.MAGNIT_MAX_ITERATIONS
? parseInt(process.env.MAGNIT_MAX_ITERATIONS, 10)
: 10000,
retryOptions: {
maxAttempts: process.env.MAGNIT_RETRY_ATTEMPTS
? parseInt(process.env.MAGNIT_RETRY_ATTEMPTS, 10)
: 3,
initialDelay: 1000,
maxDelay: 30000,
},
requestTimeout: 30000,
autoReinitOn403: true,
}); });
try { try {
@@ -27,20 +58,34 @@ async function main() {
// Инициализация скрапера // Инициализация скрапера
await scraper.initialize(); await scraper.initialize();
// Получение всех товаров let saved = 0;
const products = await scraper.scrapeAllProducts(100);
if (useStreaming) {
// STREAMING режим (рекомендуется для больших каталогов)
Logger.info('📡 Использование потокового режима скрапинга');
saved = await scraper.saveToDatabaseStreaming(prisma, {
batchSize: 50,
maxProducts,
});
} else {
// LEGACY режим (обратная совместимость)
Logger.info('📦 Использование legacy режима скрапинга');
const products = await scraper.scrapeAllProducts(50);
Logger.info(`📦 Получено товаров: ${products.length}`); Logger.info(`📦 Получено товаров: ${products.length}`);
if (products.length > 0) { if (products.length > 0) {
// Сохранение в БД saved = await scraper.saveToDatabase(products, prisma);
const saved = await scraper.saveToDatabase(products, prisma);
Logger.info(`✅ Успешно сохранено товаров: ${saved}`);
} else { } else {
Logger.warn('⚠️ Товары не найдены'); Logger.warn('⚠️ Товары не найдены');
} }
}
Logger.info(`✅ Успешно сохранено товаров: ${saved}`);
Logger.info('✅ Скрапинг завершен успешно'); Logger.info('✅ Скрапинг завершен успешно');
} catch (error) { } catch (error) {
Logger.error('❌ Ошибка при скрапинге:', error); Logger.error('❌ Ошибка при скрапинге:', error);
process.exit(1); process.exit(1);

135
src/utils/retry.ts Normal file
View File

@@ -0,0 +1,135 @@
import { Logger } from './logger.js';
export interface RetryOptions {
maxAttempts: number; // default: 3
initialDelay: number; // default: 1000ms
maxDelay: number; // default: 30000ms
backoffMultiplier: number; // default: 2 (exponential)
retryableErrors?: string[]; // default: ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND']
shouldRetry?: (error: any) => boolean;
onRetry?: (error: any, attempt: number, delay: number) => void;
}
const DEFAULT_RETRYABLE_ERRORS = [
'ECONNRESET',
'ETIMEDOUT',
'ENOTFOUND',
'ECONNREFUSED',
'ENETUNREACH',
'EAI_AGAIN'
];
export async function withRetry<T>(
operation: () => Promise<T>,
options: Partial<RetryOptions> = {}
): Promise<T> {
const {
maxAttempts = 3,
initialDelay = 1000,
maxDelay = 30000,
backoffMultiplier = 2,
retryableErrors = DEFAULT_RETRYABLE_ERRORS,
shouldRetry,
onRetry
} = options;
let lastError: any;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await operation();
} catch (error: any) {
lastError = error;
// Проверяем, нужно ли retry
const isRetryable = shouldRetry
? shouldRetry(error)
: isErrorRetryable(error, retryableErrors);
if (!isRetryable || attempt === maxAttempts) {
throw error;
}
// Exponential backoff
const delay = Math.min(
initialDelay * Math.pow(backoffMultiplier, attempt - 1),
maxDelay
);
Logger.warn(
`Попытка ${attempt}/${maxAttempts} не удалась: ${error.message}. ` +
`Повтор через ${delay}ms...`
);
if (onRetry) {
onRetry(error, attempt, delay);
}
await sleep(delay);
}
}
throw lastError;
}
function isErrorRetryable(error: any, retryableErrors: string[]): boolean {
// Network errors
if (error.code && retryableErrors.includes(error.code)) {
return true;
}
// HTTP 5xx errors (server errors)
if (error.response?.status >= 500 && error.response?.status < 600) {
return true;
}
// HTTP 429 (Too Many Requests)
if (error.response?.status === 429) {
return true;
}
return false;
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Специальная утилита для retry с автоматической переинициализацией сессии
export interface RetryWithReinitOptions extends RetryOptions {
reinitOn403?: boolean; // default: true
onReinit?: () => Promise<void>;
}
export async function withRetryAndReinit<T>(
operation: () => Promise<T>,
options: Partial<RetryWithReinitOptions> = {}
): Promise<T> {
const { reinitOn403 = true, onReinit, ...retryOptions } = options;
return withRetry(operation, {
...retryOptions,
shouldRetry: (error: any) => {
// 403 Forbidden - требуется переинициализация сессии
if (error.response?.status === 403 && reinitOn403) {
return true;
}
// Другие retryable ошибки
return isErrorRetryable(error, retryOptions.retryableErrors || DEFAULT_RETRYABLE_ERRORS);
},
onRetry: async (error: any, attempt: number, delay: number) => {
// Если 403 и есть callback переинициализации
if (error.response?.status === 403 && onReinit) {
Logger.warn('Получен 403 Forbidden. Переинициализация сессии...');
await onReinit();
Logger.info('✅ Сессия переинициализирована');
}
// Вызов пользовательского callback
if (retryOptions.onRetry) {
retryOptions.onRetry(error, attempt, delay);
}
}
});
}