Раздел 25 · Effect-TS

HttpClient и обвязка API: ретраи, схемы, middleware, CORS, OpenAPI

senior~100 мин

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

HttpClient и обвязка API: ретраи, схемы, middleware, CORS, OpenAPI

Сцена · функция, которую ты писал двадцать раз

Открой любой свой проект и найди файл api.ts. Скорее всего там лежит вот это:

const BASE = process.env.API_URL!;

async function apiGet<T>(path: string): Promise<T> {
  const res = await fetch(`${BASE}${path}`, {
    headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
    signal: AbortSignal.timeout(5000),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json() as Promise<T>;
}

Четыре проблемы, и все четыре ты уже умеешь называть по именам.

as Promise<T> это ложь. Никто не проверял, что сервер прислал то, что обещал. Мы лечили это Schema в 02 · Schema, но здесь fetch возвращает any, и вся типизация держится на честном слове.

throw new Error стирает информацию. 404 и обрыв соединения это разные ситуации с разной реакцией, а тут они превратились в одинаковые строки. Мы лечили это tagged-ошибками в 03 · Tagged-ошибки.

Ретраев нет, а когда они появятся, они будут вида for (let i = 0; i < 3; i++) и повторят в том числе 400 Bad Request, который повторять бессмысленно.

Это не тестируется. fetch глобальный, и подменять его придётся моками, а моки в 13 · Testing мы решили не любить.

HttpClient из effect/unstable/http это тот же fetch, но как сервис: с типизированными ошибками, схемами, ретраями, таймаутами и подменой в тестах одной строкой. А вторая половина урока про входящую сторону: middleware, авторизация, CORS, OpenAPI и производный клиент, который берётся из описания API бесплатно.

Отдельного пакета @effect/platform больше нет: всё, что раньше жило там, переехало внутрь effect. HTTP лежит в effect/unstable/http, декларативное описание API в effect/unstable/httpapi. Ставить нечего, импортировать проще.

Карта урока · что заберёшь домой

Восемь разделов:

  1. Раздел 1, HttpClient как сервис. Первый запрос и что можно достать из ответа.
  2. Раздел 2, декодирование ответа схемой. schemaBodyJson и разбор причин ошибки.
  3. Раздел 3, клиент как конфигурация: базовый URL, заголовки, filterStatusOk, retryTransient, таймаут.
  4. Раздел 4, подмена клиента в тестах через HttpClient.make, без единого мока.
  5. Раздел 5, кеш и ограничение частоты: RateLimiter, token bucket против fixed window.
  6. Раздел 6, серверные middleware: HttpApiMiddleware.Service, HttpApiSecurity, CurrentUser в контексте.
  7. Раздел 7, CORS и OpenAPI одной строкой каждый.
  8. Раздел 8, HttpApiClient, клиент, выведенный из описания API, с типизированными ошибками.

К концу урока у Pulse появляется взрослый исходящий клиент, авторизация на входе и живая страница документации.

Раздел 1 · HttpClient как сервис

Первый запрос

import { Effect } from 'effect';
import { FetchHttpClient, HttpClient } from 'effect/unstable/http';

const program = Effect.gen(function* () {
  const client = yield* HttpClient.HttpClient;
  const response = yield* client.get('https://api.github.com/repos/effect-ts/effect');
  const json = yield* response.json;
  return json;
}).pipe(Effect.provide(FetchHttpClient.layer));

Три вещи, которые тут стоит заметить.

HttpClient.HttpClient это обычный сервис, как Storage или Logger в твоём Pulse. Значит, канал R теперь помнит, что программе нужен HTTP-клиент, и без Effect.provide она не соберётся.

FetchHttpClient.layer это реализация поверх глобального fetch. В браузере, в Node 24, в Deno, везде одна и та же. Есть альтернативные слои (например, на undici с пулом соединений) в @effect/platform-node, но начинают всегда с FetchHttpClient.

И третье, приятное. Scope тут не нужен: запрос сам живёт ровно столько, сколько живёт эффект, который его выполняет. Если тебе всё-таки нужно привязать жизнь соединения к области видимости вручную (скачиваешь гигабайтный поток и хочешь гарантированно закрыть его на выходе), это включается явно: HttpClient.withScope(client). Тогда в канале R появится Scope из 05 · Resources, и ты сам решишь, где его закрыть.

Что можно достать из ответа

const response = yield* client.get(url);

response.status;        // number
response.headers;       // Headers
yield* response.json;   // unknown
yield* response.text;   // string
yield* response.stream; // Stream<Uint8Array>

Обрати внимание на response.json: это эффект, а не промис и не готовое значение. Пока ты его не выполнил, тело не читалось. И тип у него unknown, а не any, поэтому компилятор не даст сделать json.items.map(...) без разбора. Это ровно тот случай, где начинается Раздел 2.

Раздел 2 · Ответ разбирает схема

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

const Monitor = Schema.Struct({
  id: Schema.String,
  url: Schema.String,
  lastStatus: Schema.NullOr(Schema.Number),
});

const listMonitors = Effect.gen(function* () {
  const client = yield* HttpClient.HttpClient;
  const response = yield* client.get('/monitors');
  return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Monitor))(response);
});
// Effect<ReadonlyArray<Monitor>, HttpClientError | SchemaError, HttpClient>

Посмотри на канал ошибок. Там два разных типа, и это честно:

  • HttpClientError, что-то пошло не так на стороне транспорта: сеть не ответила, DNS не разрешился, случился таймаут, сервер вернул неожиданный статус;
  • SchemaError, сервер ответил, но прислал не то, что обещал.

Это две разные ситуации с разной реакцией. Первую имеет смысл повторить, вторую нет: если API сломало контракт, повтор его не починит. Разделение прямо в типе, а не в комментарии, это главное, ради чего вся конструкция затевалась.

Внутри HttpClientError есть второй уровень. Сам класс один, а конкретная беда лежит в поле reason, и это размеченное объединение из шести вариантов:

reason._tagЧто случилосьЕсть ли ответ
TransportErrorсеть не дошла, обрыв, таймаут соединениянет
InvalidUrlErrorадрес не разобралсянет
EncodeErrorне удалось собрать тело запросанет
StatusCodeErrorответ пришёл, но со статусом вне фильтрада
DecodeErrorтело ответа не читается как обещанода
EmptyBodyErrorждали тело, получили пустотуда

Первые три это “запрос не доехал”, последние три это “ответ доехал, но не годится”. Обработка выглядит так:

import { Match } from 'effect';

const safe = listMonitors.pipe(
  Effect.catchTag('SchemaError', (error) =>
    Effect.logError('внешний API сломал контракт', error).pipe(Effect.as([])),
  ),
  Effect.catchTag('HttpClientError', (error) =>
    Match.value(error.reason).pipe(
      Match.tag('StatusCodeError', (r) => Effect.logWarning(`статус ${r.response.status}`)),
      Match.orElse(() => Effect.logWarning('сеть не отдала ответ')),
    ).pipe(Effect.as([])),
  ),
);

Одна обёртка вместо россыпи классов это сознательный выбор v4: ловить HTTP-ошибку целиком проще, а разбирать причину нужно далеко не всегда.

Раздел 3 · Клиент это конфигурация, а не вызовы

Самая частая ошибка при переходе с fetch это оставить настройки на месте вызова: тут таймаут, там ретрай, здесь заголовок забыли. Правильный ход в том, что настраивают клиента один раз, а вызовы остаются голыми.

import { Context, Effect, flow, Layer, Schedule, Schema } from 'effect';
import {
  FetchHttpClient,
  HttpClient,
  HttpClientError,
  HttpClientRequest,
  HttpClientResponse,
} from 'effect/unstable/http';

type Fail = HttpClientError.HttpClientError | Schema.SchemaError;

type PulseClientShape = {
  readonly list: Effect.Effect<ReadonlyArray<Monitor>, Fail>;
  readonly byId: (id: string) => Effect.Effect<Monitor, Fail>;
};

class PulseClient extends Context.Service<PulseClient, PulseClientShape>()('PulseClient') {
  static readonly layer = Layer.effect(
    PulseClient,
    Effect.gen(function* () {
      const client = (yield* HttpClient.HttpClient).pipe(
        HttpClient.mapRequest(
          flow(
            HttpClientRequest.prependUrl('https://api.pulse.dev'),
            HttpClientRequest.bearerToken('secret-token'),
            HttpClientRequest.acceptJson,
          ),
        ),
        HttpClient.filterStatusOk,
        HttpClient.retryTransient({ times: 3, schedule: Schedule.exponential('100 millis') }),
      );

      const list = client.get('/monitors').pipe(
        Effect.flatMap(HttpClientResponse.schemaBodyJson(Schema.Array(Monitor))),
        Effect.timeout('2 seconds'),
      );

      const byId = (id: string) =>
        client
          .get(`/monitors/${id}`)
          .pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Monitor)));

      return PulseClient.of({ list, byId });
    }),
  ).pipe(Layer.provide(FetchHttpClient.layer));
}

Форма сервиса тут ровно та же, что в 04 · Services и Layer: Context.Service описывает контракт, статический layer собирает реализацию, Layer.provide закрывает её зависимость от FetchHttpClient.

Разберём комбинаторы по одному.

HttpClient.mapRequest пропускает каждый исходящий запрос через функцию. Готовых преобразований хватает: prependUrl, appendUrl, setHeader, setHeaders, bearerToken, acceptJson, setUrlParams. Несколько подряд удобно склеить через flow, как выше. Есть и эффектная версия mapRequestEffect, если токен надо каждый раз доставать из сервиса (например, из хранилища с обновлением по refresh-токену).

HttpClient.filterStatusOk превращает любой статус вне диапазона 2xx в ошибку с причиной StatusCodeError. Без него client.get считает 500 нормальным ответом: с точки зрения HTTP сервер ведь ответил.

HttpClient.retryTransient повторяет только временные сбои: сетевые ошибки и статусы 408, 429, 5xx. Это принципиально важно. Наивный Effect.retry повторил бы и 400 Bad Request, что бесполезно, и 401, что вредно (быстро заблокируют). Расписание берётся любое из 11 · Runtime и Schedule.

Effect.timeout ставится на вызов, а не на клиента, потому что разумный таймаут у списка и у выгрузки файла разный.

Сравни с apiGet из сцены: тот же объём кода, но каждая строка тут делает ровно одну вещь, и любая снимается по отдельности.

Раздел 4 · Тестируемость: клиент подменяется целиком

Раз клиент это сервис, в тесте он подменяется слоем. Никакого vi.mock('node-fetch'), никакого перехвата глобального fetch.

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

const StubHttpClient = HttpClient.make((request) =>
  Effect.succeed(
    HttpClientResponse.fromWeb(
      request,
      new Response(JSON.stringify([{ id: 'm1', url: 'https://stub.test' }]), {
        status: 200,
        headers: { 'content-type': 'application/json' },
      }),
    ),
  ),
);

export const StubHttpClientLive = Layer.succeed(HttpClient.HttpClient, StubHttpClient);

HttpClientResponse.fromWeb(request, response) строит ответ из стандартного Response, который есть в любом рантайме. Никаких специальных фикстур учить не надо.

Дальше это подставляется вместо FetchHttpClient.layer, и весь код клиента (базовый URL, ретраи, декодирование) продолжает работать как в проде. Тестируется именно твой код, а не fetch.

Стенд для проверки ретраев собирается так же, только с изменяемым счётчиком:

const FlakyHttpClient = (failures: number) =>
  Effect.gen(function* () {
    const attempts = yield* Ref.make(0);
    return HttpClient.make((request) =>
      Ref.updateAndGet(attempts, (n) => n + 1).pipe(
        Effect.map((n) =>
          HttpClientResponse.fromWeb(
            request,
            new Response(n <= failures ? 'nope' : '[]', { status: n <= failures ? 503 : 200 }),
          ),
        ),
      ),
    );
  });

Тест на “два раза 503, потом успех, ровно три попытки” пишется без единого реального сетевого вызова и без единой секунды ожидания, если ретраи крутятся на TestClock.

Раздел 5 · Не долби чужой API

Кеш

Повторный запрос за тем же ресурсом через сто миллисекунд обычно не нужен. Effect.cachedWithTTL из 10 · Batching закрывает это одной строкой:

const cachedList = yield* Effect.cachedWithTTL(list, '30 seconds');

cachedList это эффект, который первый раз сходит по сети, а следующие тридцать секунд отдаёт сохранённый результат. Для запросов с параметром берут Effect.cachedFunction или полноценный Cache.make с ограничением по размеру.

Ограничение частоты

Когда внешний API говорит “не больше 10 запросов в секунду”, нужен RateLimiter из effect/unstable/persistence:

import { Effect, Layer } from 'effect';
import { RateLimiter } from 'effect/unstable/persistence';

const program = Effect.gen(function* () {
  const withLimiter = yield* RateLimiter.makeWithRateLimiter;

  const limited = withLimiter({
    key: 'pulse-api',
    limit: 2,
    window: '1 second',
    algorithm: 'token-bucket',
    onExceeded: 'delay',
  });

  yield* Effect.all(
    urls.map((url) => limited(fetchOne(url))),
    { concurrency: 'unbounded' },
  );
}).pipe(Effect.provide(RateLimiter.layer.pipe(Layer.provide(RateLimiter.layerStoreMemory))));

Тут стоит остановиться на двух вещах, которых в v3 не было.

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

Вторая: у ограничителя есть хранилище, отдельным слоем. layerStoreMemory держит счётчики в памяти процесса, layerStoreRedis в Redis. Разница принципиальная: с памятью лимит соблюдается в каждом экземпляре сервиса отдельно, с Redis на весь кластер сразу. Раньше выбора не было, теперь он есть, и делать его нужно осознанно.

Опция onExceeded решает, что делать при превышении: 'delay' подождёт и пропустит, 'fail' вернёт ошибку сразу. Первое подходит фоновому пробингу, второе входящему запросу от пользователя, которому лучше честно получить 429, чем висеть.

Обрати внимание: concurrency: 'unbounded', а темп всё равно держится. Ограничитель отвечает за частоту, а не за параллелизм, это разные вещи (за параллелизм отвечает Semaphore из 07 · Координация).

Если ограничитель нужен именно исходящему HTTP-клиенту, есть готовый комбинатор HttpClient.withRateLimiter: он не только держит темп, но и умеет читать заголовки лимита из ответа и сам повторять 429.

Алгоритм выбирается опцией, и разница видна на глаз. Пять задач при лимите 2 в секунду.

token-bucket (по умолчанию), токены капают равномерно:

#1 at 1ms
#2 at 1ms
#3 at 505ms
#4 at 1005ms
#5 at 1506ms

fixed-window, окно сбрасывается целиком:

#1 at 1ms
#2 at 2ms
#3 at 1006ms
#4 at 1006ms
#5 at 2008ms

Практика простая: если чужой API считает запросы окнами (а так делает большинство), бери fixed-window, чтобы совпадать с его учётом. Если хочешь ровную нагрузку без всплесков, оставляй token-bucket.

Раздел 6 · Middleware и авторизация на входе

Переходим на серверную сторону. Описание API через HttpApi мы собрали в 12 · Production-обвязка; теперь навесим на него авторизацию.

Middleware в HttpApi объявляется классом, как сервис:

import { Context, Effect, Layer, Redacted, Schema } from 'effect';
import { HttpApiMiddleware, HttpApiSecurity } from 'effect/unstable/httpapi';

class Unauthorized extends Schema.TaggedError<Unauthorized>()('Unauthorized', {}, {
  httpApiStatus: 401,
}) {}

class CurrentUser extends Context.Service<CurrentUser, { readonly id: string }>()('CurrentUser') {}

class Auth extends HttpApiMiddleware.Service<Auth, { provides: CurrentUser }>()('Auth', {
  error: Unauthorized,
  security: { bearer: HttpApiSecurity.bearer },
}) {}

Контракт описан целиком, и обрати внимание, что он разложен на два уровня. Сервисы (provides, а если middleware сам от чего-то зависит, то requires) это типы, поэтому они стоят в угловых скобках. Всё остальное это значения, и они идут в объекте опций:

  • provides, какой сервис middleware кладёт в контекст. Именно он станет доступен handler-ам.
  • error, чем middleware может закончиться неудачно. Схема ошибки попадёт и в ответ, и в OpenAPI. Код ответа задаётся третьим аргументом Schema.TaggedError, прямо на классе ошибки.
  • security, откуда брать данные: bearer, apiKey (заголовок, cookie или query), basic.

Реализация это обычный Layer. Смотри внимательно на её форму, она изменилась и стала честнее:

const AuthLive = Layer.effect(
  Auth,
  Effect.gen(function* () {
    // тут можно поднять зависимости: базу, кеш сессий, внешний провайдер
    return Auth.of({
      bearer: (httpEffect, { credential }) =>
        Redacted.value(credential) === 'secret-token'
          ? Effect.provideService(httpEffect, CurrentUser, { id: 'user-1' })
          : Effect.fail(new Unauthorized()),
    });
  }),
);

Middleware теперь не “возвращает пользователя”, а оборачивает эффект эндпоинта. Первый аргумент httpEffect это вся оставшаяся обработка запроса, и ты решаешь, что с ней сделать: подложить сервис через Effect.provideService и пропустить дальше, или не пропускать вовсе. Форма чуть многословнее, зато сразу видно, где кончается middleware и начинается handler, и в неё бесплатно помещаются вещи, которые раньше не помещались: замерить время всей обработки, поймать ошибку эндпоинта, повторить, отменить по таймауту.

Токен приходит завёрнутым в Redacted, и это не мелочь: случайно залогировать credential теперь нельзя, в логе будет <redacted>. Подробнее про Redacted в 20 · Config, секреты и platform.

Дальше middleware вешается на группу (или на отдельный endpoint):

const monitors = HttpApiGroup.make('monitors')
  .add(HttpApiEndpoint.get('list', '/monitors', { success: Schema.Array(Monitor) }))
  .middleware(Auth);

И handler получает право спросить CurrentUser:

const MonitorsLive = HttpApiBuilder.group(PulseApi, 'monitors', (handlers) =>
  handlers.handle('list', () =>
    Effect.gen(function* () {
      const user = yield* CurrentUser;
      return yield* storage.listFor(user.id);
    }),
  ),
);

Здесь окупается типизация: убери .middleware(Auth), и yield* CurrentUser перестанет компилироваться, потому что в контексте его больше никто не предоставляет. В express-стиле ты бы узнал об этом в проде через req.user is undefined.

Раздел 7 · CORS и OpenAPI

CORS одной строкой

import { Layer } from 'effect';
import { HttpRouter } from 'effect/unstable/http';

export const ServerLive = HttpRouter.serve(
  Layer.mergeAll(ApiLive, HttpRouter.cors({ allowedOrigins: ['https://pulse.dev'] })),
).pipe(Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })));

CORS в v4 это просто ещё один слой роутера, такой же, как маршрут: подмешал в Layer.mergeAll, и всё. Опции покрывают весь стандарт: allowedOrigins, allowedMethods, allowedHeaders, exposedHeaders, credentials, maxAge. Preflight-запросы OPTIONS обрабатываются сами.

Одно предупреждение, которое стоит держать в голове: allowedOrigins: ['*'] вместе с credentials: true это дыра, и браузеры такое сочетание просто не выполнят. В проде перечисляй домены явно.

OpenAPI бесплатно

Спецификация уже есть: ты описал пути, схемы запросов, схемы ответов и схемы ошибок. Осталось её показать.

import { HttpApiScalar } from 'effect/unstable/httpapi';

const DocsLive = HttpApiScalar.layer(PulseApi, { path: '/docs' });

Само описание отдаётся тем же слоем, что строит роутер: HttpApiBuilder.layer(PulseApi, { openapiPath: '/openapi.json' }).

Есть и классический Swagger UI (HttpApiSwagger.layer), выбор чисто вкусовой. Важно другое: спека выведена из кода, а не написана рядом. Поменял схему ответа, страница документации обновилась в тот же момент. Именно ради этого стоило описывать API декларативно.

Аннотации попадают в спеку, так что документацию имеет смысл писать прямо в схемах: Schema.annotate({ description, examples }) на поле, OpenApi.annotations({ title, description }) через .annotateMerge(...) на группе или на всём API.

Раздел 8 · Производный клиент

Финальный аккорд. Если у тебя есть описание PulseApi, клиент к нему выводится автоматически:

import { Effect } from 'effect';
import { FetchHttpClient, HttpClient, HttpClientRequest } from 'effect/unstable/http';
import { HttpApiClient } from 'effect/unstable/httpapi';

const program = Effect.gen(function* () {
  const client = yield* HttpApiClient.make(PulseApi, {
    baseUrl: 'http://localhost:3199',
    transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken('secret-token')),
  });

  const monitors = yield* client.monitors.list();
  const one = yield* client.monitors.byId({ params: { id: 'm1' } });
}).pipe(Effect.provide(FetchHttpClient.layer));

Одна деталь про поля вызова. Параметры пути передаются под ключом params, ровно так же, как они объявлены в HttpApiEndpoint.get(id, path, { params }) и как приходят в handler. Раньше объявление называлось setPath, а вызов path, и на этой рассинхронизации регулярно спотыкались.

Ни одного строкового пути в коде вызова. Имена групп и endpoint-ов, форма параметров, схема ответа, всё выведено из описания. Переименуешь endpoint, компилятор покажет все места вызова.

И главное отличие от ручного клиента из Раздела 3. Сравни, что приходит в канал ошибок при запросе несуществующего монитора:

// ручной клиент с filterStatusOk
const missing = yield* raw.byId('nope').pipe(Effect.result);
// Failure(HttpClientError с reason StatusCodeError), статус 404 и всё

// производный клиент
const missing = yield* client.monitors.byId({ params: { id: 'nope' } }).pipe(Effect.result);
// Failure(NotFound { id: 'nope' }), твоя доменная ошибка целиком

Кстати про Effect.result. Он пришёл на место Effect.either и отдаёт Result, а не Either: разбирается через Result.isFailure, .failure и .success. Подробнее в 13 · Testing.

Производный клиент знает схемы ошибок из описания и декодирует тело ответа обратно в твой tagged-класс. Это ровно то, чего не хватало в сцене с throw new Error.

Отсюда практическое правило: для своих сервисов бери HttpApiClient, он даёт типизированные ошибки и держит контракт. Для чужих API бери HttpClient плюс Schema, потому что описания в виде HttpApi у чужого сервиса нет.

Pulse · вклад этого урока

Исходящая сторона. HttpService из 04 · Services и Layer переписывается на HttpClient:

// pulse-<nick>/src/services/http.ts
import { Clock, Context, Effect, Layer, Schedule } from 'effect';
import { FetchHttpClient, HttpClient, HttpClientRequest } from 'effect/unstable/http';

type ProbeResult = { readonly status: number; readonly elapsedMs: number };

export class HttpService extends Context.Service<
  HttpService,
  { readonly probe: (url: string) => Effect.Effect<ProbeResult, ProbeFailed> }
>()('HttpService') {
  static readonly layer = Layer.effect(
    HttpService,
    Effect.gen(function* () {
      const client = (yield* HttpClient.HttpClient).pipe(
        HttpClient.mapRequest(HttpClientRequest.setHeader('user-agent', 'pulse/1.0')),
        HttpClient.filterStatusOk,
        HttpClient.retryTransient({ times: 2, schedule: Schedule.exponential('200 millis') }),
        HttpClient.tapRequest((request) => Effect.logDebug(`probe ${request.url}`)),
      );

      const probe = (url: string) =>
        Effect.gen(function* () {
          const started = yield* Clock.currentTimeMillis;
          const response = yield* client.get(url);
          const finished = yield* Clock.currentTimeMillis;
          return { status: response.status, elapsedMs: finished - started };
        }).pipe(
          Effect.timeout('5 seconds'),
          Effect.catch((cause) => Effect.fail(new ProbeFailed({ url, cause }))),
          Effect.withSpan('probe'),
        );

      return HttpService.of({ probe });
    }),
  ).pipe(Layer.provide(FetchHttpClient.layer));
}

Что изменилось по сравнению с прошлой версией: ретраи временных сбоев переехали внутрь клиента и больше не дублируются на вызовах, каждый запрос логируется на уровне Debug, время берётся из Clock (значит, тест на TestClock не будет ждать по-настоящему), а спан из 18 · Observability уже на месте. Наружу сервис отдаёт одну доменную ошибку ProbeFailed, а вся кухня HttpClientError остаётся внутри: остальному Pulse не нужно знать, был это обрыв соединения или пятисотка.

Входящая сторона. К API добавляются:

  • middleware Auth с bearer-токеном, чтобы /monitors не был публичным;
  • HttpRouter.cors с явным списком доменов;
  • HttpApiScalar на /docs;
  • производный клиент в тестах вместо ручных запросов.

Тестовая сборка теперь выглядит так: StubHttpClientLive вместо FetchHttpClient.layer для исходящих запросов, HttpApiClient поверх поднятого в памяти сервера для входящих. Ни одного реального сетевого вызова в тестах.

Финал · чек-лист

Чек-листготово

ДЗ

Все задания делаются в pulse-<nick>. После каждого открой PR с тегом lesson-19.

Дальше

  • 20 · Config, секреты и platform. Токен, базовый URL и список разрешённых доменов не должны быть константами в коде. Следующий урок переселяет их в Config и учит не светить секреты в логах.
  • 21 · Schema, второй заход. Тут мы декодировали простые ответы. Дальше рекурсивные схемы, асинхронная валидация и человеческие сообщения об ошибках разбора.

Полезно перечитать: