Практика 13. Практическая работа 13. Надёжный сетевой слой: retry, отмена, оптимизация
Цели работы
- Научиться повышать надёжность сетевого слоя: автоматически повторять только те запросы, которые имеет смысл повторять.
- Освоить экспоненциальную задержку с jitter и ограничение числа попыток.
- Реализовать отмену запросов через
AbortControllerи таймаут. - Познакомиться с приёмами оптимизации: дедупликацией и батчингом запросов.
- Обсудить вопросы безопасности: certificate pinning и хранение токенов.
Работа продолжает тему лекции 13 «Сетевое взаимодействие. Часть 2». Базовый проект Expo + React Native у вас уже есть (см. практику 1).
Коротко о теории
Мобильная сеть нестабильна, поэтому учебный код «отправил fetch и забыл» в
реальном приложении не годится. Ключевые идеи лекции, которые мы применим:
- Ошибки делятся на временные (
408,429,5xx, потеря сети) и постоянные (400,401,403,404). Повторять — только временные. - Retry строят на экспоненциальной задержке (
backoff) + jitter (случайный разброс), чтобы клиенты не пришли на сервер одновременно (thundering herd). - Повторять можно только идемпотентные методы (
GET,PUT,DELETE).POSTвслепую повторять нельзя — можно создать дубликат. - Ненужный запрос надо отменять (
AbortController), иначе он тратит трафик и может перезаписать актуальные данные (race condition).
Задание
Подготовка
Создайте файл network/client.js — в нём собираем надёжный сетевой клиент.
Для проверки подойдёт публичный API https://httpbin.org (эндпоинты
/status/503, /delay/5).
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// временные ошибки: нет ответа, 408, 429, 5xxfunction isTransientStatus(status) { return status === 408 || status === 429 || status >= 500;}Задание 1. Retry с экспоненциальной задержкой и jitter
Реализуйте обёртку retry, которая повторяет операцию только при временных
ошибках и только для идемпотентных методов, с ограничением числа попыток.
const IDEMPOTENT = new Set(['GET', 'HEAD', 'PUT', 'DELETE']);
// помечаем ошибку как временную, чтобы retry знал, можно ли повторятьfunction networkError(message, { transient }) { const e = new Error(message); e.isTransient = transient; return e;}
async function retry(operation, { maxRetries = 3, baseDelay = 1000, maxDelay = 16000 } = {}) { for (let attempt = 0; ; attempt++) { try { return await operation(); } catch (error) { // постоянная ошибка или попытки кончились — пробрасываем дальше if (!error.isTransient || attempt >= maxRetries) throw error; // экспоненциальная задержка: baseDelay * 2^attempt, но не больше maxDelay const backoff = Math.min(baseDelay * 2 ** attempt, maxDelay); // full jitter: случайный множитель 0.5..1.0 await sleep(backoff * (0.5 + Math.random() * 0.5)); } }}Теперь напишите функцию запроса, которая классифицирует ответ и разрешает повтор только идемпотентным методам:
async function request(url, { method = 'GET', ...options } = {}) { const res = await fetch(url, { method, ...options }).catch(() => { // сети нет / DNS не разрешился — это временная ошибка throw networkError('Network unavailable', { transient: true }); });
if (!res.ok) { const transient = isTransientStatus(res.status) && IDEMPOTENT.has(method); throw networkError(`HTTP ${res.status}`, { transient }); } return res.json();}
// использованиеconst data = await retry(() => request('https://httpbin.org/status/503'), { maxRetries: 4, baseDelay: 500,});Что проверить:
GETна/status/503повторяется заданное число раз, затем падает.GETна/status/404падает сразу (постоянная ошибка, не повторяется).POSTна/status/503не повторяется (метод не идемпотентен).- В логах видно, что интервалы между попытками растут (~0.5 → 1 → 2 c) и при каждом запуске немного отличаются (это работает jitter).
Задание 2. Отмена запроса (AbortController) и таймаут
Сетевой запрос нужно уметь отменять — например, когда пользователь ушёл с экрана или поменял поисковый запрос. Также нужен таймаут: если ответа нет слишком долго, прекращаем ждать.
Добавьте в request поддержку таймаута через AbortController:
async function request(url, { method = 'GET', timeout = 8000, signal, ...options } = {}) { const controller = new AbortController(); // таймаут — это автоматический abort через timeout мс const timer = setTimeout(() => controller.abort(), timeout);
// если снаружи передали signal (уход с экрана) — связываем его с нашим if (signal) signal.addEventListener('abort', () => controller.abort());
try { const res = await fetch(url, { method, signal: controller.signal, ...options }); if (!res.ok) { const transient = isTransientStatus(res.status) && IDEMPOTENT.has(method); throw networkError(`HTTP ${res.status}`, { transient }); } return await res.json(); } finally { clearTimeout(timer); // важно очистить таймер в любом случае }}Отмена при уходе с экрана — в useEffect:
useEffect(() => { const controller = new AbortController();
request(`https://api.example.com/search?q=${query}`, { signal: controller.signal }) .then(setResults) .catch((e) => { if (e.name !== 'AbortError') console.error(e); // отмену игнорируем });
return () => controller.abort(); // при размонтировании / смене query}, [query]);Что проверить:
- Запрос к
/delay/5сtimeout: 2000завершается ошибкой по таймауту. - При быстрой смене
queryв живом поиске старый запрос отменяется (в сети остаётся только последний),AbortErrorне считается ошибкой.
Связка retry + abort: при отмене бросается
AbortError, у которого нет флагаisTransient, поэтомуretryего не повторяет — это правильно.
Задание 3 (дополнительное). Дедупликация, батчинг и безопасность
3.1. Дедупликация одинаковых запросов
Если несколько компонентов одновременно запросили одни и те же данные, держим
один «летящий» запрос, а остальным отдаём тот же Promise.
const inFlight = new Map();
function dedupedRequest(key, fetcher) { if (inFlight.has(key)) return inFlight.get(key); // уже летит — переиспользуем const promise = fetcher().finally(() => inFlight.delete(key)); inFlight.set(key, promise); return promise;}
// два одновременных вызова → один реальный запросconst [a, b] = await Promise.all([ dedupedRequest('user:1', () => request('https://httpbin.org/get')), dedupedRequest('user:1', () => request('https://httpbin.org/get')),]);Проверьте через httpbin.org или сетевой лог, что ушёл один запрос.
3.2. Батчинг (обсуждение)
Батчинг — объединение нескольких логических запросов в один сетевой вызов.
Вместо трёх обращений (user, posts, stats) отправляем один пакет с
массивом операций. Меньше запросов — меньше накладных расходов на соединение,
заголовки и «пробуждение» радиомодуля (экономия батареи). Опишите в отчёте,
какие запросы вашего приложения имело бы смысл сбатчить.
3.3. Безопасность (обсуждение)
Кратко ответьте письменно:
- Certificate pinning — «зашивание» в приложение ожидаемого сертификата (или публичного ключа) сервера; при рукопожатии приложение сверяет сертификат и рвёт соединение при несовпадении. Защищает от MITM даже при скомпрометированном CA. Минус — при смене сертификата на сервере нужно вовремя обновить приложение (закрепляют несколько ключей + резервный).
- Хранение токенов — НЕ держите токены в обычном
AsyncStorage(он не шифрован). Используйтеexpo-secure-store(Keychain на iOS, Keystore на Android). Access-токен короткоживущий, refresh — долгий; передавать только по HTTPS в заголовкеAuthorization: Bearer <token>, не в URL.
Критерии оценки
| Критерий | Вес |
|---|---|
| Задание 1: retry с backoff + jitter, ограничение попыток, классификация ошибок | 35% |
Задание 1: повтор только идемпотентных методов (POST не повторяется) | 10% |
Задание 2: отмена через AbortController (уход с экрана / смена query) | 20% |
Задание 2: таймаут запроса, очистка таймера, игнорирование AbortError | 15% |
| Задание 3: дедупликация одинаковых запросов | 10% |
| Задание 3: письменное обсуждение батчинга, pinning и хранения токенов | 10% |
Итого 100%. Задания 1–2 обязательны, задание 3 — дополнительное (повышает оценку и закрывает «хвосты» по обязательной части).
Вопросы для самопроверки
- Почему повторять имеет смысл только временные ошибки? Приведите по два примера временной и постоянной ошибки.
- Что такое экспоненциальная задержка и зачем к ней добавляют jitter?
- Почему
POSTнельзя повторять вслепую и как это решается ключом идемпотентности (Idempotency-Key)? - Чем отличается таймаут от отмены по уходу с экрана? Почему оба удобно
реализовать через
AbortController? - Почему
AbortErrorне должен попадать под retry и считаться ошибкой в UI? - Зачем нужен certificate pinning и почему токены нельзя хранить в
AsyncStorage?
Ресурсы
- Лекция 13. Сетевое взаимодействие. Часть 2 —
lecture_13.md. - MDN:
AbortController,fetch. - Expo:
expo-secure-store. - Тестовый API:
httpbin.org(/status/{code},/delay/{n}). - AWS Architecture Blog, «Exponential Backoff And Jitter».