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

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

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

Проблем

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

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

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

class PaymentApi:
def __init__(self):
self.payments = Payments()
def handle(self, req):
return self.payments.charge(req.order, req.amount)

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

def pay(api, order, amount):
path = f"/orders/{order}/payments"
req = Request("POST", path, None, amount)
for attempt in range(1, 4):
res = network.send(api, req)
if res is not None:
print(f"опит {attempt}: {res}")
return res
print(f"опит {attempt}: отговорът се губи")
return None

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

api = PaymentApi()
pay(api, 1001, 120)
print(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: получава заявка, връща отговор. Заявката носи метод, път, ключ и сума, а отговорът носи статус и текст:

@dataclass(frozen=True)
class Request:
method: str
path: str
key: str | None
amount: int
@property
def order(self):
# /orders/1001/payments → 1001
return int(self.path.split("/")[2])
@dataclass(frozen=True)
class Response:
status: int
body: str
def __str__(self):
return f"{self.status} {self.body}"

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

# записан сценарий: на кои извиквания отговорът се губи
class Network:
def __init__(self, *lost):
self.lost = set(lost)
self.calls = 0
def send(self, api, req):
self.calls += 1
res = api.handle(req)
# сървърът е свършил работата; губи се само отговорът
return None if self.calls in self.lost else res

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

class Payments:
def __init__(self):
self.amounts = []
def charge(self, order, amount):
self.amounts.append(amount)
n = len(self.amounts)
return Response(201, f"платено {order} (pay-{n})")
def report(self):
return f"плащания: {len(self.amounts)}, общо {sum(self.amounts)} €"

Идеята

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

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

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

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

Виж го

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

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

keys = count(1)
def pay_with_key(api, order, amount):
# един ключ за всички опити; в истински клиент UUID
key = f"k-{next(keys)}"
path = f"/orders/{order}/payments"
req = Request("POST", path, key, amount)
for attempt in range(1, 4):
res = network.send(api, req)
if res is not None:
print(f"опит {attempt}: {res}")
return res
print(f"опит {attempt}: отговорът се губи")
return None

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

class IdempotentApi:
def __init__(self):
self.payments = Payments()
# ключ → отговорът, който вече е даден
self.done = {}
def handle(self, req):
if req.key is None:
return Response(400, "без Idempotency-Key")
if req.key in self.done:
return self.done[req.key]
res = self.payments.charge(req.order, req.amount)
self.done[req.key] = res
return res

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

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

class IdempotentApi:
def __init__(self):
self.payments = Payments()
# ключ → отговорът, който вече е даден
self.done = {}
def handle(self, req):
if req.key is None:
return Response(400, "без Idempotency-Key")
if req.key in self.done:
return self.done[req.key]
res = self.payments.charge(req.order, req.amount)
self.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-то трябва да работи и с него. Може ли промяната да счупи стар клиент? Провери договора на отговора: всяко старо поле трябва да е там, със същия тип.

# договорът на отговора: име на поле и тип
def check(old, new):
problems = []
for name, kind in old.items():
if name not in new:
problems.append(f"липсва {name}")
elif new[name] != kind:
problems.append(f"{name}: {kind} → {new[name]}")
if not problems:
return "✓ не чупи"
return "✗ чупи: " + ", ".join(problems)
v1 = {"id": "string", "status": "string", "amount": "number"}
added = {**v1, "currency": "string"}
renamed = {"id": "string", "status": "string", "total": "number"}
retyped = {**v1, "amount": "string"}
print("добавено поле currency: " + check(v1, added))
print("amount стана total: " + check(v1, renamed))
print("amount стана текст: " + check(v1, retyped))
Изход
добавено поле currency: ✓ не чупи
amount стана total: ✗ чупи: липсва amount
amount стана текст: ✗ чупи: amount: number → string

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

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

Идеята

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

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

# поръчките, най-новите първи
orders = [1009, 1008, 1007, 1006, 1005, 1004, 1003, 1002, 1001]
def by_offset(offset, limit):
return orders[offset:offset + limit]
# курсорът: последното id от предишната страница
def after_cursor(cursor, limit):
return [i for i in orders if i < cursor][:limit]

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

page1 = by_offset(0, 3)
print(f"стр. 1: {page1}")
orders.insert(0, 1010)
print("идва нова поръчка 1010")
print(f"offset, стр. 2: {by_offset(3, 3)}")
cursor = page1[-1]
print(f"cursor, стр. 2: {after_cursor(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 е изтрита.

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

page1 = by_offset(0, 3)
print(f"стр. 1: {page1}")
orders.remove(1008)
print("поръчка 1008 е изтрита")
print(f"offset, стр. 2: {by_offset(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).

По езици. FastAPI прави OpenAPI сам от типовете на функциите; Django REST Framework има CursorPagination готов.

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