Към съдържанието
wannacode

Дизайн на API: договор, версии, повторени заявки

Накратко: Доброто API е договор: ресурси, методи, статуси и полета, на които клиентът разчита; ключ за идемпотентност прави повторената заявка безопасна, а версиите добавят, без да чупят.

Проблем

Платено два пъти

Клиентът плаща поръчка 1001 от мобилното приложение. Приложението праща заявка към API-то на магазина, сървърът взима 120 €, но връзката прекъсва, преди отговорът да стигне. Приложението не знае дали е платило. Затова прави това, което прави всеки разумен клиент: опитва пак.

Сървърът взима парите така:

class PaymentApi {
payments = new Payments();
handle(req) {
return this.payments.charge(orderOf(req), req.amount);
}
}

А приложението опитва до три пъти:

function pay(api, order, amount) {
const path = `/orders/${order}/payments`;
const req = { method: "POST", path, key: null, amount };
for (let attempt = 1; attempt <= 3; attempt++) {
const res = network.send(api, req);
if (res !== null) {
console.log(`опит ${attempt}: ${res}`);
return res;
}
console.log(`опит ${attempt}: отговорът се губи`);
}
return null;
}

Мрежата е записан сценарий: отговорът на първото извикване се губи. Сървърът е свършил работата, губи се само отговорът.

const api = new PaymentApi();
pay(api, 1001, 120);
console.log(api.payments.report());
Изход
опит 1: отговорът се губи
опит 2: 201 платено 1001 (pay-2)
плащания: 2, общо 240 €

Клиентът вижда „платено“ веднъж, а от картата му са излезли 240 €. Сървърът няма как да познае, че втората заявка е същото плащане: за него това е второ плащане. Изгубен отговор не е рядкост: мобилна мрежа в метрото, асансьор, таймаут на прокси.

Идеята

API-то е договор

API-то е обещание към клиентите: на какви адреси какво има, какво приема и какво връща. Клиентът е написан срещу това обещание, често от друг екип или друга фирма, и не може да се промени заедно със сървъра. Това обещание е договорът на API-то.

В HTTP API договорът се строи от няколко части:

  • ресурс е нещо с адрес: /orders/1001 е поръчка, /orders/1001/payments са плащанията ѝ. Адресът назовава нещо, не действие: /payments, не /makePayment;
  • методът казва какво се прави с ресурса: GET чете, POST създава, PUT записва цялото, PATCH променя част, DELETE трие;
  • статусът казва как е минало: 200 наред, 201 създадено, 400 грешна заявка, 404 няма такъв ресурс, 409 конфликт, 422 заявката е разбрана, но не може да се изпълни, 5xx грешката е в сървъра;
  • полетата на заявката и отговора, с имената и типовете им.

GET, PUT и DELETE са идемпотентни по правилата на HTTP: втори път дават същия резултат. Затова клиентите и проксита ги повтарят спокойно. POST не е: всеки POST създава ново нещо. Плащането е POST, затова повторението му взима пари пак.

Как изглеждат HTTP заявка и отговор, видя в урока за мрежата.

Виж го

Заявка, отговор и мрежа в паметта

Тук няма HTTP сървър. API-то е обект с метод handle: получава заявка, връща отговор. Заявката носи метод, път, ключ и сума, а отговорът носи статус и текст:

// Request е обикновен обект: { method, path, key, amount }
// /orders/1001/payments → 1001
const orderOf = (req) => Number(req.path.split("/")[2]);
class Response {
constructor(status, body) {
this.status = status;
this.body = body;
}
toString() {
return `${this.status} ${this.body}`;
}
}

Мрежата пуска заявката до API-то и по сценарий губи отговора:

// записан сценарий: на кои извиквания отговорът се губи
class Network {
constructor(...lost) {
this.lost = new Set(lost);
this.calls = 0;
}
send(api, req) {
this.calls++;
const res = api.handle(req);
// сървърът е свършил работата; губи се само отговорът
return this.lost.has(this.calls) ? null : res;
}
}

Плащанията помнят сумите:

class Payments {
amounts = [];
charge(order, amount) {
this.amounts.push(amount);
const n = this.amounts.length;
return new Response(201, `платено ${order} (pay-${n})`);
}
report() {
const sum = this.amounts.reduce((a, b) => a + b, 0);
return `плащания: ${this.amounts.length}, общо ${sum} €`;
}
}

Идеята

Ключ за идемпотентност

Сървърът трябва да различи „същото плащане, опитано пак“ от „ново плащане“. Сам не може: двете заявки изглеждат еднакво. Затова клиентът му казва. Преди първия опит клиентът прави уникален ключ за идемпотентност (обикновено UUID) и го праща с всеки опит, в заглавката Idempotency-Key.

Сървърът пази „ключ → отговор“. Ако ключът е нов, сървърът върши работата и запомня отговора. Ако ключът вече е виждан, сървърът не върши нищо и връща запазения отговор. Клиентът получава същото, което щеше да получи първия път.

Това е идеята на идемпотентния потребител от урока за събитията, обърната към клиента: там потребителят помни id-то на събитието, тук сървърът помни ключа на заявката.

Виж го

Повтореното плащане с ключ

Клиентът прави ключа веднъж, преди цикъла:

let keys = 0;
function payWithKey(api, order, amount) {
// един ключ за всички опити; в истински клиент UUID
const key = `k-${++keys}`;
const path = `/orders/${order}/payments`;
const req = { method: "POST", path, key: key, amount };
for (let attempt = 1; attempt <= 3; attempt++) {
const res = network.send(api, req);
if (res !== null) {
console.log(`опит ${attempt}: ${res}`);
return res;
}
console.log(`опит ${attempt}: отговорът се губи`);
}
return null;
}

Сървърът проверява ключа, преди да взима пари:

class IdempotentApi {
payments = new Payments();
// ключ → отговорът, който вече е даден
done = new Map();
handle(req) {
if (req.key == null)
return new Response(400, "без Idempotency-Key");
if (this.done.has(req.key)) return this.done.get(req.key);
const res = this.payments.charge(orderOf(req), req.amount);
this.done.set(req.key, res);
return res;
}
}

Същият сценарий: първият отговор се губи.

const api = new IdempotentApi();
payWithKey(api, 1001, 120);
console.log(api.payments.report());
Изход
опит 1: отговорът се губи
опит 2: 201 платено 1001 (pay-1)
плащания: 1, общо 120 €

class IdempotentApi {
payments = new Payments();
// ключ → отговорът, който вече е даден
done = new Map();
handle(req) {
if (req.key == null)
return new Response(400, "без Idempotency-Key");
if (this.done.has(req.key)) return this.done.get(req.key);
const res = this.payments.charge(orderOf(req), req.amount);
this.done.set(req.key, res);
return res;
}
}
Стъпка по стъпка, с думи
  1. Сценарий 1, без ключ: телефонът праща POST /orders/1001/payments за 120 €.
  2. Сървърът взима 120 € (pay-1) и отговаря с 201.
  3. Връзката прекъсва: отговорът се губи по пътя. Телефонът не знае дали е платил.
  4. Телефонът опитва пак със същата заявка.
  5. Сървърът не може да познае, че е същото плащане: взима още 120 € (pay-2). Общо 240 €.
  6. Сценарий 2, с ключ: клиентът прави ключа k-1 веднъж и го праща с всеки опит.
  7. Ключът е нов: сървърът взима 120 € и запомня k-1 → отговора.
  8. Отговорът пак се губи.
  9. Повторният опит носи същия ключ k-1.
  10. Сървърът намира k-1 и връща запазения отговор pay-1, без да взима пари пак.
  11. Без ключ: 2 плащания, 240 €. С ключ: 1 плащане, 120 €.

Вторият опит получава pay-1, същия отговор като първия, а парите са взети веднъж. Без ключ сървърът отказва с 400: плащане без ключ не може да се повтори безопасно, затова това API не го приема. Някои API-та (Stripe например) приемат и заявки без ключ, но тогава не обещават нищо при повторение.

Пробвай

Намери грешката

Колега пренарежда клиента и ключът попада в цикъла. API-то е същото, с ключа:

Изход
опит 1: отговорът се губи
опит 2: 201 платено 1001 (pay-2)
плащания: 2, общо 240 €

Пак две плащания. На кой ред е грешката?

Избери реда с грешката.

Идеята

Нова версия, без да чупиш стари клиенти

Мобилното приложение от миналия месец е още на хиляди телефони и няма да се обнови днес. Всяка промяна на API-то трябва да работи и с него. Може ли промяната да счупи стар клиент? Провери договора на отговора: всяко старо поле трябва да е там, със същия тип.

// договорът на отговора: име на поле и тип
function check(old, next) {
const problems = [];
for (const [name, type] of Object.entries(old)) {
if (!(name in next)) problems.push(`липсва ${name}`);
else if (next[name] !== type)
problems.push(`${name}: ${type} → ${next[name]}`);
}
if (problems.length === 0) return "✓ не чупи";
return "✗ чупи: " + problems.join(", ");
}
const v1 = { id: "string", status: "string", amount: "number" };
const added = { ...v1, currency: "string" };
const renamed = { id: "string", status: "string", total: "number" };
const retyped = { ...v1, amount: "string" };
console.log("добавено поле currency: " + check(v1, added));
console.log("amount стана total: " + check(v1, renamed));
console.log("amount стана текст: " + check(v1, retyped));
Изход
добавено поле currency: ✓ не чупи
amount стана total: ✗ чупи: липсва amount
amount стана текст: ✗ чупи: amount: number → string

Правилата, които излизат от това:

  • добавяй, не махай. Ново поле в отговора е безопасно: старият клиент не го чете. Махнато, преименувано или с друг тип поле чупи;
  • в заявката е обратното. Ново задължително поле в заявката чупи старите клиенти, защото не го пращат. Ново поле трябва да е по избор, със стойност по подразбиране;
  • клиентът да е толерантен. Клиент, който пада при непознато поле или нова стойност в списък от статуси, чупи сам себе си. Клиентът чете само каквото му трябва и пропуска останалото;
  • чупещата промяна е нова версия. /v2/orders до /v1/orders, и двете работят, докато старите клиенти се изместят. Старата версия се обявява за остаряла с дата, после се спира.

Идеята

Страниране: offset или курсор

Поръчките са хиляди, а приложението показва по 20. Най-простото страниране е offset: „прескочи първите 40, дай 20“. Курсорът е другият начин: „дай 20 след поръчка 1007“. Курсорът е последното id от предишната страница, а сървърът го връща в отговора, за да поиска клиентът следващата страница.

// поръчките, най-новите първи
const orders = [1009, 1008, 1007, 1006, 1005, 1004, 1003, 1002, 1001];
function byOffset(offset, limit) {
return orders.slice(offset, offset + limit);
}
// курсорът: последното id от предишната страница
function afterCursor(cursor, limit) {
return orders.filter((id) => id < cursor).slice(0, limit);
}
const show = (page) => `[${page.join(", ")}]`;

Клиентът чете първата страница, а между двете страници идва нова поръчка:

const page1 = byOffset(0, 3);
console.log(`стр. 1: ${show(page1)}`);
orders.unshift(1010);
console.log("идва нова поръчка 1010");
console.log(`offset, стр. 2: ${show(byOffset(3, 3))}`);
const cursor = page1.at(-1);
console.log(`cursor, стр. 2: ${show(afterCursor(cursor, 3))}`);
Изход
стр. 1: [1009, 1008, 1007]
идва нова поръчка 1010
offset, стр. 2: [1007, 1006, 1005]
cursor, стр. 2: [1006, 1005, 1004]

С offset поръчка 1007 идва два пъти: новата поръчка е избутала всичко надолу. С курсор втората страница започва точно след 1007, каквото и да е дошло отгоре.

Курсорът е бърз и в базата. OFFSET 10000 кара базата да прочете и изхвърли 10 000 реда, преди да върне 20. WHERE id < 1007 ORDER BY id DESC LIMIT 20 отива направо на мястото през индекса. Цената на курсора: не можеш да скочиш на страница 7 и броят на страниците не идва наготово.

В истинско API курсорът е непрозрачен низ (например "eyJpZCI6MTAwN30", кодирано {"id":1007}), за да не разчита клиентът на вътрешността му и сървърът да може да я смени.

Пробвай

Предвиди изхода

Сега между двете страници поръчка 1008 е изтрита.

Последният ред на изхода е:

const page1 = byOffset(0, 3);
console.log(`стр. 1: ${show(page1)}`);
orders.splice(orders.indexOf(1008), 1);
console.log("поръчка 1008 е изтрита");
console.log(`offset, стр. 2: ${show(byOffset(3, 3))}`);
Отговор

Идеята

Кога това вреди

Ключ за идемпотентност струва таблица с ключове, проверка на всяка заявка и чистене (ключовете обикновено се пазят около ден). Не го слагай:

  • на заявки, които вече са идемпотентни. GET, PUT със стойност, DELETE повторени не правят второ нещо;
  • където второто изпълнение не вреди. Двойно „маркирай като прочетено“ е същото като единично.

Курсорът е излишен, когато списъкът е малък и почти не се мени (категориите на магазина) или когато хората наистина скачат на страница 7 (таблица в админ панела). Там offset е по-прост и честен.

Версия на API-то е излишна, докато всички клиенти са в същото хранилище и се пускат заедно със сървъра: тогава ги промени заедно. Версиите са за клиенти, които не контролираш. Всяка поддържана версия е код, който трябва да тестваш и пазиш, затова не прави v2 за промяна, която може да е съвместима.

И не гони „чист REST“ на всяка цена. POST /orders/1001/cancel е по-ясно от изкуствен ресурс „отказ“. Договорът трябва да е ясен и стабилен, не красив.

Как се пуска API gateway пред услугите (TLS, ограничаване на заявките, маршрути) е тема на DevOps курса ZeroToOps.

По-точно, за любопитните

Стандартите. Заглавката Idempotency-Key е описана в чернова на IETF („The Idempotency-Key HTTP Header Field“): същият ключ с друга заявка е 422, а повторение, докато първата заявка още се изпълнява, е 409. Stripe, Adyen и PayPal ползват ключове за плащанията си. Грешките в JSON обикновено следват RFC 9457 (Problem Details): type, title, status, detail.

Договорът като файл. OpenAPI описва пътищата, полетата и статусите в YAML. От него се генерират клиенти и документация, а инструменти като oasdiff сравняват две версии и казват коя промяна чупи, точно като check от урока. Pact проверява договора от страната на клиента (consumer-driven contract tests).

По езици. Express няма нищо вградено, NestJS има @nestjs/swagger за OpenAPI. Ключовете се пазят в базата или в отделно хранилище.

Не само REST. GraphQL има един адрес и клиентът казва кои полета иска, затова добавянето на полета е естествено, а курсорите са в стандарта (Relay connections). gRPC описва договора в .proto файлове с номерирани полета: ново поле с нов номер не чупи, преизползван номер чупи.

Курсор с повторения в ключа. Ако подреждаш по дата, две поръчки могат да имат една и съща дата. Курсорът тогава е двойката (дата, id), иначе редове се губят на границата между страници.

Упражнение

Същият ключ, друго плащане

Реши задачата в редактора си.

Клиент с бъг праща ключ k-1 за плащане от 120 €, после същия ключ за плащане от 90 €. Сега сървърът връща запазения отговор за 120 €, а клиентът мисли, че е платил 90 €. Промени IdempotentApi така, че да помни и заявката: същият ключ със същата заявка връща запазения отговор, а същият ключ с друга заявка е 422. Пусни три заявки (120 €, пак 120 €, 90 €) и отчета:

Изход
201 платено 1001 (pay-1)
201 платено 1001 (pay-1)
422 ключът k-1 е за друга заявка
плащания: 1, общо 120 €

Още задачи

  • Изтичане. Пази при ключа и тик на създаване. Ключ, по-стар от 100 тика, се забравя, а заявката с него е нова.
  • Отговор на грешка. Ако плащането хвърли грешка (картата е отказана), запомни ли се отговорът? Реши и обоснови: повторение след 402 трябва ли да опита пак?
  • Курсор по дата. Направи поръчките двойки (дата, id) с две поръчки на една дата и страница от 1. Покажи, че курсор само по дата губи поръчка, а курсор (дата, id) не.

Тест

Провери се

1. Отговорът на POST плащане се губи. Какво трябва да направи клиентът, за да опита пак безопасно?
2. Кое от тези промени на отговора НЕ чупи стари клиенти?
3. Между две страници с offset идва нова поръчка най-отгоре. Какво вижда клиентът на втората страница?
4. Кога ключ за идемпотентност е излишен?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·