Idempotency key: API retry নিরাপদ করুন, দুবার চার্জ রুখুন
Network ব্যর্থ হয়, client retry করে, আর সুরক্ষা না থাকলে গ্রাহক দুবার চার্জ হন। Idempotency key এটি ঠিক করে। চালানো যায় এমন উদাহরণসহ।

একজন গ্রাহক "Pay" চাপলেন। Request আপনার server-এ পৌঁছাল, আপনি কার্ড চার্জ করলেন, কিন্তু network সাড়া হারিয়ে ফেলল। অ্যাপ timeout দেখে আবার চেষ্টা করল। সুরক্ষা না থাকলে গ্রাহক দুবার চার্জ হন। এটি বিরল নয়। Timeout, মোবাইল network ও অধৈর্য ব্যবহারকারী ডুপ্লিকেট request স্বাভাবিক করে তোলে। আদর্শ সমাধান idempotency key। কোড ও নিচের আউটপুট প্রকাশের আগে Python 3-এ চালানো হয়েছে।
Idempotent মানে কী
একটি কাজ idempotent যদি বহুবার করলে ফল একবার করার মতোই হয়। HTTP-তে GET, PUT ও DELETE idempotent হওয়ার কথা। POST, যা নতুন কিছু বানায়, তা নয়, তাই সুরক্ষা আমাদেরই যোগ করতে হয়।
ধারণা
১. Client একটি উদ্দিষ্ট কাজের জন্য অনন্য idempotency key (সাধারণত random UUID) বানায় এবং header-এ পাঠায়, যেমন Idempotency-Key: 8f1c...।
২. Server key-টি ও তার তৈরি সাড়া মনে রাখে।
৩. একই key আবার এলে server কাজ আবার করে না। সংরক্ষিত সাড়া ফেরত দেয়।
৪. Retry একই key ব্যবহার করে। সত্যিকারের নতুন payment নতুন key ব্যবহার করে।
কয়েকটি payment সেবাদাতা তাদের প্রকাশ্য API-তে এই প্যাটার্ন ব্যবহার করে, এবং একটি IETF খসড়া Idempotency-Key header বর্ণনা করে, তাই এটি পরিচিত রীতি।
একটি কাজের উদাহরণ
payments.py নামে সংরক্ষণ করুন:
import hashlib
import json
import sqlite3
def make_db():
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE payments (id INTEGER PRIMARY KEY, user TEXT, amount INTEGER);
CREATE TABLE idempotency (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
status INTEGER NOT NULL,
response TEXT NOT NULL
);
""")
return db
def fingerprint(body):
return hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()
def create_payment(db, body, idempotency_key=None):
"""Return (status, response). The same key with the same body never charges twice."""
request_hash = fingerprint(body)
if idempotency_key:
row = db.execute("SELECT request_hash, status, response FROM idempotency WHERE key = ?", (idempotency_key,)).fetchone()
if row:
if row[0] != request_hash:
return 422, {"error": "Idempotency key reused with a different request"}
return row[1], json.loads(row[2]) # replay the stored answer
try:
with db: # payment and the saved key are written in ONE transaction
cur = db.execute("INSERT INTO payments (user, amount) VALUES (?, ?)", (body["user"], body["amount"]))
response = {"payment_id": cur.lastrowid, "amount": body["amount"]}
if idempotency_key:
db.execute("INSERT INTO idempotency VALUES (?, ?, 201, ?)", (idempotency_key, request_hash, json.dumps(response)))
return 201, response
except sqlite3.IntegrityError: # two requests with the same key raced; the loser replays
row = db.execute("SELECT status, response FROM idempotency WHERE key = ?", (idempotency_key,)).fetchone()
return row[0], json.loads(row[1])
if __name__ == "__main__":
db = make_db()
body = {"user": "alice", "amount": 500}
print("1st request, key abc:", create_payment(db, body, "abc"))
print("retry, same key :", create_payment(db, body, "abc"))
print("same key, other body:", create_payment(db, {"user": "alice", "amount": 900}, "abc"))
print("no key, twice :", create_payment(db, body), create_payment(db, body))
print("payments in database:", db.execute("SELECT count(*) FROM payments").fetchone()[0])
python3 payments.py-র আউটপুট:
1st request, key abc: (201, {'payment_id': 1, 'amount': 500})
retry, same key : (201, {'payment_id': 1, 'amount': 500})
same key, other body: (422, {'error': 'Idempotency key reused with a different request'})
no key, twice : (201, {'payment_id': 2, 'amount': 500}) (201, {'payment_id': 3, 'amount': 500})
payments in database: 3
ফল পড়া
- প্রথম request payment ১ বানায়।
- একই key-সহ retry একই উত্তর ফেরত দেয়, দ্বিতীয় payment বানায় না।
- একই key, ভিন্ন body
422দিয়ে প্রত্যাখ্যাত। ভিন্ন request-এ key পুনর্ব্যবহার client-এর bug, আর পুরোনো উত্তর চুপচাপ ফেরত দিলে তা লুকিয়ে যেত। - Key ছাড়া সুরক্ষা নেই: দুটি request দুটি payment বানাল। ঝুঁকিপূর্ণ endpoint-এ key বাধ্যতামূলক করা কঠোরতর পছন্দ।
যে খুঁটিনাটি গুরুত্বপূর্ণ
একটি transaction। Payment ও সংরক্ষিত key একসঙ্গে লেখা হয়। মাঝখানে server crash করলে key ছাড়া payment (retry আবার চার্জ করত) বা payment ছাড়া key (retry এমন কাজের জন্য "হয়ে গেছে" বলত যা ঘটেইনি) থাকতে পারত। শেষ test প্রমাণ করে ব্যর্থতায় অর্ধেক-সংরক্ষিত key থাকে না।
Unique constraint-ই আসল তালা। key একটি PRIMARY KEY। দুটি অভিন্ন request ঠিক একই মুহূর্তে এলে দুটিই "এই key চিনি কি?" পরীক্ষা পার করতে পারে, কিন্তু শুধু একটি insert সফল হতে পারে। পরাজিত পক্ষ IntegrityError পায় ও বিজয়ীর সাড়া ফেরত দেয়। Unique constraint ছাড়া আগে পরীক্ষা করে পরে insert করলে race condition থাকে।
Request-এর fingerprint। Body-র hash রেখে তুলনা করি। একই key ও ভিন্ন body ভুল, replay নয়।
সাড়া সংরক্ষণ। প্রথম call যা ফেরত দিয়েছিল ঠিক তা-ই দিন, status code সহ, যাতে client ফারাক বুঝতে না পারে।
Key-র পরিধি। আসল সিস্টেমে key ব্যবহারকারী বা অ্যাকাউন্টে সীমিত করুন, যাতে একজনের key অন্যজনের সঙ্গে ধাক্কা না খায় বা তা প্রকাশ না করে।
Key-র মেয়াদ। সীমিত সময় (যেমন ২৪ ঘণ্টা বা কয়েক দিন) রাখুন ও পুরোনো row মুছুন। Retry মিনিট বা ঘণ্টায় হয়, মাসে নয়।
Test
test_payments.py নামে সংরক্ষণ করুন:
import unittest
import payments
class IdempotencyTest(unittest.TestCase):
def setUp(self):
self.db = payments.make_db()
self.body = {"user": "bob", "amount": 100}
def count(self):
return self.db.execute("SELECT count(*) FROM payments").fetchone()[0]
def test_retry_with_same_key_charges_once(self):
first = payments.create_payment(self.db, self.body, "k1")
second = payments.create_payment(self.db, self.body, "k1")
self.assertEqual(first, second)
self.assertEqual(self.count(), 1)
def test_different_keys_are_different_payments(self):
payments.create_payment(self.db, self.body, "k1")
payments.create_payment(self.db, self.body, "k2")
self.assertEqual(self.count(), 2)
def test_key_reuse_with_other_body_is_rejected(self):
payments.create_payment(self.db, self.body, "k1")
status, _ = payments.create_payment(self.db, {"user": "bob", "amount": 999}, "k1")
self.assertEqual(status, 422)
self.assertEqual(self.count(), 1)
def test_failure_leaves_no_half_saved_key(self):
with self.assertRaises(KeyError):
payments.create_payment(self.db, {"user": "bob"}, "k9") # amount missing
self.assertEqual(self.db.execute("SELECT count(*) FROM idempotency").fetchone()[0], 0)
if __name__ == "__main__":
unittest.main()
$ python3 -m unittest -v
Ran 4 tests in 0.009s
OK
চলমান request-এর কী হবে?
এই সরল সংস্করণ কাজের transaction-এই ফল লেখে, তাই দেখার মতো "চলমান" অবস্থা নেই। কাজ বাইরের সেবা (card processor, email সেবাদাতা) ডাকলে তা আপনার database transaction-এ ঢোকানো যায় না। তখন সাধারণ নকশা:
১. processing অবস্থায় key insert করুন।
২. বাইরের call করুন, সেবাদাতা idempotency key সমর্থন করলে আপনার key সঙ্গে পাঠান।
৩. ফল সংরক্ষণ করে key done চিহ্নিত করুন।
৪. processing থাকাকালীন retry এলে দ্বিতীয় call শুরু না করে 409 Conflict ফেরত দিন (বা client-কে অপেক্ষা করতে বলুন)।
এটি দেখতে যতটা, তার চেয়ে কঠিন। বাইরের call-এর পর কিন্তু সংরক্ষণের আগে crash হলে পুনরুদ্ধার যুক্তি বা মিলিয়ে দেখা লাগে। উপরের উদাহরণ তা কভার করে না।
আর যেখানে এই প্যাটার্ন কাজে লাগে
- Webhook ও queue consumer: একই বার্তা একাধিকবার পৌঁছাতে পারে, তাই প্রক্রিয়াকরণ idempotent হতে হবে। প্রক্রিয়া করা event id ট্র্যাক করুন।
- Background job, যা আবার চেষ্টা হতে পারে।
- Order, booking ও signup endpoint।
- HTTP-বহির্ভূত পদ্ধতি: স্বাভাবিক unique constraint, যেমন cart id প্রতি একটি order, ডুপ্লিকেটও ঠেকাতে পারে।
সাধারণ ভুল
- প্রতিটি retry-তে নতুন key বানানো, যা উদ্দেশ্য নষ্ট করে। একই কাজের জন্য client-কে একই key পুনর্ব্যবহার করতে হবে।
- Unique constraint ছাড়া key পরীক্ষা, যা race-এর সুযোগ দেয়।
- কাজ শেষ হওয়ার আগেই key সংরক্ষণ করে ব্যর্থতার পর পরিষ্কার না করা।
- Key অনুমানযোগ্য বা ব্যবহারকারীদের মধ্যে ভাগ করা।
- Idempotency key-কে authentication ও authorisation-এর বিকল্প ভাবা। এগুলো তা নয়।
এই উদাহরণের সীমা
এটি শেখানোর খসড়া। IntegrityError handler ধরে নেয় error key থেকে এসেছে। আসল সিস্টেমে কোন constraint ব্যর্থ হয়েছে দেখে অন্য error আলাদা সামলাতে হবে। এতে মেয়াদ-শেষের job বা ব্যবহারকারী-পরিধিও নেই, আর in-memory database বন্ধ হলে মুছে যায়।
মূল কথা: ধরে নিন প্রতিটি request দুবার আসতে পারে। তৈরি-ধরনের endpoint-এ idempotency key দিন, সাড়া key-র সঙ্গে কাজের একই transaction-এ সংরক্ষণ করুন, race জিততে unique constraint-এর ওপর নির্ভর করুন, এবং ভিন্ন body-সহ key পুনর্ব্যবহার প্রত্যাখ্যান করুন।
Backend সিরিজ, পর্ব ৩ (নির্ভরযোগ্য API): 1 নম্বর (মোট 5)।


