Перейти к содержимому

Практика 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).

network/client.js
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// временные ошибки: нет ответа, 408, 429, 5xx
function 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,
});

Что проверить:

  1. GET на /status/503 повторяется заданное число раз, затем падает.
  2. GET на /status/404 падает сразу (постоянная ошибка, не повторяется).
  3. POST на /status/503 не повторяется (метод не идемпотентен).
  4. В логах видно, что интервалы между попытками растут (~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]);

Что проверить:

  1. Запрос к /delay/5 с timeout: 2000 завершается ошибкой по таймауту.
  2. При быстрой смене 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: таймаут запроса, очистка таймера, игнорирование AbortError15%
Задание 3: дедупликация одинаковых запросов10%
Задание 3: письменное обсуждение батчинга, pinning и хранения токенов10%

Итого 100%. Задания 1–2 обязательны, задание 3 — дополнительное (повышает оценку и закрывает «хвосты» по обязательной части).


Вопросы для самопроверки

  1. Почему повторять имеет смысл только временные ошибки? Приведите по два примера временной и постоянной ошибки.
  2. Что такое экспоненциальная задержка и зачем к ней добавляют jitter?
  3. Почему POST нельзя повторять вслепую и как это решается ключом идемпотентности (Idempotency-Key)?
  4. Чем отличается таймаут от отмены по уходу с экрана? Почему оба удобно реализовать через AbortController?
  5. Почему AbortError не должен попадать под retry и считаться ошибкой в UI?
  6. Зачем нужен 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».