একজন নতুনকে জিজ্ঞেস করুন payment endpoint-এ কঠিন case কী, সে বলবে "চার্জ ব্যর্থ হলে কী হবে?" সেটা সহজ — ব্যর্থ হয়েছে, error দেখান, সবাই বোঝে। আসল কঠিন case তার উল্টো: চার্জ সফল হয়েছে কিন্তু response ফেরার পথে হারিয়ে গেছে। client অপেক্ষা করেছে, timeout দেখেছে ও যুক্তিসঙ্গত কাজটাই করেছে — আবার চেষ্টা করেছে। এখন গ্রাহক একটা click-এর জন্য দুইবার চার্জ হয়েছেন, অথচ প্রতিটা request আলাদাভাবে সম্পূর্ণ সঠিক ছিল।
ব্যাকএন্ড reliability কাজের বিশাল অংশের নিচে এই সমস্যাটাই লুকিয়ে থাকে, আর বেশিরভাগ টিউটোরিয়াল এটা এড়িয়ে যায় কারণ happy path কখনো এটা দেখায় না। Network response হারায়। Client retry করে। আপনার write যদি এর জন্য তৈরি না হয়, তাহলে "এটা কাজ করেছে" আর "এটা দুইবার চলেছে" আলাদা করা যায় না — আর দ্বিতীয়টার দাম আসল টাকা আর আসল ক্ষমাপ্রার্থনা।
কেন Retry ঐচ্ছিক নয়
স্বাভাবিক প্রবৃত্তি হলো বলা "শুধু retry কোরো না।" কিন্তু বাস্তবতার সামনে এটা টেকে না। কাজ শেষ হওয়ার পরও request অনেকভাবে ব্যর্থ হতে পারে — connection মাঝপথে বিচ্ছিন্ন হয়, load balancer timeout করে, client-এর network এক মুহূর্তের জন্য কেঁপে যায়, মোবাইল অ্যাপ সবচেয়ে খারাপ সময়ে signal হারায়। client-এর দিক থেকে এসবই দেখতে একইরকম: "সার্ভার আমার request-ই পায়নি।" "এটা ঘটেইনি" আর "ঘটেছে কিন্তু আমি জবাব পাইনি" — এই দুইয়ের মধ্যে পার্থক্য করার কোনো উপায় client-এর নেই।
তাই প্রতিটা গুরুত্বপূর্ণ client retry করে, আর করাই উচিত। Network আপনাকে বড়জোর "at least once" নিশ্চয়তা দেয় — একটা message যেটা পৌঁছায়, সেটা একাধিকবারও পৌঁছাতে পারে। এটা দূর করার মতো কোনো বাগ নয়; এটাই সেই পরিবেশ যেখানে আপনি বানাচ্ছেন। কাজটা retry বন্ধ করা নয়। কাজটা হলো একটা পুনরাবৃত্ত request নিরাপদে জায়গামতো বসুক তা নিশ্চিত করা।
Network আপনাকে "at least once" দেয়, কখনো "exactly once" নয়। একটা সফল request client-এর কাছে ব্যর্থ মনে হতে পারে, তাই সে retry করে। আপনার write-এর কাজ হলো সেই দ্বিতীয় চেষ্টাকে কিছু না করানো।
Idempotency key: client-কে বলতে দেওয়া "এটা একই request"
পরিষ্কার সমাধান হলো client-কে প্রতিটা উদ্দিষ্ট operation-এ একটা unique id — একটা idempotency key — লাগাতে দেওয়া, আর সেই operation-এর প্রতিটা retry-তে একই key আবার ব্যবহার করা। সার্ভার যেসব key আগে process করেছে তা মনে রাখে, আর একটা key দ্বিতীয়বার দেখলে কাজ আবার না করে আগের result-ই ফিরিয়ে দেয়।
async function createCharge(req) {
const key = req.headers["idempotency-key"];
const existing = await store.get(key);
if (existing) return existing; // already did this exact operation
const result = await chargeCard(req.body);
await store.set(key, result, { ttl: 60 * 60 * 24 });
return result;
}key টা per-operation, client তৈরি করে — ব্যবহারকারী "Pay"-তে click করার সময় বানানো একটা UUID, সেই click retry হলে পুনর্ব্যবহৃত হয় আর পরের কেনাকাটার জন্য ফেলে দেওয়া হয়। প্রথম request: কোনো সংরক্ষিত key নেই, চার্জ করো, result মনে রাখো। একই key দিয়ে retry: পাওয়া গেছে, একই result ফেরত দাও, নতুন কিছু চার্জ কোরো না। ভিন্ন কেনাকাটা মানে ভিন্ন key, স্বাভাবিকভাবে এগিয়ে যায়। Stripe-এর মতো payment provider ঠিক এই কারণেই তাদের caller-দের এই একই pattern দেয়।
যে race চুপচাপ ফাঁকটা আবার খুলে দেয়
উপরের সরল ভার্সনের একটা ফাঁক আছে যা শুধু load-এর নিচেই দেখা যায় — আর duplicate load ভালোবাসে, কারণ ধীর response-ই প্রথমে retry-কে উসকে দিয়েছিল। কল্পনা করুন একই key নিয়ে দুটো request প্রায় একসঙ্গে এসেছে। দুটোই store.get(key) চালায়, দুটোই কিছু পায় না, দুটোই চার্জ করতে এগিয়ে যায়। check-then-act atomic নয়, তাই যা আপনি আটকাতে চেয়েছিলেন সেটাই check আর write-এর মাঝের ফাঁক দিয়ে গলে যায়।
এটা বন্ধ করতে হলে কাজ শুরুর আগে "এই key দাবি করা"-কে একটা atomic ধাপ বানাতে হবে। একটা conditional insert — key আগে থেকে না থাকলেই কেবল সফল হয় — দুটো race করা request-কে একটা বিজয়ী আর একটা "already in progress" শোনা request-এ পরিণত করে:
async function createCharge(req) {
const key = req.headers["idempotency-key"];
// Atomic claim: only the first request to insert this key wins.
const claimed = await store.insertIfAbsent(key, { status: "pending" });
if (!claimed) {
const existing = await store.get(key);
// Still pending? A duplicate is mid-flight — tell the client to wait.
if (existing.status === "pending")
throw new ConflictError("in progress");
return existing.result;
}
const result = await chargeCard(req.body);
await store.set(key, { status: "done", result });
return result;
}atomic insert-ই (ডেটাবেসে একটা unique constraint, Redis-এ SET NX — দুটোই একই primitive) আসলে এটাকে নিরাপদ করে। এটা ছাড়া আপনার কাছে একটা idempotency check থাকে যা বেশিরভাগ সময় কাজ করে, আর retry-র চাপে সেটা কাজ না করারই সমান।
Key কোথায় রাখবেন
key store আপনার service-এর প্রতিটা instance জুড়ে শেয়ার করা থাকতে হবে — load balancer-এর পেছনের দুটো সার্ভারকে একই key দেখতে হবে, নয়তো request আলাদা box-এ পড়লেই dedupe ব্যর্থ হয়। unique constraint সহ একটা database table বা শেয়ারড Redis দুটোই কাজ করে। per-process in-memory map কাজ করে না; পাশের instance-এর কাছে সেটা অদৃশ্য।
আরও ভালো উপায়: operation-কে স্বাভাবিকভাবেই repeatable বানানো
Idempotency key সেসব operation-এর জন্য টুল যা স্বভাবতই one-shot — card চার্জ করা, email পাঠানো। কিন্তু অনেক write এমনভাবে ডিজাইন করা যায় যে সেগুলো বারবার চালালেও কিছু আসে যায় না — তখন কোনো key-tracking যন্ত্রপাতিই দরকার নেই।
"create" আর "set"-এর পার্থক্যই সবচেয়ে স্পষ্ট উদাহরণ। নতুন row যোগ করা POST /orders বারবার চালানো বিপজ্জনক — দুইবার মানে দুইটা order। কিন্তু "এই resource-কে এই state-এ আনো" হিসেবে বলা একটা operation স্বভাবতই বারবার চালানো নিরাপদ, কারণ দুইবার চালালেও ফলাফল একবার চালানোর মতোই:
-- Run this once or five times, the row ends up identical either way.
INSERT INTO user_settings (user_id, theme)
VALUES (42, 'dark')
ON CONFLICT (user_id) DO UPDATE SET theme = 'dark';এই কারণেই PUT-কে (এই value-তে সেট করো) idempotent ধরা হয় আর POST-কে (আরেকটা তৈরি করো) নয়। যেখানে operation-এর গঠন বদলানোর স্বাধীনতা আছে — insert-এর বদলে upsert, "shipment যোগ করো"-র বদলে "status shipped করো" — সেখানে আগে স্বাভাবিকভাবে repeatable ভার্সনটা বেছে নিন। সবচেয়ে নিরাপদ retry সেটাই যেটা প্রথম থেকেই কোনো ক্ষতি করার কথাই ছিল না।
সবকিছু retry করাও ঠিক না
client-এর দিক থেকে আরেকটা বিষয়: চোখ বন্ধ করে retry করাই একটা বাগ। 500 বা timeout retry করার মতো — সার্ভারের একটা খারাপ মুহূর্ত ছিল, আবার চেষ্টা কাজ করতে পারে। 400 তা নয় — request-টাই ভুলভাবে গঠিত, একই ভাঙা request আবার পাঠানো শুধু সবার সময় নষ্ট করে, আর idempotent না হলে দ্বিগুণ হওয়ার ঝুঁকিও তৈরি করে। যেসব ব্যর্থতা সাময়িক হতে পারে সেগুলো retry করুন; যেগুলো নিজে থেকে ঠিক হবে না সেগুলো ছেড়ে দিন।
আর যখন retry করবেন, backoff আর সামান্য randomness দিয়ে চেষ্টাগুলো ছড়িয়ে দিন। কোনো service সামান্য হোঁচট খেলে আর হাজারটা client একই মুহূর্তে retry করলে, ঠিক যখন সে সামলাতে সবচেয়ে কম সক্ষম তখনই একটা synchronized ঢেউ হয়ে আছড়ে পড়ে — আপনি একটা ছোট বিচ্যুতিকে outage বানিয়ে ফেলেছেন। ক্রমবর্ধমান delay আর সামান্য jitter সেই ঢেউকে সহনীয় কিছুতে ছড়িয়ে দেয়।
যে mindset পুরো শ্রেণির বাগ ঠেকায়
যে পরিবর্তনটা এসব স্বাভাবিক করে তোলে তা হলো আপনার write ঠিক একবারই চলবে এই ধারণা ছেড়ে দেওয়া। এটা চলে না। একটা networked সিস্টেমে, গুরুত্বপূর্ণ যেকোনো write একদিন দুইবার চলবেই — একটা retry, একটা duplicate delivery, একজন অধৈর্য ব্যবহারকারীর "Submit"-এ দুইবার click। তাই প্রতিটা write endpoint-এর জন্য আগেভাগে একটাই প্রশ্ন করুন: এটা দুইবার চললে কী হবে?
সৎ উত্তর যদি হয় "দ্বিগুণ চার্জ," "duplicate order" বা "দুইটা email," তাহলে আপনার কাজ বাকি আছে — একটা idempotency key, একটা atomic claim, বা এমন কিছুতে redesign যা স্বাভাবিকভাবেই repeatable। উত্তর যদি হয় "কিছুই না, এটা একটা upsert," তাহলে আপনি আগে থেকেই নিরাপদ। প্রথম duplicate-charge টিকিট আসার আগে ship করার আগেই এই প্রশ্নটা করাই মূলত আলাদা করে দেয় একটা production-টিকে-থাকা endpoint-কে এমন একটা endpoint থেকে যা শুধু তার test পাশ করেছিল।