Дизайн на 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 resdef 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 Noneclass PaymentApi: def __init__(self): self.payments = Payments()
def handle(self, req): return self.payments.charge(req.order, req.amount)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 Noneclass 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, без ключ: телефонът праща POST /orders/1001/payments за 120 €.
- Сървърът взима 120 € (pay-1) и отговаря с 201.
- Връзката прекъсва: отговорът се губи по пътя. Телефонът не знае дали е платил.
- Телефонът опитва пак със същата заявка.
- Сървърът не може да познае, че е същото плащане: взима още 120 € (pay-2). Общо 240 €.
- Сценарий 2, с ключ: клиентът прави ключа k-1 веднъж и го праща с всеки опит.
- Ключът е нов: сървърът взима 120 € и запомня k-1 → отговора.
- Отговорът пак се губи.
- Повторният опит носи същия ключ k-1.
- Сървърът намира k-1 и връща запазения отговор pay-1, без да взима пари пак.
- Без ключ: 2 плащания, 240 €. С ключ: 1 плащане, 120 €.
Вторият опит получава pay-1, същия отговор като първия, а парите са взети веднъж. Без ключ сървърът отказва с 400: плащане без ключ не може да се повтори безопасно, затова това API не го приема. Някои API-та (Stripe например) приемат и заявки без ключ, но тогава не обещават нищо при повторение.
Пробвай
Намери грешката
Колега пренарежда клиента и ключът попада в цикъла. API-то е същото, с ключа:
опит 1: отговорът се губиопит 2: 201 платено 1001 (pay-2)плащания: 2, общо 240 €Пак две плащания. На кой ред е грешката?
Избери реда с грешката.
Ключът се прави при всеки опит, затова вторият опит носи k-2. За сървъра това е нова операция и той взима пари пак. Ключът назовава плащането, не опита: прави се веднъж, преди цикъла, и се пази, докато плащането не получи отговор. Истински клиент го пази дори след рестарт на приложението.
Идеята
Нова версия, без да чупиш стари клиенти
Мобилното приложение от миналия месец е още на хиляди телефони и няма да се обнови днес. Всяка промяна на 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: ✗ чупи: липсва amountamount стана текст: ✗ чупи: 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]идва нова поръчка 1010offset, стр. 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)}")След изтриването 1007 е втора, 1006 е трета, а 1005 е четвърта. Offset 3 прескача 1009, 1007 и 1006, затова 1006 не се показва на никоя страница. Изтриване по време на четене прескача ред, вмъкване повтаря ред. Курсорът „след 1007“ щеше да върне 1006, 1005, 1004.
Идеята
Кога това вреди
Ключ за идемпотентност струва таблица с ключове, проверка на всяка заявка и чистене (ключовете обикновено се пазят около ден). Не го слагай:
- на заявки, които вече са идемпотентни.
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) не.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Списъчни изрази и генератори · dataclass, match и шаблони · Мрежово програмиране · Събития и опашки
- Продължава в: ADR: записани архитектурни решения · Когато съседът е бавен: timeout, retry, circuit breaker · Транзакция през няколко услуги (saga) · Интеграция със стари и чужди системи
- Задачи по темата: Софтуерни архитектури