HttpClient и обвязка API: ретраи, схемы, middleware, CORS, OpenAPI
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
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,
HttpClientкак сервис. Первый запрос и что можно достать из ответа. - Раздел 2, декодирование ответа схемой.
schemaBodyJsonи разбор причин ошибки. - Раздел 3, клиент как конфигурация: базовый URL, заголовки,
filterStatusOk,retryTransient, таймаут. - Раздел 4, подмена клиента в тестах через
HttpClient.make, без единого мока. - Раздел 5, кеш и ограничение частоты:
RateLimiter, token bucket против fixed window. - Раздел 6, серверные middleware:
HttpApiMiddleware.Service,HttpApiSecurity,CurrentUserв контексте. - Раздел 7, CORS и OpenAPI одной строкой каждый.
- Раздел 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, второй заход. Тут мы декодировали простые ответы. Дальше рекурсивные схемы, асинхронная валидация и человеческие сообщения об ошибках разбора.
Полезно перечитать:
- 09-backend · 18 Паттерны устойчивости, про то, какие ретраи допустимы и когда они превращаются в лавину.
- 12-security · 02 XSS, CSP и секреты, соседняя тема: какие заголовки браузер проверяет и почему звёздочка в списке доменов это плохая идея.