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

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

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

Проблем

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

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

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

class PaymentApi : IApi
{
public Payments Payments { get; } = new();
public Response Handle(Request req)
{
return Payments.Charge(req.Order, req.Amount);
}
}

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

Response? Pay(IApi api, int order, int amount)
{
string path = $"/orders/{order}/payments";
var req = new Request("POST", path, null, amount);
for (int attempt = 1; attempt <= 3; attempt++)
{
var res = network.Send(api, req);
if (res != null)
{
Console.WriteLine($"опит {attempt}: {res}");
return res;
}
Console.WriteLine($"опит {attempt}: отговорът се губи");
}
return null;
}

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

var api = new PaymentApi();
Pay(api, 1001, 120);
Console.WriteLine(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 заявка и отговор, видя в урока за мрежата. Истинско API с ASP.NET Core направи в урока за WebAPI.

Виж го

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

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

record Request(string Method, string Path, string? Key, int Amount)
{
// /orders/1001/payments → 1001
public int Order => int.Parse(Path.Split('/')[2]);
}
record Response(int Status, string Body)
{
public override string ToString() => $"{Status} {Body}";
}
interface IApi
{
Response Handle(Request req);
}

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

// записан сценарий: на кои извиквания отговорът се губи
class Network(int[] lost)
{
int calls = 0;
public Response? Send(IApi api, Request req)
{
calls++;
var res = api.Handle(req);
// сървърът е свършил работата; губи се само отговорът
return lost.Contains(calls) ? null : res;
}
}

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

class Payments
{
readonly List<int> amounts = [];
public Response Charge(int order, int amount)
{
amounts.Add(amount);
return new Response(201, $"платено {order} (pay-{amounts.Count})");
}
public string Report() =>
$"плащания: {amounts.Count}, общо {amounts.Sum()} €";
}

Идеята

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

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

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

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

Виж го

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

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

int keys = 0;
Response? PayWithKey(IApi api, int order, int amount)
{
// един ключ за всички опити; в истински клиент UUID
string key = $"k-{++keys}";
string path = $"/orders/{order}/payments";
var req = new Request("POST", path, key, amount);
for (int attempt = 1; attempt <= 3; attempt++)
{
var res = network.Send(api, req);
if (res != null)
{
Console.WriteLine($"опит {attempt}: {res}");
return res;
}
Console.WriteLine($"опит {attempt}: отговорът се губи");
}
return null;
}

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

class IdempotentApi : IApi
{
public Payments Payments { get; } = new();
// ключ → отговорът, който вече е даден
readonly Dictionary<string, Response> done = [];
public Response Handle(Request req)
{
if (req.Key is null)
return new Response(400, "без Idempotency-Key");
if (done.TryGetValue(req.Key, out var saved))
return saved;
var res = Payments.Charge(req.Order, req.Amount);
done[req.Key] = res;
return res;
}
}

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

var api = new IdempotentApi();
PayWithKey(api, 1001, 120);
Console.WriteLine(api.Payments.Report());
Изход
опит 1: отговорът се губи
опит 2: 201 платено 1001 (pay-1)
плащания: 1, общо 120 €

class IdempotentApi : IApi
{
public Payments Payments { get; } = new();
// ключ → отговорът, който вече е даден
readonly Dictionary<string, Response> done = [];
public Response Handle(Request req)
{
if (req.Key is null)
return new Response(400, "без Idempotency-Key");
if (done.TryGetValue(req.Key, out var saved))
return saved;
var res = Payments.Charge(req.Order, req.Amount);
done[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-то трябва да работи и с него. Може ли промяната да счупи стар клиент? Провери договора на отговора: всяко старо поле трябва да е там, със същия тип.

// договорът на отговора: име на поле и тип
string Check(List<Field> old, List<Field> next)
{
var problems = new List<string>();
foreach (var f in old)
{
var now = next.FirstOrDefault(n => n.Name == f.Name);
if (now is null) problems.Add($"липсва {f.Name}");
else if (now.Type != f.Type)
problems.Add($"{f.Name}: {f.Type} → {now.Type}");
}
if (problems.Count == 0) return "✓ не чупи";
return "✗ чупи: " + string.Join(", ", problems);
}
var id = new Field("id", "string");
var status = new Field("status", "string");
List<Field> v1 = [id, status, new Field("amount", "number")];
List<Field> added = [.. v1, new Field("currency", "string")];
List<Field> renamed = [id, status, new Field("total", "number")];
List<Field> retyped = [id, status, new Field("amount", "string")];
Console.WriteLine("добавено поле currency: " + Check(v1, added));
Console.WriteLine("amount стана total: " + Check(v1, renamed));
Console.WriteLine("amount стана текст: " + Check(v1, retyped));
Изход
добавено поле currency: ✓ не чупи
amount стана total: ✗ чупи: липсва amount
amount стана текст: ✗ чупи: amount: number → string

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

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

Идеята

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

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

// поръчките, най-новите първи
List<int> orders = [1009, 1008, 1007, 1006, 1005, 1004, 1003, 1002, 1001];
List<int> ByOffset(int offset, int limit) =>
orders.Skip(offset).Take(limit).ToList();
// курсорът: последното id от предишната страница
List<int> AfterCursor(int cursor, int limit) =>
orders.Where(id => id < cursor).Take(limit).ToList();
string Show(List<int> page) => "[" + string.Join(", ", page) + "]";

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

var page1 = ByOffset(0, 3);
Console.WriteLine("стр. 1: " + Show(page1));
orders.Insert(0, 1010);
Console.WriteLine("идва нова поръчка 1010");
Console.WriteLine("offset, стр. 2: " + Show(ByOffset(3, 3)));
int cursor = page1[^1];
Console.WriteLine("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 е изтрита.

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

var page1 = ByOffset(0, 3);
Console.WriteLine("стр. 1: " + Show(page1));
orders.Remove(1008);
Console.WriteLine("поръчка 1008 е изтрита");
Console.WriteLine("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).

По езици. ASP.NET Core: minimal APIs или контролери, OpenAPI е вграден, а пакетът Asp.Versioning дава /v1 и /v2 с атрибути.

Не само 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 за достъпно обучение по програмиране ·