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

Провод к чужой книге: HttpClient

middle~25 мин

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

* Почтамт Анкмора стоит на месте трёх прежних. Первый принимал письма у всех и терял половину. Второй не принимал ни у кого и не терял ничего. Третий сгорел от писем, которые никто не забирал. Нынешний каждое утро начинает с того, что вешает на дверь список: что он делает и чего не делает.

До сих пор башня говорила с Магистралью на своих условиях: линия толкала строки, башня их разбирала, а что делать с мусором, решала сама. Почтамту этого мало. Ему надо спрашивать: послать запрос в чужую книгу и дождаться ответа. Книга живёт на другом конце провода, по HTTP, и ничего Почтамту не обещает. Она может ответить, может ответить «нет такой», может ответить мусором, а может не ответить вовсе.

На скорописи это fetch. Он честно отправляет запрос, но дальше всё на тебе: fetch не падает на 404, response.json() отдаёт any, повторов нет, а в тесте его приходится подменять глобально. Сегодня тот же разговор, только записанный.

Провод это сервис

В Effect HTTP-клиент это сервис HttpClient, такой же, как сервисы из урока 47: код говорит, что ему нужен провод, а какой именно, решает слой.

import { Effect, Schema } from 'effect';
import { HttpClient, HttpClientResponse } from 'effect/http';

export const TowerInfo = Schema.Struct({
  code: Schema.String,
  name: Schema.String,
  open: Schema.Boolean,
});

export const lookup = (code: string) =>
  Effect.gen(function* () {
    const client = yield* HttpClient.HttpClient;
    const response = yield* client.get(`http://towers.ankmor/towers/${code}`);
    return yield* HttpClientResponse.schemaBodyJson(TowerInfo)(response);
  });
//    ^? (code: string) => Effect<TowerInfo, HttpClientError | SchemaError, HttpClient>

Прочитай тип, как квитанцию из урока 36. R это HttpClient: без провода эффект не запустить. E это две беды. HttpClientError значит, что разговор не состоялся: провод оборвался или книга ответила не тем статусом. SchemaError значит, что разговор состоялся, но ответ не похож на башню. И только A это то, ради чего спрашивали: TowerInfo, разобранный по схеме, а не any.

client.get возвращает HttpClientResponse, ответ целиком: статус, заголовки, тело. HttpClientResponse.schemaBodyJson(TowerInfo) читает тело как JSON и прогоняет через схему. Это ровно урок 76: разбирай, а не проверяй. Книге Магистрали Академия верит не больше, чем кадрам Магистрали.

Настоящий провод даёт слой FetchHttpClient.layer: под капотом тот самый fetch.

const live = Effect.provide(lookup('T-2'), FetchHttpClient.layer);

В Node есть свой слой в @effect/platform-node, в Bun свой. Код lookup о них не знает и не должен знать: это будет главной мыслью всего раздела.

Прослойки

Каждый запрос в книгу башен должен идти на один адрес, просить JSON и подписываться: «это Почтамт, башня T-1». Писать это в каждом get скучно и ненадёжно, кто-нибудь забудет. HttpClient можно превратить в другой HttpClient, который делает это сам. Такие превращения называют прослойками (middleware):

import { Effect, flow, Schedule } from 'effect';
import { HttpClient, HttpClientRequest } from 'effect/http';

const book = Effect.map(HttpClient.HttpClient, (client) =>
  client.pipe(
    HttpClient.mapRequest(
      flow(
        HttpClientRequest.prependUrl('http://towers.ankmor'),
        HttpClientRequest.acceptJson,
        HttpClientRequest.setHeader('x-pochtamt', 'T-1'),
      ),
    ),
    HttpClient.filterStatusOk,
    HttpClient.retryTransient({ schedule: Schedule.exponential('100 millis'), times: 2 }),
  ),
);

Разберём по строкам.

  • HttpClient.mapRequest(f) меняет каждый запрос перед отправкой. flow из урока 21 склеивает три правки в одну функцию: приставить адрес книги к пути, поставить Accept: application/json, добавить свой заголовок.
  • HttpClient.filterStatusOk превращает ответ со статусом не из двухсотых в ошибку HttpClientError. Это то, чего не делает fetch: для него 404 обычный ответ с телом.
  • HttpClient.retryTransient повторяет запрос, если беда временная. Сколько ждать между попытками, говорит расписание из урока 72: 100 миллисекунд, потом 200. Попыток всего три: первая и два повтора.

Порядок прослоек это порядок вызовов, но здесь он почти ничего не решает. filterStatusOk стоит до retryTransient, и повтор видит 503 уже ошибкой HttpClientError. Поменяй их местами, и 503 всё равно повторят: по умолчанию retryTransient смотрит и на ошибки, и на ответы. Порядок начнёт решать, если сказать повтору retryOn: 'errors-only': тогда он видит только ошибки, и 503, которое ещё не стало ошибкой, проходит мимо него.

Какие беды временные

retryTransient не повторяет всё подряд. Временной в 4.1 считается беда, после которой есть смысл спросить ещё раз:

  • обрыв провода (TransportError) и таймаут;
  • статусы 408 (запрос шёл слишком долго), 429 (слишком часто спрашиваешь), 500, 502, 503 и 504 (у книги что-то сломалось или она занята).

А 404 и 400 не временные. Если башни T-9 нет в книге, через 100 миллисекунд она там не появится, а лишний вопрос только нагрузит книгу. Если книга прислала заголовок Retry-After, повтор не пойдёт раньше, чем она просила. Тикка сказала бы, что это и есть разница между повтором и стуком молотком.

Беда словами

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

export class TowerBookError extends Schema.TaggedError<TowerBookError>()('TowerBookError', {
  code: Schema.String,
  reason: Schema.String,
}) {}

Чтобы перевести беду провода в слова, у HttpClientError есть поле reason с тегом: StatusCodeError, если книга ответила, но не тем статусом, TransportError, если провод оборвался, DecodeError и EmptyBodyError, если не разобралось тело. У StatusCodeError есть сам ответ, а значит, и статус:

const reasonOf = (error: HttpClientError.HttpClientError | Schema.SchemaError): string => {
  if (error._tag === 'SchemaError') return 'ответ не по форме';
  switch (error.reason._tag) {
    case 'StatusCodeError':
      return error.reason.response.status === 404 ? 'нет такой башни' : 'книга ответила отказом';
    case 'TransportError':
      return 'книга не отвечает';
    default:
      return 'ответ не по форме';
  }
};

default ловит теги про тело: книга прислала не JSON или пустой ответ. С точки зрения окошка это тоже «не по форме». В сервисе книги останется приклеить это к запросу: Effect.mapError((error) => new TowerBookError({ code, reason: reasonOf(error) })). Сам сервис Почтамту виден так:

export class TowerBook extends Context.Service<
  TowerBook,
  { readonly lookup: (code: string) => Effect.Effect<typeof TowerInfo.Type, TowerBookError> }
>()('pochtamt/TowerBook') {}

Один метод, а в его типе ни слова про HTTP: башня или беда словами. Слой TowerBook.layer(baseUrl) соберёт его из HttpClient с прослойками выше, это первая работа этапа.

Почему не отдать окошку HttpClientError как есть? Потому что тогда каждый, кто зовёт книгу, будет знать про HTTP. Завтра книга переедет в базу данных, и всем придётся переписывать обработку бед. Ошибка с именем Почтамта прячет провод за сервисом, как сам сервис прячет fetch.

Провод в тестах это слой

Главная выгода провода-сервиса: в тесте провода нет. HttpClient.make строит клиента из функции «запрос в ответ», и эта функция может отвечать что угодно, без сети:

const bridge = { code: 'T-2', name: 'Мост', open: true };

const fake = HttpClient.make((request) =>
  Effect.succeed(
    HttpClientResponse.fromWeb(
      request,
      request.url.endsWith('/T-2')
        ? new Response(JSON.stringify(bridge), { status: 200 })
        : new Response('нет', { status: 404 }),
    ),
  ),
);

const tested = Effect.provide(lookup('T-2'), Layer.succeed(HttpClient.HttpClient, fake));

HttpClientResponse.fromWeb заворачивает обычный Response из Fetch API в ответ Effect. Тест может записать каждый запрос и проверить адрес, заголовки и сколько раз спросили. Ни моков fetch, ни глобальных подмен: тот же приём, что и со слоями в уроке 53. Повторы ждут по часам, поэтому тест двигает TestClock, как в уроке 74, и три попытки проходят без настоящего ожидания.

Нестабильно. Модули effect/http и effect/http-api в 4.1 помечены @stability unstable, как и вся платформа этого раздела: effect/rpc, effect/sql, effect/cli, FileSystem, Path. Имена и настройки могут поменяться в минорной версии. Уроки EF11 написаны на 4.1.0.

Под капотом

Где тут дыра

const knownTower = async (code: string): Promise<TowerInfo> => {
  const response = await fetch(`http://towers.ankmor/towers/${code}`);
  return (await response.json()) as TowerInfo;
};

На его примере всё работает: башня T-2 есть, книга отвечает {"code":"T-2","name":"Мост","open":true}, as ничего не ломает. Соседний пример: горожанин несёт письмо в T-9, которой нет. Книга отвечает 404 и телом {"error":"нет такой башни"}. fetch не падает: 404 для него нормальный ответ. json() разбирает тело, as говорит компилятору, что это башня, и окошко получает «башню» без кода и без имени. Письмо принято в никуда.

Ещё один сосед: книга перегружена и отвечает 503 страницей HTML. Тогда json() бросает исключение, и окошко падает с SyntaxError, хотя через секунду книга ответила бы. Ринцвальд сам это нашёл: «у меня одна строка делала три работы и ни одну не проверяла».

Почтамт, этап 1

Новое изделие, Почтамт. Первый модуль src/towers.ts: сервис книги башен, который ходит в книгу по HTTP, разбирает ответ схемой, повторяет временные беды и называет каждую беду словами для окошка. Тесты подменяют провод своим клиентом и считают запросы: лишний повтор на 404 или потерянный заголовок видны сразу.

Практика

Глубже в архиве

Каким бывает хороший HTTP-адрес и что значит каждый статус, рассказывает урок про REST, а повторы, таймауты и предохранители без Effect в уроке про устойчивость.

Дальше

К вечеру Почтамт умеет спрашивать книгу башен и не верит ей на слово. Липвиг перечитывает план и стучит пальцем по рядам окошек.

Окошки Почтамта рисуют в следующем уроке.