Розумэнне ключоў ідэмпотэнцыі у канцах POST у Node.js
Адказвае, чаму запыткі типу POST неякша паводзяцца пад праконтрольваным перапрыткам, і як ключы ідэмпотэнцыі, створаны кліентам, дапамагаюць API-ям Node.js безпечна обрабоцваць дублікатныя запыткі.
Ідэмпотентнасць — гэта термін, які можна знайсці ў дасведчэннях API для платежаў, зазвычай разам з дзеякім слоўніковым адзначэнням, якое всі працягваюць пазірнучы, не розумеючы яго насправдзе. Чырвоныя строчкі — гэта спроба паспрацаваць яго чераз запытанні, якія розробнікі насправдзе ставяць, калі сталкуюцца з ім у працоўным кодзе, а не чераз абстрактную версію, якую можна знайсці ў падручніку.
Што насправдзе значыць «ідэмпотентны», калі вы пішаце код, а не чытаете глосарый?
Аперэйція аб’яднаецца як ідэмпатная, калі ўжытак яе адна раз здаеся таму ж, каб ужытак яе пяць разоў паспалу з абсолютна тым самым вхідным дадзенням. Возьмем PUT /users/8/name з тэлам { "name": "Jane" }: незалежна ад таго, чы робім мы ўжытак яе адна раз чы пяць разоў, імя пользователя застаецца „Jane“, і нічога не накапліваецца. Пораўняймо гэта з POST /orders з пакетам дадзенняў, прызначаным для стварэння новага замовлення — якщо ўжытак яго зробіць пяць разоў, верагодна будзе п’ята разлучныя замовленні, а не адна, таму што нічога ў самай аперэйцыі не заважае яе падшароўванню.
Чаму гэта стае такой важлівай проблемай саме ў зв’язку з запитамі POST?
POST зазвычай ўжоўваецца для стварэння новых дадзеных, а сеті маюць спецыфічны спосаб працэўвання, які робіць гэта небяпечным: запит можа быць успешна адправлены на серверы, а кліент нават не дазнаецца пра гэта, таму што сам адпаведзь згубляецца ў паходзе назад. З точкі зору кліента ён бачыць толькі праханне аб паўтарэнні. У яго няма можлівасці дазнацца, чыі запит насправды быў адправлены, таму ён рабіць ежае разумнае – прабуець зноў.
// the client's perspective, roughly
async function submitOrder(payload) {
try {
return await fetch("/orders", { method: "POST", body: JSON.stringify(payload) });
} catch {
return submitOrder(payload); // did the first one actually fail, or just the response?
}
}
Якщо канцэнтр пададзення /orders не прызначаны для таго, каб вытрымаць такія паўтарэння, кліент заплачвае два разы за адну пакупку – і ніхто з учаснікаў не ёсць явна вінаваты. За тым, каму ведае кліент, запит сапраўды не удалося. А за тым, каму ведае сервер, запит сапраўды удалося.
Тады што трэба, каб канцэнтр POST у Node быў ідэмпатны?
Стандартны раёшчык — калектар стварае адзін унікальны ключ на кожную логічную операцыю, прыкладзе яго як загалоўку і дазволі серверу выкарыстаць гэты ключ, каб адзначыць праказаны зноў запит як той самы запит, які ён вялікі час уже обрабатаваў, а не як што-небудзь новае.
app.post("/orders", async (req, res) => {
const idempotencyKey = req.headers["idempotency-key"];
if (!idempotencyKey) {
return res.status(400).json({ error: "Idempotency-Key header required" });
}
const existing = await db.query(
"SELECT response_body, status_code FROM idempotency_keys WHERE key = $1",
[idempotencyKey]
);
if (existing) {
return res.status(existing.status_code).json(JSON.parse(existing.response_body));
}
const order = await createOrder(req.body);
await db.query(
"INSERT INTO idempotency_keys (key, response_body, status_code) VALUES ($1, $2, $3)",
[idempotencyKey, JSON.stringify(order), 201]
);
res.status(201).json(order);
});
Завадой калектара ёст тое, каб ён знова выкарыстоўваў той самы ключ кожны раз, калі праказае той самы логічны запит — зазвычай это UUID, створаны ўпершыне працоўна перед першай спробай. Завадой сервера ёст простыя: адзначыць ключ, які ён вялікі час вёс, і вернуць зберажаны рэзультат уместо таго, каб перадзелваць роботу.
Хто должен ствараць ключ ідэмпотентнасі — калектар чы сервер?
Цэй павінен быть кліент, і гэта дывуе багато людзей, таму што іх інстынкт кажа працоўнае. Якбы ключ ствараў сервер, кожная пракатка прыходзіла бы з нашым ключам, што зробіла бы весь механізм беспарадным — у сервера не было б падставы адразніць пракатку ад новага запиту. Ключ павінен існаваць ўжо да таго часу, калі падае першая спроба, ўсё таму, каб можна было зноў выкарыстаць тое ж значэнне, якщо спробу патрэбна будзе пракацаваць знову.
Што, як два ідэнтычныя запиты падуц буквальна ў той ж самы момент, а не адні за іншым?
Эта частка — тая, яку практычна кожны першы спроба выкарыстоўваць гэты шаблон робіць некоректна. Просты падход «пераканацца, а потым дадаць», показанный раней, мае ў сабе проблему з адночасным выкананням операцый: два запиты з аднаковым ключам можу выканаць своі SELECT, але жадны з іх не будзе мяць даных, і оба продавяжуць ствараць замовленне — чым цэлком паспелюе мету наявнасці ключа.
// safer: let the database's own uniqueness constraint catch the race
app.post("/orders", async (req, res) => {
const idempotencyKey = req.headers["idempotency-key"]; try {
await db.query("INSERT INTO idempotency_keys (key) VALUES ($1)", [idempotencyKey]);
} catch (err) {
if (err.code === "23505") { // unique constraint violation
const existing = await db.query(
"SELECT response_body, status_code FROM idempotency_keys WHERE key = $1",
[idempotencyKey]
);
return res.status(existing.status_code).json(JSON.parse(existing.response_body));
}
throw err;
}
const order = await createOrder(req.body);
await db.query(
"UPDATE idempotency_keys SET response_body = $1, status_code = $2 WHERE key = $3",
[JSON.stringify(order), 201, idempotencyKey]
);
res.status(201).json(order);
});
Застосаванне унікальнага абсалютнаўскага правіла для столбца key пераносіць прыняття рашэння з логіки вашага прыемніка запытак на саму базу дадзеных: калі сталося супаралельнае запытанне, база дадзеных вяршыць, кальве з іх будзе прийнята, а другое паказвае чыстую, можна зловіць памылку, у працоўны спосаб не пройшоў. Статэмента if у вашым прыемніку запытак проста не можа сама падрыхтуваць такі рашэння — проблемы супаралельнасці такога роду трэба рашыць на тым слое, які фактычна серыялізуе доступ, а тым слоем ёсць база дадзеных, а не умовная перагледка ў вашам коде.
Чы мае значэнне хоць што-небудзь з гэтага для запытак GET таксама?
Не так, і гэта рэгулярна прычына памылак. GET з самага пачатку задумваўся як ідэмпотентны — ён не должен нічога зменшваць, таму свабодна прабавы ўжо самыя по сабе безпечныя, не трэба ніякога спецыяльнага адмахвання. Патэрн ідэмпотентнасці-клуча існуе саме для операцый, якія ствараюць або меняюць стан, калі недбала прабава могла б удвое падвоўніць ефект. Якщо канец-пункт GET ўжо не ёсць безпечным для паўтаральных вызоў, справжняя проблема ў тым, што ён выконвае пабочныя эфекты, якіх узагалі не должен выканаваць па сэмантыцы GET.
Калі час мае застацца чынным ідэмпотентны клуч?
У ідеале настолькі, каб пакрыць рэальныя сценарыі прабав, але не настолькі дзяўна, каб збераганыя клучы накапліваліся нескончаема. Багатыя платежныя платформы выбіраюць перыяд ад 24 гадзін да калькі дзён. Планаваная задача на чыстка можа тады адмахнуць выйшлі з терміну клучы:
await db.query("DELETE FROM idempotency_keys WHERE created_at < NOW() - INTERVAL '24 hours'");
Якщо акран задаць занадта маленькім, тады прабаўка, якая адкладзена з законных прычын — напрыклад, телефон кліента перывае звязак на дзесяць хвілін пад час адправкі замовлення — можа не пацягнуцца ў цей акран і спрычыніць справжню дуплікацыю. Якшчо ж акран заставіць вялікім, табелі будуць продовжваць растаць без жадных рэальных пераваг.
Чы гэты патэрн варты ўвагі толькі для систем адплатаў?
Зазвычай самэй платежах людзі першыя вучацца гэтам уроку, большасцю таму, што дублікатны платеж ёсць тып такой проблемы, якая за гадзіну прыводзіць да розгневанага пісьма ад кліента. Але фундаментальная прычына — калі кліент не можа розразліцаваць между „мая запрос не выйшоў“ і „мой запрос выйшоў, але я нічога не паведамлены“ — прояўляецца там, дзе є парадуктальны эфект: адправка пісьма, запуск webhook-а, стварэнне новага абліку, пачатак фонавай задачы. Будзь-яя операцыя, пры якой правдападзейна праба запуску знову, і пры якой ўжо два разы запуск будзе горшы, чым яго ўжо не запускаць, ёсць хорашым кандыдатам для такога ж падходу.
Спакануючыя матэрыялы
- Дзеянне API на Node.js з слоямі: ад «тэплых» кантролераў да чыстай архітэктуры — Дазвольце вам дазнацца, як перерабіць API на Node.js у слоі кантролераў, сервісаў і адчынення да дадзэнняў, каб усунуць заплутаную бізнес-логіку, нэўнацоўныя памылкі і проблемы з масштабаваннем.
- 20 шаблонаў Node.js, якія запобегаюць перыядам апошнення роботы сервера ў працэсе вырабніцтва — Дазвольце вам пазнаць 20 практычных шаблонаў Node.js — ад обробкі памылак да граказныяго выключэння і кешавання з’язнаў — якія запобегаюць збоям, прычынай якіх становяцца неабходнасць перыядоў паўтарнага запуску.