Ты пришёл в Академию с готовым ответом. Пять лет на TypeScript, строгие типы, тесты, ревью. Вчерашние пять дыр ты нашёл и закрыл за полчаса. Зачем тебе свитки, если ты и так пишешь хороший код?
Рыдкулл спрашивает не о том, умеешь ли ты писать повторы. Умеешь. Он спрашивает, во что превратится функция, когда ты допишешь их все. В этом уроке ты потратишь на это весь день и увидишь, что каждое требование надёжности на голом async/await стоит строк, а тип функции к вечеру скажет о ней не больше, чем утром.
Счастливый путь
Код, который мы пишем, почти всегда рассказывает одну историю: соседи на месте, сеть быстрая, кадр правильный. Это счастливый путь. Его мы держим в голове, когда пишем, его проверяем первым тестом, его видим в типе: Promise<number> означает «когда всё получится, будет число».
У каждой строки на счастливом пути есть тень. Сосед не ответил. Ответил через минуту. Ответил дважды. Бросил не то, что ждали. Рыдкулл передумал. Всё это несчастливый путь, и в коде на async/await его не видно. Он не записан ни в сигнатуре, ни в типе, ни в структуре функции. Он живёт в catch, в комментариях и в головах тех, кто однажды уже обжёгся.
Эту беду называют слепотой к несчастливому пути. С этим доводом за Effect выступал на конференции Effect Days Диллон Малрой (Dillon Mulroy), тогда инженер Vercel. Проверим довод на своей башне: допишем к ней всё, что просил Рыдкулл, по одному требованию.
Что лежит в catch
Первым делом журнал должен говорить, почему сосед не принял кадр. Пишешь catch (error) и тянешься к error.message. Строгий TypeScript не даёт: в catch у переменной тип unknown. Это часть режима strict, флаг useUnknownInCatchVariables (все строгие флаги разберём в уроке 04), и он прав. Бросить могли что угодно, это пятое место из прошлого урока: сосед может бросить и строку 'шторм'. Поэтому сначала разбор:
const messageOf = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
Это решает журнал, но не главное. По unknown нельзя понять, какая ошибка перед тобой: сосед в тумане, у соседа сломаны ставни или в нашем собственном коде опечатка. А тому, кто вызывает relayFrame, тип Promise<number> не говорит даже того, что ошибки бывают.
Таймаут
Своего таймаута у промиса нет. Первое, что приходит в голову, это гонка с таймером:
const withTimeout = <A>(work: Promise<A>, ms: number): Promise<A> =>
Promise.race([
work,
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)),
]);
Когда таймер выигрывает, withTimeout отклоняется, и ты идёшь дальше. А work продолжает работать. Promise.race перестаёт ждать проигравшего, но не останавливает его: ставни в сторону T-4 так и остались открыты, запрос к ней так и висит. Таймаут, который не останавливает работу, это не таймаут, а нетерпение.
Отмена
Остановить промис снаружи нельзя: у него нет такой кнопки. В JavaScript для этого есть AbortSignal (подробно он разобран в уроке «События, сигналы и AbortController»), и работает он по договорённости. Ты создаёшь сигнал и передаёшь его вниз руками, в каждую функцию по дороге, а каждая функция обещает его слушать. Поэтому у send меняется сигнатура: теперь она принимает третий аргумент, signal. Таймаут становится сигналом, который сработает сам через timeoutMs, и его склеивают с сигналом отмены от Рыдкулла:
const signal = AbortSignal.any([options.signal, AbortSignal.timeout(options.timeoutMs)]);
await deps.send(tower, frame, signal);
Две строки, но они тянут за собой всё остальное. Каждый, кто вызывает relayFrame, теперь обязан передать сигнал. Каждая функция ниже send тоже. И если хоть одна из них сигнал проигнорирует, отмена до неё не дойдёт, а компилятор об этом не предупредит.
Повторы
Сосед в тумане отвечает со второго раза, значит, нужен цикл попыток. Между попытками пауза, и каждая следующая длиннее: 100 * 2 ** attempt миллисекунд. Пауза тоже обязана слушать сигнал, иначе отмена будет ждать, пока она кончится. И сразу вопрос, на который нет ответа: какие ошибки повторять? Туман развеется. Заклинившие ставни сами не починятся, и повторять их значит тратить время. Но в catch у тебя unknown, и отличить одно от другого без проверок нечем. Поэтому цикл повторяет всё подряд.
Зависимости
Для журнала со временем функции нужны часы. Можно позвать Date.now() прямо в коде, но тогда в тесте время не подменить, и функция тайком зависит от того, чего нет в её сигнатуре. Такие связи называют скрытыми зависимостями. Честный путь это положить часы в deps рядом с send и log. Тогда каждый, кто вызывает relayFrame, обязан собрать полный deps и передать его дальше. Чем больше в программе функций, тем длиннее тянутся эти провода через весь код.
Трасса
Журнал говорит relayed 1/3, но Рыдкулл хочет знать больше: сколько отвечала каждая башня и с какой попытки. Это трасса. На голом async/await её собирают руками: засечь время до, засечь после, записать строку. Настоящая трасса со вложенными шагами потребует ещё и передавать контекст через каждый вызов, как сигнал.
Что выросло к вечеру
Вот функция башни со всеми требованиями Рыдкулла.
export type Deps = {
readonly send: (tower: string, frame: string, signal: AbortSignal) => Promise<void>;
readonly log: (line: string) => void;
readonly now: () => number;
};
export type Options = {
readonly timeoutMs: number;
readonly attempts: number;
readonly signal: AbortSignal;
};
const messageOf = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
const sleep = (ms: number, signal: AbortSignal) =>
new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, ms);
signal.addEventListener(
'abort',
() => {
clearTimeout(timer);
reject(signal.reason);
},
{ once: true },
);
});
const sendWithRetry = async (deps: Deps, tower: string, frame: string, options: Options) => {
const started = deps.now();
let last: unknown;
for (let attempt = 1; attempt <= options.attempts; attempt += 1) {
options.signal.throwIfAborted();
const signal = AbortSignal.any([options.signal, AbortSignal.timeout(options.timeoutMs)]);
try {
await deps.send(tower, frame, signal);
deps.log(`span ${tower}: ok in ${deps.now() - started}ms, attempt ${attempt}`);
return;
} catch (error) {
last = error;
if (attempt < options.attempts) await sleep(100 * 2 ** attempt, options.signal);
}
}
deps.log(`span ${tower}: failed in ${deps.now() - started}ms`);
throw last;
};
export async function relayFrame(
frame: string,
towers: readonly string[],
deps: Deps,
options: Options,
): Promise<number> {
const results = await Promise.allSettled(
towers.map((tower) => sendWithRetry(deps, tower, frame, options)),
);
let sent = 0;
for (const [index, result] of results.entries()) {
if (result.status === 'fulfilled') sent += 1;
else deps.log(`failed ${towers[index]}: ${messageOf(result.reason)}`);
}
deps.log(`relayed ${sent}/${towers.length}`);
return sent;
}
Утром в функции было двадцать с небольшим строк. Теперь около шестидесяти, и про сам кадр из них одна: await deps.send(tower, frame, signal). Остальное про несчастливый путь. Запустим на соседях: T-2 отвечает со второй попытки, у T-3 заклинило ставни, T-4 спит. Две попытки, таймаут пятьдесят миллисекунд:
span T-2: ok in 202ms, attempt 2
span T-3: failed in 202ms
span T-4: failed in 302ms
failed T-3: ставни заклинило
failed T-4: The operation was aborted due to timeout
relayed 1/3
Журнал наконец говорит правду. Но присмотрись. T-3 честно отработала вторую попытку и двести миллисекунд паузы, хотя ставни от ожидания не починятся. Таймаут подписан не твоими словами, а словами платформы, и в типах его нет: это DOMException с именем TimeoutError, и узнал ты о нём только из журнала. А тип функции остался прежним, Promise<number>. Всё, что ты сегодня выучил о провалах башни, живёт в теле функции, и тот, кто её вызывает, не увидит ничего.
И это ещё не всё, что нужно башне. Ответ соседа приходит с провода, и его форму хорошо бы проверить. Однажды Рыдкулл попросит ограничить, сколько отправок идёт одновременно. Каждое новое требование снова войдёт в каждую функцию по дороге.
Кривая сложности
Ниже модель того, как растёт код, когда требования надёжности добавляются одно за другим. Включай повторы, таймауты, отмену, зависимости, трассировку и проверку входа схемой и смотри на две кривые: число строк на async/await и на Effect. Это модель, а не замер: числа в ней условные, и в твоём проекте они будут другими. Смотри не на числа, а на форму кривых.
На async/await каждое требование вплетается во все остальные: повтор должен знать про отмену, отмена про паузу, трасса про повтор. Строки складываются, а сверху растут строки на стыки требований: виджет считает их отдельно. У Effect каждое требование это отдельный кусок, который пристёгивается к записи заклинания и не лезет внутрь соседних.
Тот же список на Effect
Для сравнения те же требования, записанные заклинанием. Как и в прошлом уроке, это взгляд вперёд, а не задача: повторы и расписания придут в разделе EF8, сервисы в EF5.
import { Context, Data, Effect, Result, Schedule } from 'effect';
class SendFailed extends Data.TaggedError('SendFailed')<{ readonly tower: string }> {}
class Towers extends Context.Service<
Towers,
{ readonly send: (tower: string, frame: string) => Effect.Effect<void, SendFailed> }
>()('Towers') {}
const relayFrame = Effect.fn('relayFrame')(function* (frame: string, towers: readonly string[]) {
const { send } = yield* Towers;
const results = yield* Effect.forEach(
towers,
(tower) =>
send(tower, frame).pipe(
Effect.timeout('1 second'),
Effect.retry({ times: 2, schedule: Schedule.exponential('100 millis') }),
Effect.tapError((error) => Effect.logWarning(`failed ${tower}: ${error._tag}`)),
Effect.withSpan('send', { attributes: { tower } }),
Effect.result,
),
{ concurrency: 'unbounded' },
);
const sent = results.filter(Result.isSuccess).length;
yield* Effect.log(`relayed ${sent}/${towers.length}`);
return sent;
});
Таймаут, повторы с растущей паузой, журнал неудач и шаг трассы с именем башни здесь по одной строке в цепочке после send. Отмену писать не пришлось: если таймаут сработал, Effect сам прерывает отправку, которая не успела, и прерывание доходит до всех вложенных шагов без проводов с сигналом. Зависимость от соседей записана в типе: relayFrame возвращает Effect<number, never, Towers>. Третья буква говорит, что заклинанию нужны башни, и компилятор не даст его произнести, пока ты их не дашь. В тесте вместо настоящих соседей ты дашь подставных.
Где тут дыра
Вечером к тебе подсаживается Ринцвальд, смотрит на шестьдесят строк и морщится.
Его версия на тех же Deps, что в первом уроке:
export async function relayFrame(
frame: string,
towers: readonly string[],
deps: Deps,
): Promise<number> {
let sent = 0;
await Promise.all(
towers.map(async (tower) => {
try {
await deps.send(tower, frame);
sent += 1;
} catch {}
}),
);
deps.log(`relayed ${sent}/${towers.length}`);
return sent;
}
На T-2 и T-6 всё хорошо: функция ждёт обе отправки, возвращает 2, в журнале relayed 2/2. Счёт даже честный: sent растёт только после удачной отправки.
Теперь добавь T-7. Её отправку Ринцвальд дописал вчера и ошибся номером ставни, так что send для неё бросает TypeError: Cannot read properties of undefined (reading 'open'). Это не туман и не сломанный сосед, это ошибка в нашем коде, которую нужно найти и исправить. Пустой catch ловит её так же охотно, как туман. В журнале relayed 2/3, и ни слова о том, что T-7 не заработает никогда, сколько её ни повторяй. Функция действительно не падает. Она врёт.
catch {} не отличает ожидаемую ошибку от дефекта. Первое надо обработать, второе исправить, а пустой catch прячет оба. В Effect у них разные места: ожидаемые ошибки записаны в E и видны в типе, а дефект идёт мимо E отдельным путём, и обработчики ожидаемых ошибок его не глотают.
Запомни эту фразу. В городе есть люди, которые любят зелёное ещё больше Ринцвальда.
Поздно вечером ты выходишь из Академии. У двери стоит Сундук: окованный латунью, на множестве коротких ножек. Ты делаешь шаг, и он делает шаг.
Никто его к тебе не посылал. Похоже, он выбрал сам.
Практика
Глубже в архиве
Чем Promise.all, allSettled и race отличаются друг от друга и почему проигравший в гонке продолжает работать, разбирает урок «Promise, thenable и async/await». Как прокинуть контекст без проводов через каждый вызов, есть в уроке «Контекст и модели»: там AsyncLocalStorage и Effect стоят рядом.
Дальше
Рыдкулл смотрит на шестьдесят строк, на журнал и на Сундук у тебя за спиной. Про Сундук он ничего не говорит.
Что значит «записать» и почему от этого исчезают провода с сигналами, разберём в следующем уроке.