Раздел 46 · Effect: Тихая Академия

Дефекты

middle~30 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

В прошлом уроке у каждой беды башни появилось имя, и все имена встали в тип. Но список в типе это список того, что башня ждёт. Этот урок о том, что делать с бедой, которой не ждали, потому что её не может быть, и почему её не надо ловить там же, где ловят чужие кадры.

Кадр, которого не может быть

Начнём с улики. Кадр пришёл от T-3, башня передаёт его дальше, к T-2. Для этого она собирает строку провода через encodeFrame, а тот кодирует текст encodeURIComponent. И encodeURIComponent бросает:

URIError: URI malformed

Бросает он в одном случае: в строке одинокий суррогат. Символы вроде 📡 в JavaScript занимают две ячейки строки, пару суррогатов. Разорви пару, и половинка останется в строке мусором, которого кодировщик не примет.

Откуда половинка взялась? С провода прийти она не могла: текст кадра на входе раскодирует decodeURIComponent, а он либо собирает целые символы, либо бросает URIError, и тогда башня отвечает BadPayload. Значит, суррогат сделал наш код. Так и есть: Ринцвальд добавил в башню обрезку длинных текстов до семи ячеек, body.slice(0, 7), и на тексте «гроза 📡 над рекой» срез прошёл ровно посередине эмодзи.

Теперь скоропись. Вот как Ринцвальд передавал кадры дальше до Effect:

import { encodeFrame, type Frame } from './frame';

declare const send: (line: string) => Promise<void>;

export const forwardLater = async (frame: Frame, next: string): Promise<void> => {
  for (let attempt = 1; attempt <= 3; attempt++) {
    try {
      await send(encodeFrame({ ...frame, from: 'T-1', to: next }));
      return;
    } catch (error) {
      console.log(`попытка ${attempt}: ${String(error)}`);
    }
  }
};

На кадре с половинкой эмодзи:

попытка 1: URIError: URI malformed
попытка 2: URIError: URI malformed
попытка 3: URIError: URI malformed

Повтор задуман для провода: оборвался, подождём, попробуем ещё. Но catch ловит всё, и ошибка в нашем же коде попадает туда же, куда обрыв. Три попытки закодировать строку, которую закодировать нельзя. А будь попыток не три, а «пока не получится», башня повторяла бы до утра.

Беды бывают двух сортов, и смешивать их нельзя. Обрыв провода случается сам, повтор может помочь. Половинка эмодзи это поломка программы: повторять бесполезно, каждый раз будет то же самое, и вызывающий код ничего с ней сделать не может. Её надо не обрабатывать, а заметить, громко упасть и чинить код.

Ошибка и дефект

Effect делит провалы ровно так. Ожидаемая ошибка из прошлого урока входит в дело: у неё есть имя, она стоит в E, вызывающий код решает, что с ней делать. А дефект это поломка: исключение в коде, который бросать не должен, нарушенное обещание. В типе дефекта нет, потому что перечислить заранее, где программа сломается, нельзя. В подвале у Гекса это различали с урока 31: Fail против Die. В Effect то же самое и с теми же именами.

Где проходит граница? Три вопроса.

  1. Может ли вызывающий что-то сделать? Пропустить кадр, повторить, ответить иначе: ошибка. Только упасть и сообщить: дефект.
  2. Чьи это данные? Чужое бывает битым, это часть жизни: провод, ввод человека, файл. Если битое пришло снаружи, это ошибка. Своё по замыслу цело: строки, которые башня собрала сама, её собственные обещания. Если битым оказалось своё, сломан наш код.
  3. Где ты стоишь? Одна и та же функция бывает и тем, и другим. decodeFrame на строке с провода падает ожидаемо, это DecodeError. Та же decodeFrame на строке, которую башня сама записала в журнал, падать не должна; если упала, у нас дефект. Решает не функция, а место, откуда её зовут.

Как дефект попадает в эффект

Первый путь ты уже знаешь, только с другой стороны. В уроке 36 было два способа завернуть код, который бросает:

Effect.tryEffect.sync
бросок становитсяожидаемой ошибкой в Eдефектом, в типе его нет
значит«бросать здесь могут, это часть дела»«бросать здесь не должны, если бросили, сломан код»
в башнеdecodePayload: текст с провода бывает битымencodeFrame при передаче дальше: текст уже наш

Выбор между ними это выбор границы. decodePayload стоит на входе, где чужие данные, и URIError там это BadPayload. Кодирование при передаче дальше идёт по тексту, который уже прошёл через башню, и бросок там значит, что башня сама испортила текст. Его заворачивают в Effect.sync, и URIError становится дефектом. Так же дефектом становится исключение в функции, переданной в Effect.map, Effect.flatMap и прочие комбинаторы: от них Effect броска не ждёт.

Второй путь явный: Effect.die(defect) создаёт эффект, который сразу падает дефектом. Тип у него Effect<never>: ни значения, ни ошибки.

const broken: Effect.Effect<never> = Effect.die(new Error('кадр без маршрута'));

Третий путь превращает ожидаемые ошибки в дефекты: Effect.orDie. Он берёт эффект, вынимает из E все ошибки и, если эффект упал, роняет его дефектом с той же ошибкой внутри. Это ответ на третий вопрос: башня перечитывает свой журнал, и строки там писала она сама.

const replay = (lines: readonly string[]) =>
  Effect.forEach(lines, (line) => Effect.orDie(decodeFrame(line)));
// Effect<Frame[]>

DecodeError из типа ушла. Не потому, что её обработали, а потому, что здесь она означает сломанный журнал, и вызывающему с ней делать нечего.

Ловить на краю

Дефект летит мимо всех обработчиков ожидаемых ошибок: мимо catchTag, мимо catch, мимо mapError. Так и задумано. Обработчик кадров не должен знать, как чинить чужой код, и не должен случайно превращать поломку в «пропущенный кадр».

Но где-то дефект всё же надо встретить. Башня не должна молча умирать посреди ночи из-за одного кадра: она должна записать, что кадр её уронил, и взять следующий. Для этого есть Effect.catchDefect: он ловит дефект и только его, ожидаемые ошибки идут мимо. Ставят его на краю, там, где кончается обработка одного кадра и начинается следующий, а не посреди логики.

Перевод с Ук

Первая попытка записать, что уронило кадр:

import { Effect } from 'effect';

import type { Frame } from './frame';
import { forward } from './tower';

declare const send: (line: string) => Promise<void>;
declare const frame: Frame;

export const logged = Effect.catchDefect(forward(send, frame, 'T-2'), (defect) =>
  Effect.succeed(`упал кодировщик: ${defect.message}`),
);
night.ts(10,38): error TS18046: 'defect' is of type 'unknown'.

Строка 10, колонка 38: defect в шаблонной строке. TS18046 значит, что у значения тип unknown, и обращаться к его полям нельзя, пока не сузишь. Почему unknown, а не Error? Потому что дефект это всё, что бросили, а в JavaScript бросить можно что угодно: ошибку, строку, число, undefined. Ожидаемая ошибка у нас перечислена в E, у дефекта списка нет и быть не может. Компилятор честно говорит: я не знаю, что тут лежит. Сужаем, как в уроке 06:

const logged = Effect.catchDefect(forward(send, frame, 'T-2'), (defect) =>
  Effect.succeed(`упал кодировщик: ${defect instanceof Error ? defect.message : String(defect)}`),
);

Effect.runPromise(logged).then(console.log);
упал кодировщик: URI malformed

Под капотом

IO.sync Гекса звал функцию внутри try, и если она бросала, свиток кончался причиной Die, а не Fail. IO.catch ловил только Fail и пропускал Die насквозь, а с этапа 4 цикл так же проносил Die мимо рамок провала. Effect делает то же самое: исключение внутри Effect.sync, Effect.map или функции у Effect.flatMap ловит цикл и кладёт в причину провала как Die. А когда дефект доезжает до края программы, Effect.runPromise отклоняет промис самим дефектом: наружу вылетит тот же URIError, со своим стеком. Строка, на которой башня сломалась, видна в трассе, и в этом весь смысл: дефект не прячут, его чинят.

Где тут дыра

import { Effect } from 'effect';

import { guard, sendLine } from './tower';

const brokenWire = async () => {
  throw new Error('обрыв на T-2');
};

const sendQuiet = (send: (line: string) => Promise<void>, line: string): Effect.Effect<void> =>
  Effect.orDie(sendLine(send, line));

const honest = Effect.match(guard(sendLine(brokenWire, 'T-1->T-2|1|Ping|')), {
  onSuccess: (outcome) => outcome,
  onFailure: (error) => `${error._tag}: ${error.reason}`,
});
const quiet = guard(sendQuiet(brokenWire, 'T-1->T-2|1|Ping|'));

const main = async () => {
  console.log(await Effect.runPromise(honest));
  console.log(await Effect.runPromise(quiet));
};
void main();
SendFailed: обрыв на T-2
crashed

Пока провод цел, разницы не видно: sendQuiet отправляет строку, тип короче. Ломается на первом обрыве. Честная версия сообщает SendFailed с причиной, и вызывающий может решить: подождать и повторить, записать в отчёт, что T-2 молчит. У Ринцвальда тот же обрыв доехал до края дефектом, и guard записал кадр в упавшие, как будто в башне ошибка в коде. Ожидаемую беду переодели в поломку, а повторить кадр теперь некому: catchTag(..., 'SendFailed', ...) даже не соберётся, тега в типе нет.

orDie годится там, где ошибка правда означает поломку: свой журнал, свой конфиг, разбор строки, которую ты только что сам собрал. На проводе, в чужих данных, в сети ошибка это часть дела, и место ей в E.

Башня, этап 5

Башня учится отличать поломку от ошибки дела. Передача кадра дальше заворачивает кодирование в Effect.sync, и одинокий суррогат становится дефектом. Свои строки из журнала башня разбирает строго, через orDie. А на краю обработки guard встречает дефект ответом 'crashed' и пропускает ожидаемые ошибки дальше, в тип.

Практика

Дальше

Башня больше не путает обрыв провода с поломкой кода. Пондер раскладывает на столе ночной журнал: у одного кадра в графе «итог» записано сразу два слова.

Из чего состоит провал эффекта, если бед в нём несколько, и почему catchTag может не поймать даже ошибку, которая стоит в типе, разберём в следующем уроке.