Повторные попытки
Повторяйте запрос только при 5xx и сетевых таймаутах — никогда при 4xx. Используйте экспоненциальную задержку с jitter, жёсткий лимит попыток и ключ идемпотентности, чтобы повторы не создавали дубликаты задач.
Когда повторять
Правило простое: повторяйте только если ошибка похожа на временную и сервер не сказал, что запрос неверен. Ответы 4xx детерминированы — тот же запрос приведёт к той же ошибке, повторы только жгут квоту.
| Сценарий | Повторять? | Комментарий |
|---|---|---|
2xx | Нет | Успех — повтор не нужен. |
4xx | Нет | Детерминированная ошибка клиента. Покажите error.code пользователю — повтор даст тот же результат. |
429 | Да, с задержкой | Зарезервировано для будущего rate-limiting. Соблюдайте Retry-After, если есть, иначе подождите минимум 1 секунду и повторите один раз. |
5xx | Да | Временная ошибка сервера. Повторите с экспоненциальной задержкой и jitter, до 5 попыток. |
сеть/таймаут | Да | Обрыв соединения, DNS-ошибка или клиентский таймаут. Повторите — но POST-запросы защищайте ключом идемпотентности (см. ниже). |
ошибка парсинга | Нет | Невалидный JSON от сервера почти всегда — баг или прокси, вставляющий HTML. Не зацикливайте повторы — залогируйте и прервите операцию. |
transcription.failed | Нет | Асинхронный отказ самой задачи. Требует вмешательства пользователя (починить вход, пополнить баланс). Повтор запроса натолкнётся на ту же ошибку. |
Стратегия задержки
Используйте экспоненциальную задержку с потолком 30 секунд: 1с, 2с, 4с, 8с, 16с, 30с, с полным случайным jitter на каждом шаге. Ограничьте 5 повторами и общим дедлайном (в примере ниже — 2 минуты), чтобы сломанный апстрим никогда не подвешивал воркер навсегда.
type RetryOpts = { retries?: number; baseMs?: number; capMs?: number; totalMs?: number };
export async function retry<T>(
fn: (signal: AbortSignal) => Promise<T>,
opts: RetryOpts = {},
): Promise<T> {
const { retries = 5, baseMs = 1000, capMs = 30_000, totalMs = 120_000 } = opts;
const ctrl = new AbortController();
const deadline = setTimeout(() => ctrl.abort(), totalMs);
try {
let lastErr: unknown;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn(ctrl.signal);
} catch (err) {
lastErr = err;
if (attempt === retries || !isRetryable(err)) throw err;
const exp = Math.min(capMs, baseMs * 2 ** attempt);
const jitter = Math.random() * exp;
await new Promise((r) => setTimeout(r, jitter));
}
}
throw lastErr;
} finally {
clearTimeout(deadline);
}
}
function isRetryable(err: any) {
if (err?.name === "AbortError") return false;
if (err?.status && err.status >= 500) return true;
return err?.code === "ETIMEDOUT" || err?.code === "ECONNRESET" || err?.name === "TypeError";
}Ошибки 4xx
Покажите error.code и error.message пользователю (для интерактивных сценариев) или залогируйте (для фоновых задач) и остановитесь. Ветвитесь по code, а не по message — сообщения для людей и могут меняться.
Ответы 429
429 зарезервирован для будущего rate-limiting и пока не применяется, но корректный клиент должен уметь его обработать уже сейчас. Если в ответе есть заголовок Retry-After (в секундах), подождите указанное время и повторите один раз. Если заголовка нет — подождите минимум секунду. Не подмешивайте 429 в общую экспоненциальную петлю — это отдельная осознанная пауза.
async function sendWith429(req) {
const res = await fetch("https://api.quillhub.ai/v1/transcriptions", req);
if (res.status !== 429) return res;
const header = res.headers.get("Retry-After");
const waitSec = header ? Math.max(1, parseInt(header, 10) || 1) : 1;
await new Promise((r) => setTimeout(r, waitSec * 1000));
return fetch("https://api.quillhub.ai/v1/transcriptions", req);
}Дубликаты задач
POST /v1/transcriptions пока не идемпотентен. Если запрос оборвался по таймауту, сервер мог успешно принять задачу — слепой повтор создаст вторую и спишет очки дважды. Серверный дедуп через заголовок Idempotency-Key у нас в планах, а пока — дедуплицируйте на клиенте.
const idempotencyKey = crypto.randomUUID();
async function createOnce(url: string) {
// Before retrying a timed-out POST, check if the job already landed.
const existing = await api.get("/v1/transcriptions?limit=20").then((r) =>
r.data.find((t: any) => t.metadata?.idempotency_key === idempotencyKey)
);
if (existing) return existing;
return api.post("/v1/transcriptions", {
url,
metadata: { idempotency_key: idempotencyKey },
});
}Доставка вебхуков
Отказы транскрипций
Задача в статусе status: "failed" — это не временный сбой, а детерминированный исход: неподдерживаемый источник, битый файл, недостаточно очков. Не повторяйте такие задачи программно — тот же вход даст ту же ошибку. Покажите error.code пользователю, чтобы он устранил причину и отправил заново.