সরাসরি পর্বে যান
পর্ব ৩৩ · ৯৩-এর মধ্যে · মডিউল ৫

Status code আর error format — problem details আর idempotency

Status Codes and Error Formats — Problem Details and Idempotency

Taught in Bangla, this lesson picks status codes with three questions, covers 200, 201, 202, 204, 301 and 304, the confusing 4xx pairs and 500, 502, 503 and 504, a stable error format with RFC 9457 problem details, reproduces a duplicated order from a lost response and fixes it with an Idempotency-Key including storage, expiry and concurrent duplicates, safe retries with Retry-After and rate-limit headers, and conditional requests with ETag, 304 and 412.

এই পর্বে যা শিখবেন

  • অভ্যাসে নয়, সিদ্ধান্তের ধাপ মেনে status code বেছে নেওয়া
  • 400 আর 422, 401 আর 403, 404 আর 410-এর পার্থক্য, আর কখন 403-র বদলে 404
  • Client-এর চোখে একটি error format নকশা করা, RFC 9457-এর পাঁচটি সদস্যসহ
  • দুবার টাকা কাটা নিজে ঘটিয়ে Idempotency-Key দিয়ে ঠিক করা
  • Key-এর মেয়াদ, একসাথে আসা duplicate আর ভিন্ন body সামলানো
  • ETag দিয়ে 304 আর 412, আর সংখ্যায় লাভের হিসাব

১ · Status code একটি সিদ্ধান্ত, অভ্যাস নয়

কুরিয়ারের অফিসে পার্সেল দিলে আপনি একটি রসিদ পান। রসিদে লেখা থাকে “গৃহীত”, “ঠিকানা ভুল”, “প্রাপক নেই” বা “আজ গাড়ি যায়নি”। প্রতিটি লেখার পরের কাজ আলাদা। ঠিকানা ভুল হলে আবার পাঠিয়ে লাভ নেই, ঠিকানা বদলাতে হবে। গাড়ি না গেলে কাল আবার খোঁজ নিলেই হয়। HTTP-র status code ঠিক এই রসিদ। সে শুধু খবর দেয় না, পরের কাজও ঠিক করে দেয়।

আগের পর্বগুলোয় আপনি server-এর ভেতরটা দেখেছেন। পর্ব ২৭-এ ইনপুট যাচাই আর পর্ব ২৮-এ central error handler বানানো হয়েছে। সেই কোড আর লিখব না। এখানে প্রশ্নটি অন্য: API-র বাইরে বসে থাকা client কী দেখে, আর সেটি দেখে কী করবে? এই পর্বটি client-এর চোখে API নকশা। URL আর method-এর নকশা আছে পর্ব ৩২-এ (REST resource নকশা)।

Status code কে কে পড়ে? শুধু আপনার React কোড নয়। Browser 301 আর 304 দেখে নিজেই কাজ করে। CDN code দেখে ঠিক করে কী জমাবে। পর্ব ১৯-এর TanStack Query code দেখে retry করে। Monitoring 5xx গুনে alert বাজায়। তাই ভুল উত্তরে 200 আর body-তে {"success":false} দিলে এরা সবাই ধরে নেয় সব ঠিক আছে। মাঝের cache সেই ভুলটিই জমিয়ে সবাইকে দিতে পারে।

2xx · হয়েছে

অনুরোধ বোঝা গেছে আর কাজ হয়েছে বা নেওয়া হয়েছে।

পরের কাজ: এগিয়ে যান

3xx · অন্য জায়গায় দেখুন

উত্তরটি অন্য ঠিকানায়, অথবা আপনার কাছের কপিই ঠিক আছে।

পরের কাজ: Location বা cache

4xx · অনুরোধে সমস্যা

পাঠানো জিনিসে বা অনুমতিতে সমস্যা। না বদলে পাঠালে সাধারণত ফল একই।

পরের কাজ: বদলে পাঠান

5xx · আমাদের সমস্যা

অনুরোধ হয়তো ঠিক ছিল, server বা তার পেছনের সার্ভিস পারেনি।

পরের কাজ: পরে আবার, শর্তসাপেক্ষে

আরেকটি পরিবার আছে, 1xx. যেমন 101 দিয়ে WebSocket-এ সংযোগ বদলায় (পর্ব ৬০). সাধারণ API নকশায় এগুলো আপনাকে বেছে নিতে হয় না। আর client কোনো অচেনা code পেলে তার শ্রেণি ধরে নেয়: অচেনা 4xx মানে 400-এর মতো, অচেনা 5xx মানে 500-এর মতো। RFC 9110 এটাই বলে। তাই খুব নির্দিষ্ট code নিয়ে দুশ্চিন্তার চেয়ে সঠিক পরিবার বেছে নেওয়া বেশি জরুরি।

তিনটি প্রশ্ন

১. যা চাওয়া হয়েছিল তা কি হয়েছে? ২. না হলে সমস্যাটি কার দিকে, পাঠানো জিনিসে না আমাদের দিকে? ৩. একই জিনিস আবার পাঠালে কি ফল বদলাতে পারে? এই তিন উত্তর মিলে code ঠিক হয়। “সবাই এখানে 400 দেয়” কোনো উত্তর নয়।

তিন অঙ্কের এই code এসেছে HTTP-র প্রথম দিকের নকশা থেকে। HTTP/1.0 নথিভুক্ত হয় RFC 1945-এ (১৯৯৬), আর আজকের মূল মান RFC 9110 (জুন ২০২২), যা HTTP-র অর্থ এক জায়গায় এনেছে। লেখার সময়ে নিচের সব উদাহরণ চলেছে Express 5.3.0, Node 24.15.0, Zod 4.6.7 আর curl 8.18.0-এ। Client code-এর লেখা (reason phrase, যেমন Not Found) পড়ে সিদ্ধান্ত নেবে না; অর্থ থাকে সংখ্যায়।

২ · কাজ হয়েছে: 200, 201, 202, 204, 301 আর 304

সফল উত্তরেও সিদ্ধান্ত আছে। সব জায়গায় 200 দিলে কোড চলে, কিন্তু client কম তথ্য পায়। বাজারঘরের API-র সত্যিকারের উত্তরগুলো পাশাপাশি দেখুন।

Codeকখনসাথে যা থাকেবাজারঘরে
200 OKকাজ হয়েছে, উত্তরে তথ্য আছেBodyGET /products/1
201 Createdনতুন resource তৈরি হয়েছেLocation, সাধারণত bodyPOST /orders
202 Acceptedনেওয়া হয়েছে, কাজ এখনো শেষ হয়নি; নাও হতে পারেঅবস্থা দেখার ঠিকানাPOST /reports
204 No Contentকাজ হয়েছে, পাঠানোর কিছু নেইBody থাকে নাDELETE /cart/items/5
301 Moved Permanentlyঠিকানা চিরতরে বদলেছেLocation/old-products/1
304 Not Modifiedআপনার কপিই ঠিক আছে (শুধু GET, HEAD)Body থাকে নাধারা ৯ দেখুন

নিচের সব পেস্ট করা উত্তরে Date, Content-Length ধরনের লাইন আর লম্বা id ছোট করা হয়েছে, বাকিটা সত্যিকারের run. Location আর Retry-After লক্ষ করুন।

$ curl -si -X POST -H 'content-type: application/json' -H 'Authorization: Bearer token-rafi' \
    -d '{"sku":"RICE-5KG","qty":1}' http://127.0.0.1:47333/v1/orders
HTTP/1.1 201 Created
Location: /orders/1
Content-Type: application/json; charset=utf-8

{"id":1,"total":700}

$ curl -si -X POST -H 'Authorization: Bearer token-rafi' http://127.0.0.1:47333/reports
HTTP/1.1 202 Accepted
Location: /jobs/71
Retry-After: 5

{"jobId":71,"state":"queued"}

$ curl -si -X DELETE -H 'Authorization: Bearer token-rafi' http://127.0.0.1:47333/cart/items/5
HTTP/1.1 204 No Content

$ curl -si -X DELETE -H 'Authorization: Bearer token-rafi' http://127.0.0.1:47333/cart/items/5
HTTP/1.1 404 Not Found

$ curl -si http://127.0.0.1:47333/old-products/1
HTTP/1.1 301 Moved Permanently
Location: /products/1

দুটি জিনিস ভাবার মতো। প্রথমত, দুবার DELETE করলে প্রথমে 204, পরে 404 এলো। তবু DELETE idempotent, কারণ idempotent কথাটি server-এর অবস্থা নিয়ে, উত্তর নিয়ে নয়। দুবার পরেও পণ্যটি কার্টে নেই। দ্বিতীয়ত, 202 একটি সৎ উত্তর: “নিয়েছি, হবে কি না এখনো জানি না”। লম্বা report বা ইমেইলের মতো কাজে দরকার, যার পুরো রূপ পর্ব ৬২-তে।

301 আর 302-তে browser-সহ বেশিরভাগ client ঐতিহাসিক কারণে আজও POST-কে GET বানিয়ে ফেলে। Method অক্ষত রাখতে হলে 308 (চিরস্থায়ী) আর 307 (সাময়িক) আছে। 301 browser দীর্ঘদিন মনে রাখতে পারে, তাই ভুল 301 ফেরানো কঠিন। পরীক্ষার সময়ে 302 বা 307 দিয়ে শুরু করুন।

৩ · ভুল অনুরোধ: 4xx-এর জোড়াগুলো

4xx-এর সব কষ্ট কয়েকটি জোড়ায়। একবার ধরতে পারলে ইন্টারভিউয়ের অর্ধেক প্রশ্ন শেষ।

400 আর 422: পড়া গেল কি না

দর্জিকে একটি কাগজ দিলেন। হাতের লেখা পড়াই গেল না, এটি 400. পড়া গেল, কিন্তু লেখা “হাতা ৯৯ ইঞ্চি”, এটি 422. এই কোর্সে 400 মানে অনুরোধটি পড়া বা বোঝাই যায়নি, 422 মানে পড়া গেছে কিন্তু নিয়ম ভেঙেছে। পর্ব ২৭-এ এই ভাগ দেখেছেন। মনে রাখবেন, অনেক API সব ইনপুটের ভুলে 400 দেয়, আর সেটিও চলে। ধারাবাহিকতাই আসল। Idempotency-Key না পাঠালে আমরা 400 দিচ্ছি, কারণ IETF-এর খসড়াও তাই বলে।

401 আর 403: কে আপনি, আর আপনার কি অনুমতি আছে

অফিসের গেটে পরিচয়পত্র নেই, এটি 401. পরিচয়পত্র আছে, কিন্তু সার্ভার-রুমে ঢোকার অনুমতি নেই, এটি 403. প্রথমটি authentication, দ্বিতীয়টি authorization. 401-এর নাম “Unauthorized” হলেও অর্থ “পরিচয় পাইনি”, এই বিভ্রান্তি পুরোনো। RFC 9110 বলে 401-এ WWW-Authenticate header পাঠাতেই হবে। Client-এর আচরণও আলাদা: 401-এ login পাতায় নেওয়া বা token নবায়ন, 403-এ “অনুমতি নেই” দেখানো। 403-তে login পাতায় পাঠালে ব্যবহারকারী চক্রে পড়েন।

$ curl -si http://127.0.0.1:47333/orders/1
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="bazarghar"
Content-Type: application/problem+json; charset=utf-8

{"type":"https://bazarghar.example/problems/not-authenticated","title":"লগইন করা নেই","status":401,…}

404 আর 410, আর অস্তিত্ব লুকানো

404 মানে “এখানে এখন কিছু পাচ্ছি না”; পরে আসতেও পারে। 410 Gone মানে “ছিল, চিরতরে গেছে”, যেমন বন্ধ হওয়া কুপন EID2025. বেশিরভাগ API-তে 404-ই যথেষ্ট; 410 দিন যখন client-কে সত্যিই বলতে চান আর খোঁজ করবেন না।

আসল সূক্ষ্মতা অন্য জায়গায়। মিনা রাফির order চাইলে কী বলবেন? 403 দিলে মিনা জেনে গেলেন এই id-র order আছে। /orders/1, /orders/2 করে ঘুরলে কোনটি আছে আর দিনে কত order হয়, দুটিই বেরিয়ে আসে। তাই অন্যের জিনিস আর না-থাকা জিনিস, দুটিতেই হুবহু একই 404. RFC 9110-ও এই ব্যবহার মেনে নেয়। সত্যিকারের run:

# মিনা রাফির order চাইলেন
HTTP/1.1 404 Not Found
{"type":"…/order-not-found","title":"এই অর্ডারটি পাওয়া যায়নি","status":404,"instance":"urn:uuid:c12f88b2-…","code":"ORDER_NOT_FOUND",…}

# মিনা এমন order চাইলেন, যা নেই
HTTP/1.1 404 Not Found
{"type":"…/order-not-found","title":"এই অর্ডারটি পাওয়া যায়নি","status":404,"instance":"urn:uuid:c53b12a1-…","code":"ORDER_NOT_FOUND",…}

পার্থক্য শুধু এলোমেলো traceId-তে। কোডে এটি এক লাইনের কাজ: WHERE id = ? AND user_id = ?. তাই “আছে কি না” আর “কার” আলাদা করে দেখার সুযোগই থাকে না। 403 কখন? যখন জিনিসটির অস্তিত্ব গোপন নয়, যেমন একজন ক্রেতা পণ্যের দাম বদলাতে চাইলে; পণ্য তো সবার সামনেই। এই ঝুঁকির তত্ত্ব আছে broken access control পর্বে, আর কোড পর্ব ৪৮-এ।

বাকিগুলো এক টেবিলে

CodeকখনClient-এর কাজ
405 Method Not Allowedঠিকানা আছে, এই method নেই। Allow header বাধ্যতামূলকMethod বদলান
409 Conflictবর্তমান অবস্থার সাথে সংঘাত: মজুত নেই, SKU আছেঅবস্থা দেখে সমাধান করুন
412 Precondition Failedআপনার পাঠানো শর্ত মেলেনি, যেমন If-Matchনতুন করে পড়ুন
413 Content Too LargeBody সীমার চেয়ে বড়ছোট করুন
415 Unsupported Media TypeBody-র ধরন নেওয়া হয় নাContent-Type ঠিক করুন
429 Too Many RequestsRate limit পেরিয়েছে (RFC 6585, ২০১২)Retry-After মেনে অপেক্ষা

এখানে Express-এর একটি ফাঁদ আছে। Express নিজে 405 দেয় না। অসমর্থিত method-এ সে ধরে নেয় route মেলেনি, তাই 404. সত্যিকারের run:

$ curl -si -X DELETE http://127.0.0.1:47333/plain      # শুধু app.get('/plain') আছে
HTTP/1.1 404 Not Found

$ curl -si -X DELETE http://127.0.0.1:47333/products/1 # .all() বসানো আছে
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD, PATCH

দ্বিতীয় ফলটি পেতে একই route-এর শেষে একটি .all() বসাতে হয়:

const methodNotAllowed = (allow) => (req, res) => {
  res.set('Allow', allow);
  throw new Problem(405, 'METHOD_NOT_ALLOWED');
};

app.route('/products/:id')
  .get(readProduct)
  .patch(auth, onlyAdmin, needJson, json, updateProduct)
  .all(methodNotAllowed('GET, HEAD, PATCH'));

এখন সিদ্ধান্তের ক্রমটি দেখুন। Code বাছাই আসলে ওপর থেকে নিচে একটি মই, আর প্রথম যে ধাপে আটকায় সেখানেই উত্তর। ক্রম বদলালে উত্তরও বদলায়, আর তথ্যও ফাঁস হতে পারে। যেমন অনুমতির আগে যাচাই করলে অননুমোদিত মানুষও আপনার schema জেনে যান। নিচের আটটি অনুরোধের ফল সত্যিকারের run থেকে নেওয়া।

1method ok?   else 4052who are you? else 4013allowed?     else 4034readable?    else 4005rules ok?    else 4226condition?   else 4127state ok?    else 4098all passed:  201
এই অনুরোধ
অনুরোধ
কে
থামল
  1. Method নেই। /v2/orders ঠিকানা আছে, কিন্তু সেখানে শুধু POST চলে। উত্তর 405, সাথে Allow: POST.
  2. পরিচয় নেই। কোনো token নেই, তাই 401. বাকি কিছু দেখার দরকারই নেই।
  3. পরিচয় আছে, অনুমতি নেই। ক্রেতা রাফি দাম বদলাতে চাইলেন, তাই 403. Body যাচাই হলো না, তাই অনুমতিহীন মানুষ schema-র কিছুই জানলেন না।
  4. পড়াই গেল না। {bad কোনো JSON নয়। 400, অর্থাৎ নিয়ম দেখার আগেই থামা।
  5. পড়া গেল, নিয়ম ভাঙল। JSON ঠিক, কিন্তু qty 99, সীমা ১০. উত্তর 422, সাথে ঘরভিত্তিক ভুল।
  6. শর্ত মেলেনি। ইনপুট ঠিক, কিন্তু If-Match: "v1" পুরোনো, দাম আগেই বদলে গেছে। উত্তর 412, ধারা ৯-এ বিস্তারিত।
  7. অবস্থা অনুমতি দিচ্ছে না। ইনপুট নিখুঁত, কিন্তু চা আছে ২টি, চাওয়া হয়েছে ৫টি। উত্তর 409, সাথে available: 2.
  8. সব পার। Order তৈরি হলো। উত্তর 201, আর Location-এ নতুন order-এর ঠিকানা।

মইয়ের ওপরের দিকে 429 বা 413-ও বসে, কারণ এগুলো খরচের কাজ শুরু হওয়ার আগেই থামানো ভালো। ঠিক কোথায় বসবে, তা নির্ভর করে middleware-এর ক্রমের ওপর (পর্ব ২৬).

৪ · আমাদের সমস্যা: 500, 502, 503 আর 504

রেস্তোরাঁয় চারটি আলাদা দৃশ্য ভাবুন। রান্নাঘরে হাঁড়ি উল্টে গেল, এটি 500. ওয়েটার রান্নাঘর থেকে এমন কিছু আনলেন যা খাবারই নয়, এটি 502. আজ ভিড়ে বা মেরামতে দোকান সাময়িক বন্ধ, এটি 503. রান্নাঘর সময়মতো কিছুই দিল না, এটি 504. চারটিতেই খদ্দের খাবার পাননি, কিন্তু পরের কাজ আলাদা।

Codeঅর্থসাধারণ কারণআবার চেষ্টা?
500অপ্রত্যাশিত ভুলBug, ধরা না-পড়া exceptionসাধারণত লাভ নেই
502পেছনের সার্ভিস থেকে অকার্যকর উত্তরProxy-র পেছনে app পড়ে গেছে, ভাঙা উত্তরসাময়িক হতে পারে
503এখন পারছি নাঅতিরিক্ত চাপ, maintenance, deployহ্যাঁ, Retry-After মেনে
504পেছনের সার্ভিস সময়মতো সাড়া দেয়নিধীর reverse proxy পথ, ধীর gatewayহ্যাঁ, কিন্তু ফল অজানা

Lab-এ একটি ছোট নকল পেমেন্ট সার্ভিস চালিয়ে 502 আর 504 ঘটানো হলো। সে একবার ভাঙা HTML দিল, আরেকবার দেরি করল।

$ curl -si 'http://127.0.0.1:47333/boom'
HTTP/1.1 500 Internal Server Error
{"type":"…/internal","title":"আমাদের দিকে একটি সমস্যা হয়েছে","status":500,…,"code":"INTERNAL","traceId":"eab57c2c-…"}

$ curl -si 'http://127.0.0.1:47333/pay/check?mode=garbage'
HTTP/1.1 502 Bad Gateway
{"type":"…/upstream-bad-response","title":"পেমেন্ট সার্ভিসের উত্তর বোঝা গেল না","status":502,…}

$ curl -si 'http://127.0.0.1:47333/pay/check?mode=slow'
HTTP/1.1 504 Gateway Timeout
Retry-After: 5
{"type":"…/upstream-timeout","title":"পেমেন্ট সার্ভিস সময়মতো সাড়া দেয়নি","status":504,…}

সূক্ষ্ম পার্থক্য: 502 আর 504 আপনি সবসময় নিজে লেখেন না। বেশিরভাগ সময় এগুলো আপনার app-এর সামনে বসা proxy বা load balancer পাঠায়, আর তখন উত্তরটি আপনার JSON নয়, proxy-র HTML পাতা। তাই client-এর কোড ধরে নেবে না যে 5xx-এর body সবসময় JSON. প্রথমে Content-Type দেখবে। Downstream সংযোগ না পেলে কেউ 502, কেউ 503 দেন, এখানে দুই মতই চালু; পর্ব ২৮-এ আমরা 503 নিয়েছি।

504 মানে “কাজটি হয়নি” নয়। 504 মানে “আমরা জানি না”। POST-এ এই পার্থক্যটিই পরের তিনটি ধারার মূল কথা।

৫ · Error format একটি চুক্তি: RFC 9457

Status code মোটা দাগের খবর। 409 বললে client জানে সংঘাত হয়েছে, কিন্তু কোন সংঘাত? মজুত শেষ, না SKU আগেই আছে? Frontend দুই ক্ষেত্রে দুটি আলাদা পর্দা দেখাবে। তাই body-তে আরও তথ্য লাগে, আর সেই আকার একবার প্রকাশ হলে সেটি চুক্তি। বদলালে অন্যের কোড ভাঙে। পর্ব ৩৫-এ এই চুক্তি OpenAPI-তে লেখা হবে।

একটি ভালো error-এর চারটি অংশ, আর প্রতিটির পাঠক আলাদা:

অংশকে পড়েনিয়ম
স্থির codeClient-এর কোডকখনো বদলায় না, অনুবাদ হয় না। OUT_OF_STOCK
মানুষের বার্তাব্যবহারকারীবাংলা বা ইংরেজি। কোড কখনো এই লেখা মিলিয়ে সিদ্ধান্ত নেবে না
ঘরভিত্তিক ভুলFormpath আর code, যাতে ঠিক ঘরের নিচে বার্তা বসে
Trace idসহায়তা দল, ডেভেলপারLog-এর সাথে জোড়ে। পর্ব ৩১-এর request id এটিই

মানটি পুরোটা

প্রতিটি দলের নিজের আকার বানানোর বদলে IETF একটি সাধারণ আকার দিয়েছে। RFC 7807 (২০১৬) এর শুরু, আর জুলাই ২০২৩-এর RFC 9457 সেটিকে প্রতিস্থাপন করেছে। Media type application/problem+json. পাঁচটি মানক সদস্য, সবগুলোই ঐচ্ছিক:

সদস্যমানেখেয়াল রাখুন
typeসমস্যার ধরনের URI, মূল পরিচয়না থাকলে ধরা হয় about:blank. Client এটি স্বয়ংক্রিয়ভাবে খুলবে না
titleধরনের ছোট সারাংশএকই ধরনে একই থাকে, শুধু অনুবাদে বদলায়
statusHTTP code, সংখ্যা হিসেবেশুধু পরামর্শমূলক, আসল code-এর সমান রাখতে হবে
detailএই ঘটনার ব্যাখ্যাClient-কে সমাধানে সাহায্য করবে; এটি parse করা যাবে না
instanceএই ঘটনার URIখোলা যায় এমন হতে পারে, অথবা অস্বচ্ছ পরিচয়

এর বাইরে নিজের সদস্য যোগ করা যায়, আর client অচেনা সদস্য উপেক্ষা করবে। এটাই একে বাড়তে দেয়। type মূল পরিচয় হলেও বাজারঘরে আমরা সরল code-ও রাখছি, কারণ পর্ব ২৮-এর frontend সেটি পড়ে। পর্ব ২৮-এ instance-এ path রাখা হয়েছিল; সেটিও অনুমোদিত, তবে একই path-এ অনেক ঘটনা ঘটে, তাই এখানে urn:uuid: নিয়েছি। handler-এর কোড পর্ব ২৮-এ আছে, শুধু আকার বদলেছে। সত্যিকারের 422 উত্তর:

$ curl -si -X POST -H 'content-type: application/json' -H 'Authorization: Bearer token-rafi' \
    -d '{"sku":"x","qty":99}' http://127.0.0.1:47333/v1/orders
HTTP/1.1 422 Unprocessable Entity
Content-Language: bn
Vary: Accept-Language
Content-Type: application/problem+json; charset=utf-8

{
  "type": "https://bazarghar.example/problems/validation-failed",
  "title": "কিছু ঘরের তথ্য ঠিক নেই",
  "status": 422,
  "instance": "urn:uuid:05160637-2cd0-4bde-8068-f8efa5bd4f9a",
  "code": "VALIDATION_FAILED",
  "traceId": "05160637-2cd0-4bde-8068-f8efa5bd4f9a",
  "errors": [
    { "path": "sku", "code": "too_small", "message": "SKU কমপক্ষে ৩ অক্ষরের" },
    { "path": "qty", "code": "too_big", "message": "সর্বোচ্চ ১০টি" }
  ]
}

JSON-টি পড়ার সুবিধার জন্য সাজানো। ভাষা ঠিক হয় content negotiation দিয়ে: Accept-Language: en পাঠালে একই run-এ এলো "title":"Some fields are invalid" আর Content-Language: en. কিন্তু ওই run-এর errors-এর বার্তা বাংলাই রইল, কারণ Zod schema-য় বাংলা লিখে রাখা হয়েছিল। এটি আমাদের lab-এর একটি সত্যিকারের ত্রুটি, আর শিক্ষাও। বহুভাষী client-এর জন্য errors[].code ধরে অনুবাদ করা নিরাপদ, বার্তা দেখে নয়। বাংলা সাইটের i18n আছে পর্ব ৫৯-এ।

Client-এর দিকে কোডটি তাহলে সরল। লক্ষ করুন, title মিলিয়ে কোনো সিদ্ধান্ত নেই:

function showProblem(p, form) {
  switch (p.code) {
    case 'VALIDATION_FAILED':
      p.errors.forEach((e) => form.setError(e.path, { message: e.message }));
      break;
    case 'OUT_OF_STOCK':
      toast(`মজুত আছে ${p.available}টি`);
      break;
    default:
      toast(`${p.title} (কোড: ${p.traceId.slice(0, 8)})`);
  }
}

একাধিক ভিন্ন ধরনের ভুল একসাথে হলে RFC 9457-এর পরামর্শ, সবচেয়ে জরুরিটি জানান। একই ধরনের অনেক ভুল হলে একটি extension-এ তালিকা দিন, যেমন উপরের errors. আর একটি সতর্কতা: খুব সাধারণ, সবার ব্যবহারের ধরন ছাড়া type IANA registry-তে যায় না। নিজের URI নিজের domain-এই রাখুন। Content-Type-এ charset=utf-8 জুড়েছে Express নিজে; JSON-এ এটি দরকার নেই, তবে ক্ষতিও নেই।

৬ · দুবার ক্লিক, দুবার টাকা: সমস্যাটি নিজে ঘটানো

আপনি মোবাইলে টাকা পাঠালেন। ঘুরতে ঘুরতে পর্দায় লেখা এলো “সংযোগ বিচ্ছিন্ন”। টাকা কি গেল? আবার পাঠাবেন? এই অস্বস্তি প্রত্যেকের চেনা। HTTP-তে ঠিক এটাই ঘটে। উত্তর না পাওয়া মানে কাজ না হওয়া নয়। অনুরোধটি হয়তো পৌঁছেছে, কাজ শেষ হয়েছে, শুধু উত্তর ফেরার পথে হারিয়েছে।

একটি হারানো উত্তর, দুটি order

A lost response turns a safe-looking retry into a double charge

একটি হারানো উত্তর থেকে দুবার টাকা কাটা ক্রেতা API Gateway POST /orders ৳৭০০ কাটার অনুরোধ ✕ 201 হারাল timeout: আবার ক্লিক একই POST আবার ৳৭০০ 201, order ২ ফল: ২টি order, ৳১,৪০০ কাটা
ছবি ৩৩.১ — ক্রেতা শেষ উত্তরে সব ঠিক দেখলেন, অথচ টাকা গেছে দুবার।

এটি কাল্পনিক নয়। বাজারঘরের POST /v1/orders-এ gateway-র কাজ লাগে ৪০০ ms. curl-কে ২০০ ms পরে থেমে যেতে বলা হলো, যেন মোবাইল নেটওয়ার্ক কেটে গেছে। সত্যিকারের run:

$ curl -sS --max-time 0.2 -X POST … -d '{"sku":"RICE-5KG","qty":1}' $API/v1/orders
curl: (28) Operation timed out after 212 milliseconds with 0 bytes received

$ curl -si -X POST … -d '{"sku":"RICE-5KG","qty":1}' $API/v1/orders    # ক্রেতা আবার চাপলেন
HTTP/1.1 201 Created
Location: /orders/2

{"id":2,"total":700}

$ curl -s $API/debug/ledger
{"orders":2,"charges":2,"charged":1400}

Client চলে গেলেও Node-এর handler থামেনি, কাজ শেষ করেছে। তাই order ২টি, আর কাটা গেছে ৳১,৪০০. $API এখানে http://127.0.0.1:47333, আর /debug/ledger শুধু lab-এর হিসাব দেখার জন্য।

ক্ষতির আকার হিসাব করুন। ধরুন দিনে ২,০০০ চেকআউট, ০.২% ক্ষেত্রে উত্তর হারায় আর ক্রেতা আবার চাপেন, গড় order ৳৭০০ (সব সংখ্যা ধরে নেওয়া)। তাহলে 2000 × 0.002 = ৪টি duplicate, মানে দিনে ৳২,৮০০ ভুল কাটা, আর ৪ জন রাগী ক্রেতা। শতাংশ ছোট, কিন্তু প্রতিটি ঘটনা সরাসরি টাকা।

কেন এমন হলো? কারণ POST idempotent নয়, দুবার পাঠালে দুবার কাজ। PUT আর DELETE নিজে থেকেই idempotent, তাই সেগুলো আবার পাঠানো যায়। অনেকে ভাবেন, একবার ক্লিকের পরে বোতাম বন্ধ করলেই সমস্যা শেষ। তা হয় না। disabled বোতাম শুধু একই পাতার দ্বিতীয় ক্লিক থামায়। পাতা reload, দুই tab, মোবাইলের back, আর library-র নিজের retry এতে থামে না। এখন ছোট মডেলে সমাধানের মূল ধারণা চালিয়ে দেখুন।

const http = require('node:http');
const orders = [];
const saved = new Map();   // key -> আগের উত্তর
const server = http.createServer((req, res) => {
  const key = req.headers['idempotency-key'];
  req.resume();
  req.on('end', () => {
    if (key && saved.has(key)) {
      res.writeHead(201, { 'Idempotency-Replayed': 'true' });
      return res.end(saved.get(key));
    }
    orders.push({ sku: 'RICE-5KG' });
    const body = JSON.stringify({ orderId: orders.length });
    if (key) saved.set(key, body);
    res.writeHead(201).end(body);
  });
});
async function post(url, headers = {}) {
  const r = await fetch(url, { method: 'POST', headers, body: '{"sku":"RICE-5KG"}' });
  const replay = r.headers.get('idempotency-replayed') ?? 'no';
  console.log(' ', r.status, await r.text(), 'replayed:', replay);
}
server.listen(0, '127.0.0.1', async () => {
  const url = `http://127.0.0.1:${server.address().port}/orders`;
  console.log('double click, no key');
  await post(url); await post(url);
  console.log('  orders saved:', orders.length);
  orders.length = 0;
  console.log('double click, same key');
  const headers = { 'Idempotency-Key': 'intent-7f3c' };
  await post(url, headers); await post(url, headers);
  console.log('  orders saved:', orders.length);
  server.closeAllConnections();
  server.close();
});

আউটপুট:

double click, no key
  201 {"orderId":1} replayed: no
  201 {"orderId":2} replayed: no
  orders saved: 2
double click, same key
  201 {"orderId":1} replayed: no
  201 {"orderId":1} replayed: true
  orders saved: 1

Key ছাড়া ২টি order. Key থাকলে দ্বিতীয় অনুরোধে কাজ হয়নি; server জমানো উত্তরটি হুবহু ফিরিয়েছে, তাই দুই উত্তরেই orderId 1. Client-এর চোখে দুটিই সফল, আর সেটিই চাই। Idempotency-Replayed আমাদের নিজের header, মানে নয়; এটি শুধু log আর debug-এর সুবিধা।

৭ · Idempotency key: জমানো, মেয়াদ আর একসাথে আসা duplicate

ধারণাটি সরল। Client প্রতিটি অভিপ্রায়ের জন্য একটি অনন্য key বানায়, যেমন “এই কার্টের এই চেকআউট”। সেই অভিপ্রায়ের সব retry একই key নিয়ে যায়। চায়ের দোকানের হিসাব খাতার মতো: “রসিদ ৪১৭ আগেই লেখা হয়েছে” দেখলে দোকানদার দ্বিতীয়বার লেখেন না, পুরোনো হিসাবটাই দেখান।

মানের অবস্থা সাবধানে পড়ুন। Idempotency-Key এখনো RFC নয়। IETF-এর httpapi দলের draft-ietf-httpapi-idempotency-key-header লেখার সময়ে সংস্করণ ০৮-এ (২২ অক্টোবর ২০২৬ তারিখের), একটি সক্রিয় Internet-Draft. খসড়া বদলাতে পারে, আর নিচের code-গুলো তার বর্তমান পরামর্শ। Header-টি অবশ্য বহু পেমেন্ট API আগে থেকেই ব্যবহার করে আসছে, খসড়াতেও তার তালিকা আছে। যে API ব্যবহার করবেন, তার নিজের ডকুমেন্ট আগে পড়ুন।

খসড়া অনুযায়ী key একটি string, আর UUID বা সমান এলোমেলো মান ব্যবহারের সুপারিশ আছে। নিচের ছয়টি প্রশ্নের উত্তর ঠিক করাই পুরো নকশা:

প্রশ্নবাজারঘরের উত্তরLab-এর ফল
Key নেই?400 (খসড়ার পরামর্শ)IDEMPOTENCY_KEY_MISSING
Key কার?প্রতি ব্যবহারকারীর আলাদা: (user_id, key)মিনার একই key-তে আলাদা order
একই key, ভিন্ন body?422 (খসড়ার পরামর্শ)IDEMPOTENCY_KEY_REUSED
আগেরটি এখনো চলছে?409 আর Retry-AfterREQUEST_IN_PROGRESS
আগেরটি শেষ?জমানো status আর body201, একই id
আগেরটি 5xx-এ ব্যর্থ?Key ছেড়ে দেওয়া, নিচে কারণRetry-তে সফল, charge ১টি

Key-কে ব্যবহারকারীর সাথে বাঁধা নিরাপত্তারও প্রশ্ন। খসড়াও সতর্ক করে: দুর্বল key অনুমান করা গেলে অন্যের জমানো উত্তর পড়া যেতে পারে। তাই format যাচাই, আর lookup-এ client-এর দেওয়া key-র সাথে নিজের জানা পরিচয় জোড়া। Fingerprint হলো body-র SHA-256 hash, যা দিয়ে “একই key, ভিন্ন body” ধরা পড়ে। পুরো body জমানোর দরকার হয় না।

মূল অংশটি হলো atomic claim. কোডের মধ্যে SELECT করে দেখে তারপর INSERT নয়, একটিমাত্র SQL, যা unique constraint-এর ওপর ভরসা করে:

function claimKey(userId, key, fp) {
  db.prepare('DELETE FROM idempotency_keys WHERE expires_at < ?').run(Date.now());
  const r = db.prepare(`INSERT INTO idempotency_keys
      (user_id, key, fingerprint, state, expires_at)
    VALUES (?, ?, ?, 'processing', ?)
    ON CONFLICT (user_id, key) DO NOTHING`)
    .run(userId, key, fp, Date.now() + KEY_TTL_MS);
  return r.changes === 1;           // আমি জিতলাম?
}

// handler-এর ভেতরে
if (!claimKey(req.user.id, key, fp)) {
  const row = readKey(req.user.id, key);
  if (row.fingerprint !== fp) throw new Problem(422, 'IDEMPOTENCY_KEY_REUSED');
  if (row.state === 'processing') {
    res.set('Retry-After', '1');
    throw new Problem(409, 'REQUEST_IN_PROGRESS');
  }
  res.set('Idempotency-Replayed', 'true').location(row.location);
  return res.status(row.status).type('application/json').send(row.body);
}

await charge(`${req.user.id}:${key}`, total);   // একই key নিচেও যায়
db.exec('BEGIN');
// order লেখা আর জমানো উত্তর লেখা একই transaction-এ
db.exec('COMMIT');

BEGIN আর COMMIT অবহেলার নয়। Order লেখা হলো কিন্তু key “done” হলো না, এই অবস্থা transaction ছাড়া সম্ভব, আর তখন retry-তে দ্বিতীয় order. Transaction-এর পুরো গল্প পর্ব ৪৩-এ। এখন একই POST-এর সত্যিকারের run. আগের মতোই প্রথমটি মাঝপথে কাটা গেল:

$ K=7f3c1a52-9d4e-4b6a-8c21-0e5d3f6a7b10
$ curl -sS --max-time 0.2 -X POST … -H "Idempotency-Key: $K" -d '{"sku":"RICE-5KG","qty":1}' $API/v2/orders
curl: (28) Operation timed out after 208 milliseconds with 0 bytes received

$ curl -si -X POST … -H "Idempotency-Key: $K" -d '{"sku":"RICE-5KG","qty":1}' $API/v2/orders   # সাথে সাথে
HTTP/1.1 409 Conflict
Retry-After: 1

{"type":"…/request-in-progress","title":"আগের অনুরোধটি এখনো চলছে","status":409,…}

$ sleep 1
$ curl -si -X POST … -H "Idempotency-Key: $K" -d '{"sku":"RICE-5KG","qty":1}' $API/v2/orders
HTTP/1.1 201 Created
Idempotency-Replayed: true
Location: /orders/1

{"id":1,"total":700}

$ curl -s $API/debug/ledger
{"orders":1,"charges":1,"charged":700}

$ curl -si -X POST … -H "Idempotency-Key: $K" -d '{"sku":"RICE-5KG","qty":2}' $API/v2/orders   # অন্য body
HTTP/1.1 422 Unprocessable Entity

{"type":"…/idempotency-key-reused","title":"একই key ভিন্ন তথ্যে ব্যবহার করা হয়েছে","status":422,…}

৳১,৪০০ নেমে ৳৭০০, order ২টি নেমে ১টি। পুরো যাত্রাটি ধাপে ধাপে দেখুন। সরলতার জন্য এখানে retry এসেছে প্রথমটি শেষ হওয়ার পরে।

1client: new key2api: claim key3api: charge 7004api: order + reply5api: 201 (lost)6client: same key7api: replay 201
হিসাব
key-এর অবস্থা
orders
charges
কাটা টাকা
  1. Key বানানো। ক্লিকের সময় নয়, চেকআউটের অভিপ্রায় শুরুর সময়ে key তৈরি হয়। না হলে retry আলাদা key পাবে।
  2. দখল নেওয়া। INSERT-এ ১টি সারি ঢুকল, অর্থাৎ এই অনুরোধটিই কাজের মালিক। অবস্থা processing.
  3. টাকা কাটা। Gateway ৳৭০০ কাটল। এই ধাপ database transaction-এর বাইরে, কারণ অন্য সার্ভিসকে rollback করা যায় না।
  4. এক সাথে লেখা। Order আর জমানো উত্তর একই transaction-এ। হয় দুটিই থাকবে, নয় কোনোটিই নয়। অবস্থা done.
  5. উত্তর হারাল। Server 201 পাঠাল, পথে নেটওয়ার্ক গেল। Server-এর হিসাবে সব ঠিক, ক্রেতার পর্দায় timeout.
  6. একই key-তে retry. INSERT-এ ০ সারি ঢুকল, কারণ key আগেই আছে। Fingerprint মিলল, অবস্থা done. Charge বা order কোনোটিই আবার হলো না।
  7. জমানো উত্তর ফেরত। ক্রেতা হুবহু আগের 201 পেলেন, একই id. শেষে order ১টি, charge ১টি, কাটা ৳৭০০.

একসাথে আসা duplicate

দুবার ক্লিক একই সাথে হলে দুটি অনুরোধ একই মুহূর্তে চলে। “আগে দেখি key আছে কি না” পদ্ধতি এখানে ভাঙে: দুজনেই দেখে নেই, দুজনেই লেখে। এটি একটি race condition. মডেলে চালিয়ে দেখুন, একই key-তে তিনটি অনুরোধ একসাথে।

const http = require('node:http');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ভুল: আগে দেখা, মাঝে database-এর এক ঘোরা, তারপর লেখা
async function checkThenWrite(keys, key) {
  const found = keys.has(key);
  await sleep(5);
  if (found) return false;
  keys.set(key, 'processing');
  return true;
}
// ঠিক: দেখা আর লেখা এক ধাপে, যেমন INSERT ... ON CONFLICT DO NOTHING
async function atomicClaim(keys, key) {
  if (keys.has(key)) return false;
  keys.set(key, 'processing');
  return true;
}
async function trial(name, claim) {
  const keys = new Map();
  let charges = 0;
  const server = http.createServer(async (req, res) => {
    const mine = await claim(keys, req.headers['idempotency-key']);
    if (!mine) return res.writeHead(409).end();
    await sleep(30);          // gateway-কে টাকা কাটতে বলা
    charges += 1;
    res.writeHead(201).end();
  });
  await new Promise((r) => server.listen(0, '127.0.0.1', r));
  const url = `http://127.0.0.1:${server.address().port}/`;
  const send = () => fetch(url, { method: 'POST', headers: { 'Idempotency-Key': 'k1' } });
  const replies = await Promise.all([send(), send(), send()]);
  console.log(name, replies.map((r) => r.status).sort().join(' '), '| charges:', charges);
  server.closeAllConnections();
  server.close();
}
trial('check-then-write', checkThenWrite).then(() => trial('atomic claim    ', atomicClaim));

আউটপুট:

check-then-write 201 201 201 | charges: 3
atomic claim     201 409 409 | charges: 1

Key থাকা সত্ত্বেও প্রথম পদ্ধতিতে ৩ বার টাকা কাটা গেল, কারণ দেখা আর লেখার মাঝে ফাঁক ছিল। দ্বিতীয় পদ্ধতিতে ফাঁক নেই। আসল Express আর SQLite-এ একই পরীক্ষা তিনটি সমান্তরাল curl দিয়ে চালানো হলো। সত্যিকারের ফল:

request 2 -> 409
request 3 -> 409
request 1 -> 201
ledger: {"orders":1,"charges":1,"charged":700}

ব্যর্থ হলে key-এর কী হবে

Gateway বন্ধ থাকলে 503. কাজ হয়নি, তাই আমরা key মুছে দিই, যাতে retry নতুন করে চলে। কিন্তু 504 হলে? gateway হয়তো টাকা কেটেছে, উত্তরটি হারিয়েছে। তখন retry আবার gateway-তে যাবে। এখানেই key-কে নিচের স্তরেও পাঠানো জরুরি। Lab-এর gateway একই key দ্বিতীয়বার পেলে আর টাকা কাটে না। সত্যিকারের run:

# gateway টাকা কাটল, কিন্তু তার উত্তর হারাল
HTTP/1.1 504 Gateway Timeout
Retry-After: 1
--- ledger: {"orders":0,"charges":1,"charged":700}

# একই key-তে retry
HTTP/1.1 201 Created
Location: /orders/1
--- ledger: {"orders":1,"charges":1,"charged":700}

মাঝের লাইনটি ভয়ের: টাকা কাটা, order নেই। পরের retry সেটি মেলাল। আসল gateway-র idempotency কতক্ষণ থাকে, সেটি তার ডকুমেন্ট বলবে; পর্ব ৬৫-তে webhook সহ পুরো ছবি। আর যদি process মাঝপথে মরে যায়? Key processing-এই আটকে থাকবে, retry পাবে চিরকাল 409. তাই বাস্তবে এতে একটি lease লাগে: নির্দিষ্ট সময় পেরোলে অন্য অনুরোধ দখল নিতে পারবে, আর gateway-র key-র কারণে সেটি নিরাপদ। আমাদের lab-এ lease লেখা হয়নি, এটি নকশার কথা, চালানো হয়নি।

মেয়াদ: কতদিন মনে রাখবেন

খসড়া কোনো নির্দিষ্ট সময় বলে না; বলে মেয়াদ ঠিক করে ডকুমেন্টে প্রকাশ করতে। সময়টি হওয়া উচিত আপনার client-এর সবচেয়ে লম্বা retry-র চেয়ে বেশি। কম হলে কী হয়, তা ইচ্ছা করে করে দেখা হলো: মেয়াদ ১.৫ সেকেন্ড, retry ২ সেকেন্ড পরে।

HTTP/1.1 201 Created
Location: /orders/1

# ২ সেকেন্ড পরে একই key
HTTP/1.1 201 Created
Location: /orders/2
--- ledger: {"orders":2,"charges":1,"charged":700}

Key ভুলে যাওয়ায় দ্বিতীয় order হলো। Charge ১টিই, কারণ lab-এর gateway এখনো key মনে রেখেছে; আসল জীবনে সেটিরও মেয়াদ আছে। উল্টো দিকে খরচ আছে:

মেয়াদসারি = 50 × সেকেন্ডজায়গা (প্রতি সারি 2 KB ধরে)
২৪ ঘণ্টা50 × 86,400 = 4,320,0008.64 GB
৭২ ঘণ্টা50 × 259,200 = 12,960,00025.92 GB

এখানে সেকেন্ডে ৫০টি POST আর ২ KB ধরে নেওয়া সংখ্যা। শিক্ষাটি হলো, আকার বাড়ে মেয়াদের সাথে সরলরেখায়, তাই পুরোনো সারি নিয়মিত মুছতে হয়। আমাদের কোডে সেটি প্রতিটি claim-এ হচ্ছে, যা ছোট lab-এ চলে। বড় টেবিলে একটি আলাদা cron কাজ ভালো (পর্ব ৬২). Key কোথায় থাকবে? Order-এর সাথে একই database-এ রাখলে একই transaction পাওয়া যায়, আর সেটিই বড় সুবিধা। আলাদা cache-এ রাখলে দ্রুত, কিন্তু “এক সাথে লেখা” আর থাকে না।

React থেকে key পাঠানো

Key-এর জন্ম ক্লিকে নয়, অভিপ্রায়ে। নিচের অংশটি উদাহরণ, এখানে চালানো হয়নি। পর্ব ১৯-এর useMutation-এর ওপর এটি লেখা।

const keyRef = useRef(crypto.randomUUID());   // চেকআউট শুরুতে একবার

const pay = useMutation({
  mutationFn: (cart) => api.post('/v2/orders', cart, {
    headers: { 'Idempotency-Key': keyRef.current },
  }),
  retry: (count, err) => count < 2 && [503, 504].includes(err.status),
  onSuccess: () => { keyRef.current = crypto.randomUUID(); },
});
// কার্ট বদলালেও নতুন key দিন, নইলে 422 আসবে

৮ · নিরাপদ retry, Retry-After আর rate limit header

এখন client-এর পুরো সিদ্ধান্ত এক জায়গায় আনা যায়। শর্ত দুটি: ভুলটি কি সাময়িক, আর আবার পাঠানো কি নিরাপদ? GET সবসময় নিরাপদ, PUT আর DELETE নকশামতো হলে নিরাপদ, POST নিরাপদ শুধু key থাকলে। Exponential backoff, jitter আর retry storm-এর হিসাব আছে পর্ব ২৮-এ; এখানে শুধু সিদ্ধান্তটি। নিচের block-এ একটি ছোট server নির্দিষ্ট ক্রমে code ফেরায়, আর client ঠিক করে আবার যাবে কি না। অপেক্ষার সময় শুধু হিসাব হচ্ছে, যাতে block দ্রুত শেষ হয়।

const http = require('node:http');
const server = http.createServer((req, res) => {
  const steps = req.url.slice(1).split('/');           // যেমন /503:2/200
  const [status, wait] = steps[Number(req.headers['x-try'])].split(':');
  if (wait) res.setHeader('Retry-After', wait);
  res.writeHead(Number(status)).end();
});
function shouldRetry(method, status, hasKey) {
  const temporary = [408, 429, 502, 503, 504].includes(status);
  return temporary && (method === 'GET' || hasKey);    // POST শুধু key থাকলে
}
async function send(base, method, path, hasKey) {
  let waited = 0;
  const trail = [];
  for (let attempt = 0; attempt < 3; attempt++) {
    const r = await fetch(base + path, { method, headers: { 'x-try': String(attempt) } });
    trail.push(r.status);
    if (r.ok || !shouldRetry(method, r.status, hasKey) || attempt === 2) break;
    const after = r.headers.get('retry-after');
    waited += after ? Number(after) * 1000 : 100 * 2 ** attempt;   // শুধু হিসাব, আসল ঘুম নয়
  }
  console.log(method.padEnd(4), hasKey ? 'key   ' : 'no key', trail.join(' > ').padEnd(15), `wait ${waited} ms`);
}
server.listen(0, '127.0.0.1', async () => {
  const base = `http://127.0.0.1:${server.address().port}`;
  await send(base, 'GET', '/503:2/200', false);
  await send(base, 'GET', '/502/504/200', false);
  await send(base, 'POST', '/503:2/201', false);
  await send(base, 'POST', '/504/201', true);
  await send(base, 'POST', '/422/201', true);
  await send(base, 'GET', '/500/200', false);
  await send(base, 'GET', '/429:1/429:1/429:1', false);
  server.closeAllConnections();
  server.close();
});

আউটপুট:

GET  no key 503 > 200       wait 2000 ms
GET  no key 502 > 504 > 200 wait 300 ms
POST no key 503             wait 0 ms
POST key    504 > 201       wait 100 ms
POST key    422             wait 0 ms
GET  no key 500             wait 0 ms
GET  no key 429 > 429 > 429 wait 2000 ms

প্রথম সারিতে server বলেছে ২ সেকেন্ড, client তা-ই মেনেছে। দ্বিতীয় সারিতে header নেই, তাই backoff: 100 + 200 = 300 ms. তৃতীয় সারিটি আসল শিক্ষা: key ছাড়া POST-এ 503-তেও client থেমেছে। পঞ্চম সারিতে 422, পরের উত্তর 201 হওয়ার কথা থাকলেও client যায়নি, কারণ না বদলে পাঠানো অর্থহীন। ষষ্ঠ সারিতে 500-এ retry নেই; এটি আমাদের সিদ্ধান্ত, অনেকে একবার চেষ্টা করেন। শেষ সারিতে ৩ বারেই থেমেছে, চিরকাল চেষ্টা করেনি। আসল কোডে jitter যোগ হবে, আর এখানে তা বাদ যাতে আউটপুট স্থির থাকে। 409-এর REQUEST_IN_PROGRESS সাধারণ 409 নয়; সেটির জন্য status-এর সাথে code-ও দেখতে হবে, আর এই জায়গাতেই স্থির code-এর দাম।

Retry-After

RFC 9110 অনুযায়ী এর মান দুই রকম হতে পারে: সেকেন্ডের সংখ্যা (Retry-After: 120), অথবা একটি HTTP তারিখ। Client-এর কোড দুটিই বুঝতে পারলে ভালো। মান ০ বা ভুল হলে নিজের backoff-এ ফিরুন, আর একটি সর্বোচ্চ সীমা রাখুন; একটি ভুল header যেন client-কে এক ঘণ্টা আটকে না রাখে। 429 আর 503-তে এটি বেশি দেখা যায়, 202 বা 3xx-এও পাঠানো যায়।

Rate limit header

পর্ব ২৬-এর express-rate-limit (8.7.2) দিয়ে /search-এ মিনিটে ৩টি অনুরোধের সীমা দেওয়া হলো। চতুর্থ অনুরোধের সত্যিকারের উত্তর:

app.get('/search', rateLimit({
  windowMs: 60_000, limit: 3,
  standardHeaders: 'draft-8', legacyHeaders: false,
  handler: (req, res, next) => next(new Problem(429, 'RATE_LIMITED')),
}), search);
$ curl -si http://127.0.0.1:47333/search    # তৃতীয়বার
HTTP/1.1 200 OK
RateLimit: "3-in-1min"; r=0; t=60
RateLimit-Policy: "3-in-1min"; q=3; w=60; pk=:MTJjYTE3YjQ5YWYy:

$ curl -si http://127.0.0.1:47333/search    # চতুর্থবার
HTTP/1.1 429 Too Many Requests
RateLimit: "3-in-1min"; r=0; t=60
RateLimit-Policy: "3-in-1min"; q=3; w=60; pk=:MTJjYTE3YjQ5YWYy:
Retry-After: 60
Content-Type: application/problem+json; charset=utf-8

{"type":"…/rate-limited","title":"খুব বেশি অনুরোধ, একটু পরে চেষ্টা করুন","status":429,…}

q হলো কোটা, w সময়ের জানালা, r বাকি অনুরোধ, t জানালা শেষ হতে বাকি সেকেন্ড। ভালো client r=0 দেখেই থামতে পারে, 429 খাওয়ার আগেই। মান কিন্তু এখনো স্থির নয়। RateLimit header IETF-এর একটি সক্রিয় খসড়া (draft-ietf-httpapi-ratelimit-headers-11, মে ২০২৬), আর library-র 'draft-8' নামটিই বলছে সে কোন খসড়া মানে। খসড়া ১১-এর উদাহরণের লেখার ধরন একটু আলাদা, যেমন "default";r=50;t=30. বহু পুরোনো API এখনো X-RateLimit-* দেয়, যার কোনো মান নেই আর API ভেদে অর্থ বদলায়। তাই যে API ডাকবেন তার ডকুমেন্ট পড়ুন। Limit-এর algorithm আছে System Design কোর্সের rate limiting পর্বে.

৯ · Conditional request: ETag, 304 আর 412

একই খাতায় দুজন হিসাব লিখছেন। মিনা দাম লিখে গেলেন, রাফি পুরোনো ফটোকপি দেখে তার ওপর নিজের দাম লিখলেন। মিনার কাজ নীরবে মুছে গেল। কেউ কোনো ভুল বার্তা পেলেন না। এই হলো lost update. ফটোকপিতে যদি একটি সংস্করণ নম্বর থাকত, রাফি দেখতেন খাতা এগিয়ে গেছে।

ETag ঠিক সেই নম্বর, একটি resource-এর বর্তমান রূপের পরিচয়। এর ওপর ভর করে দুটি conditional request:

Headerপ্রশ্নকাজে লাগেশর্ত না মিললে
If-None-Matchআমার কপি কি পুরোনো হয়েছে?পড়া, ব্যান্ডউইথ বাঁচানোGET/HEAD-এ 304
If-Matchআমি যা পড়েছি, সেটিই কি এখনো আছে?লেখা, lost update ঠেকানো412
const http = require('node:http');
const item = { price: 700, version: 1 };
const etag = () => `"v${item.version}"`;
const server = http.createServer((req, res) => {
  if (req.method === 'GET') {
    if (req.headers['if-none-match'] === etag()) {
      return res.writeHead(304, { ETag: etag() }).end();
    }
    return res.writeHead(200, { ETag: etag() }).end(JSON.stringify(item));
  }
  const ifMatch = req.headers['if-match'];
  if (!ifMatch) return res.writeHead(428).end();
  if (ifMatch !== etag()) return res.writeHead(412, { ETag: etag() }).end();
  item.price = Number(req.headers['x-price']);
  item.version += 1;
  res.writeHead(200, { ETag: etag() }).end(JSON.stringify(item));
});
async function call(label, url, opts) {
  const r = await fetch(url, opts);
  console.log(label.padEnd(21), r.status, r.headers.get('etag') ?? '-', await r.text());
  return r.headers.get('etag');
}
const patch = (price, tag) => ({ method: 'PATCH',
  headers: { 'x-price': String(price), ...(tag && { 'If-Match': tag }) } });
server.listen(0, '127.0.0.1', async () => {
  const url = `http://127.0.0.1:${server.address().port}/products/1`;
  const seen = await call('GET', url);
  await call('GET again', url, { headers: { 'If-None-Match': seen } });
  await call('Mina PATCH 720', url, patch(720, seen));
  await call('Rafi PATCH 650 (old)', url, patch(650, seen));
  await call('PATCH without tag', url, patch(1));
  await call('GET with old tag', url, { headers: { 'If-None-Match': seen } });
  server.closeAllConnections();
  server.close();
});

আউটপুট:

GET                   200 "v1" {"price":700,"version":1}
GET again             304 "v1"
Mina PATCH 720        200 "v2" {"price":720,"version":2}
Rafi PATCH 650 (old)  412 "v2"
PATCH without tag     428 -
GET with old tag      200 "v2" {"price":720,"version":2}

দ্বিতীয় লাইনের 304-এ কোনো body নেই। চতুর্থ লাইনে রাফির পুরোনো "v1" আটকে গেছে, দাম 720-ই আছে, মিনার কাজ বাঁচল। পঞ্চম লাইনে 428: RFC 6585-এর Precondition Required, মানে “শর্ত ছাড়া এখানে লেখা চলবে না”। না হলে যে client If-Match পাঠাতে ভোলে, সে-ই পাহারা ভেঙে দিত।

আসল Express-এ GET-এর দিকে কাজ প্রায় নেই। Handler ETag বসালে res.json নিজেই If-None-Match মিলিয়ে 304 পাঠায়, সত্যিকারের run:

$ curl -si -H 'If-None-Match: "v1"' http://127.0.0.1:47333/products/1
HTTP/1.1 304 Not Modified
ETag: "v1"
Cache-Control: no-cache

PATCH-এর দিকে Express কিছু করে না। আর সেখানে if (etag !== current) লিখে পরের লাইনে UPDATE করলে একই race ফিরে আসে। তাই যাচাই আর লেখা একই SQL-এ:

const ifMatch = req.get('If-Match');
if (!ifMatch) throw new Problem(428, 'PRECONDITION_REQUIRED');

const m = /^"v(\d+)"$/.exec(ifMatch);
const r = db.prepare(`UPDATE products
    SET price = ?, version = version + 1
    WHERE id = ? AND version = ?`)
  .run(price, id, m ? Number(m[1]) : -1);

const p = findProduct(id);
if (!p) throw new Problem(404, 'PRODUCT_NOT_FOUND');
if (r.changes === 0) {
  throw new Problem(412, 'STALE_VERSION', { currentEtag: `"v${p.version}"` });
}
res.set('ETag', `"v${p.version}"`).json(p);

একে বলে optimistic concurrency: আগে থেকে কিছু আটকে রাখা নয়, লেখার মুহূর্তে যাচাই। দুজন admin-এর একই পণ্য বদলানোর সত্যিকারের ঘটনাটি ধাপে ধাপে দেখুন।

  1. দুজনে পড়লেন"v1"৳700
  2. মিনা লিখলেন200৳720
  3. রাফি আটকালেন4120 সারি
  4. আবার পড়া"v2"৳720
  5. জেনেশুনে লেখা"v3"৳650
মিনা আর রাফিGET
HTTP/1.1 200 OK
ETag: "v1"
{"sku":"RICE-5KG","price":700,"version":1}
মিনাPATCH
If-Match: "v1"   // price: 720
UPDATE ... WHERE id = 1 AND version = 1   -- 1 সারি
HTTP/1.1 200 OK
ETag: "v2"
রাফিPATCH
If-Match: "v1"   // price: 650
UPDATE ... WHERE id = 1 AND version = 1   -- 0 সারি
HTTP/1.1 412 Precondition Failed
{"code":"STALE_VERSION",
 "title":"অন্য কেউ এটি আগেই বদলেছেন",
 "currentEtag":"\"v2\""}
রাফির পর্দাGET
HTTP/1.1 200 OK
ETag: "v2"
{"sku":"RICE-5KG","price":720,"version":2}
// দাম ৭২০ হয়েছে। আপনার ৬৫০ কি তবু রাখবেন?
রাফিPATCH
If-Match: "v2"   // price: 650
HTTP/1.1 200 OK
ETag: "v3"
{"sku":"RICE-5KG","price":650,"version":3}
  1. একই জিনিস পড়া। দুজনেই দেখলেন ৳৭০০, ETag "v1". দুজনের পর্দায় একই ফর্ম খোলা।
  2. মিনা আগে পৌঁছালেন। শর্ত মিলল, ১টি সারি বদলাল। দাম ৳৭২০, version ২, নতুন ETag "v2".
  3. রাফি পুরোনো চিহ্ন নিয়ে এলেন। WHERE version = 1-এ কোনো সারি মিলল না। ০ সারি বদলাল, উত্তর 412. মিনার ৳৭২০ অক্ষত।
  4. আবার পড়া। রাফির পর্দায় নতুন দাম আর নতুন ETag "v2". এখন সিদ্ধান্ত মানুষের, যন্ত্রের নয়। এখানে অন্ধ retry হবে না।
  5. জেনেশুনে লেখা। রাফি মিনার বদল দেখে তবু ৳৬৫০ রাখলেন। If-Match: "v2" মিলল, তাই নতুন ETag "v3". কোনো লেখা নীরবে হারায়নি।

Weak আর strong

আমাদের "v1" নিজের বানানো strong ETag. Express নিজে থেকে যা বসায়, তা body-র hash থেকে তৈরি weak ETag: ETag: W/"7a-Y9ZtARp/0QbC65Q4B7t5B5X0YfE". RFC 9110 বলে If-None-Match weak তুলনা করে, আর If-Match strong তুলনা করে। তাই সত্যিকারের run-এ If-Match: W/"v2", যদিও version মিলছে, 412 পেয়েছে। পড়ার জন্য weak ঠিক আছে, লেখার পাহারায় নয়।

আরেকটি জিনিস run-এ ধরা পড়েছে। Express ভুলের উত্তরেও নিজের weak ETag বসায়; আমাদের 412-র উত্তরে ছিল ETag: W/"130-…", যেটি error body-র hash, পণ্যের সংস্করণ নয়। তাই client শুধু 2xx-এর ETag মনে রাখবে, আর পরের সংস্করণ নেবে currentEtag থেকে। উপরে পেস্ট করা উত্তরে এই লাইনগুলো ছোট করা হয়েছে।

কত বাঁচে: একটি হিসাব

ধরুন পণ্যের তালিকার JSON ২০০ KB, ১,০০০ জন ব্যবহারকারী প্রতি ৩০ সেকেন্ডে poll করেন, আর ৯৫% সময় কিছু বদলায় না। 304-এর উত্তর ধরি ০.৩ KB (সবই ধরে নেওয়া সংখ্যা)। এক ঘণ্টায়:

ধাপহিসাবফল
মোট অনুরোধ1000 × 3600 ÷ 30120,000
ETag ছাড়া120,000 × 200 KB24,000 MB
পুরো উত্তর (৫%)6,000 × 200 KB1,200 MB
304 (৯৫%)114,000 × 0.3 KB34.2 MB
ETag সহ1,200 + 34.2≈ 1,234 MB

ব্যান্ডউইথ প্রায় ৯৫% কমেছে, যা 1 − 1234 ÷ 24000 ≈ 0.949. কিন্তু সাবধান: Express-এর স্বয়ংক্রিয় ETag বানাতে body তৈরি করতে হয়, তাই database query আর CPU বাঁচে না, বাঁচে শুধু নেটওয়ার্ক। Version-এর মতো সস্তা চিহ্ন আগে দেখলে তবেই query-ও বাঁচে। HTTP cache-এর পুরো ছবি আছে Computer Networks কোর্সের web caching পর্বে আর পর্ব ৮৪-তে।

আরও গভীরে: If-None-Match: * দিয়ে “শুধু না থাকলে তৈরি করুন” বোঝানো যায়, আর PUT-এ এটি একই নামের resource দুবার তৈরি হওয়া ঠেকায়। “ঠিক একবার পৌঁছানো” নেটওয়ার্কে অসম্ভব বলে ধরা হয়; বাস্তবে আমরা পাই at-least-once পাঠানো আর idempotent গ্রহণ, যা মিলে ফলাফল একবারের মতো হয়। Queue আর webhook-এও একই যুক্তি চলে (delivery semantics পর্ব). আর Vary: Accept-Language cache-কে বলে, ভাষা ভেদে উত্তর আলাদা, তাই বাংলা উত্তর ইংরেজি পাঠকের কাছে যাবে না।

পরীক্ষা ও ইন্টারভিউতে: 401 আর 403-র পার্থক্য? 401 পরিচয় পাইনি, 403 পরিচয় আছে কিন্তু অনুমতি নেই। কখন 403-র বদলে 404? যখন জিনিসটির অস্তিত্বই গোপন রাখতে হবে। Idempotent আর safe-এর পার্থক্য? Safe মানে কিছু বদলায় না, idempotent মানে একাধিকবারের প্রভাব একবারের সমান; তাই DELETE idempotent, safe নয়। POST-কে retry-নিরাপদ করবেন কীভাবে? Client-এর key, atomic claim, জমানো উত্তর। 304 আর 412-র পার্থক্য? দুটিই conditional; 304 পড়ায় “কপি ঠিক আছে”, 412 লেখায় “শর্ত মেলেনি, কিছু করিনি”।

১০ · AI-এর যুগে: API-র ভুলের পথ যাচাই

AI দিয়ে লেখা API সুখের পথে সুন্দর চলে। দুবার ক্লিক, হারানো উত্তর আর দুজনের একসাথে লেখা, এগুলো কোনো demo-তে ঘটে না। Idempotency-র কোড দেখতে ঠিক লাগলেও atomic না হলে কাজ করে না, আর সেটি শুধু একসাথে অনুরোধ পাঠালেই ধরা পড়ে।

AI-এর ভুলকেন ক্ষতিকরযাচাই
Key-তে SELECT, তারপর INSERTএকসাথে এলে দুটিই পাস করে৩টি সমান্তরাল curl
Key শুধু memory-র Map-এRestart-এ মোছে, দুই process-এ আলাদাRestart দিয়ে আবার পাঠান
Key-তে ব্যবহারকারী জোড়া নেইঅন্যের জমানো উত্তর পাওয়া যেতে পারেদুই ব্যবহারকারী, একই key
Body-র তুলনা নেইভিন্ন order-এ পুরোনো উত্তর আসেএকই key, অন্য body
Idempotency-Key-কে “RFC” বলাএটি এখনো খসড়াdatatracker দেখুন
সব ভুলে 200 বা 500Cache, retry আর alert ভুল আচরণ করেমইয়ের প্রতিটি ধাপ curl করুন
অন্যের জিনিসে 403অস্তিত্ব ফাঁস হয়দুই ব্যবহারকারী দিয়ে তুলনা
If-Match যাচাই আর UPDATE আলাদাLost update ফিরে আসেদুটি PATCH একসাথে

“Idempotency যোগ করো” বলবেন না। সিদ্ধান্তগুলো লিখে দিন, আর পরীক্ষা চান:

আমার API Express 5, Node 24 আর PostgreSQL-এ চলে। POST /orders-এ Idempotency-Key যোগ করো। শর্ত: ১. Key থাকবে (user_id, key)-এর unique constraint-সহ একটি টেবিলে। দখল নেওয়া হবে একটি INSERT ... ON CONFLICT DO NOTHING দিয়ে, আগে SELECT করে নয়। ২. Body-র SHA-256 fingerprint রাখো। একই key, ভিন্ন body হলে 422. ৩. আগের অনুরোধ চলতে থাকলে 409 আর Retry-After. ৪. Order লেখা আর জমানো উত্তর লেখা একই transaction-এ হবে। ৫. Gateway-তে একই key পাঠাও। Process মরে গেলে key যেন চিরতরে আটকে না থাকে, তার ব্যবস্থা বলো। ৬. Key না থাকলে 400. ভুলের উত্তর application/problem+json-এ। ৭. প্রতিটি শর্তের জন্য একটি পরীক্ষা দাও, যার একটিতে একই key-তে ৩টি অনুরোধ একসাথে যাবে। Idempotency-Key যে এখনো IETF খসড়া, সেটি মনে রেখো। যে package বা option সম্পর্কে নিশ্চিত নও, আলাদা করে জানাও। কোনো আসল secret চাইবে না।

AI-এর লেখা API হাতে পেলে

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।

প্র ০১400 আর 422 নিয়ে এত তর্ক কেন? 422 তো শুরুতে HTTP-রই অংশ ছিল না।

তর্কটির শিকড় ইতিহাসে। ১৯৯৯ সালের RFC 2616-এ 400-এর অর্থ ছিল সরু: malformed syntax, অর্থাৎ অনুরোধের গঠনই ভাঙা। তখন “JSON ঠিক, কিন্তু মান ভুল” বোঝানোর কোনো পরিষ্কার code ছিল না। ২০০৭ সালে WebDAV-এর RFC 4918 আনল 422 Unprocessable Entity, ফাইল-সম্পাদনার প্রোটোকলের জন্য। REST API-গুলো শব্দটি পছন্দ করে ধার নিল, যদিও সেটি মূল HTTP-তে ছিল না। Rails-এর মতো framework-এর প্রচলিত ধরনও এটিকে জনপ্রিয় করেছে বলে ধরা হয়।

এরপর দুই দিকেই বদল এলো। ২০১৪ সালের RFC 7231 400-এর অর্থ চওড়া করল: যা কিছু client-এর ভুল বলে মনে হয়। ফলে “সব ইনপুটের ভুলে 400” মানের দিক থেকেও ঠিক হয়ে গেল। আর ২০২২ সালের RFC 9110 422-কে মূল মানে তুলে আনল, নাম দিল Unprocessable Content. তাই আজ দুই পক্ষই মান দেখিয়ে নিজেদের ঠিক বলতে পারেন। এটি সেই বিরল ক্ষেত্র, যেখানে কোনো পক্ষই ভুল নয়।

তাহলে কীভাবে ঠিক করবেন? প্রশ্ন করুন, client এই ভাগ দিয়ে কী করবে। 400 আর 422 আলাদা করলে একটি ব্যবহারিক লাভ আছে: 400 সাধারণত client-এর কোডের ভুল, যা ডেভেলপার দেখবেন, আর 422 ব্যবহারকারীর ইনপুট, যা ফর্মে দেখাতে হবে। Monitoring-এও এটি কাজে লাগে; 400 হঠাৎ বাড়লে হয়তো কোনো app version ভাঙা JSON পাঠাচ্ছে। আবার ছোট দলের জন্য এক code সহজ, আর পার্থক্যটি body-র code-তেই থাকতে পারে। IETF-এর idempotency খসড়া মিশ্র পথ নিয়েছে: key না থাকলে 400, key-এর ভুল ব্যবহারে 422. আমাদের কোর্সও সেটি মানে।

মূল উপলব্ধি: দুই মতই মানসম্মত। ক্ষতি হয় মত বেছে না নেওয়ায়, যখন একই API-র একেক route একেক কথা বলে।

প্র ০২GraphQL প্রায়ই ভুলেও 200 দেয়। তাহলে “ভুলে 200 নয়” নিয়মটি কি সর্বজনীন?

নিয়মটি HTTP-র অর্থের ওপর দাঁড়ানো, আর সেটি বুঝলেই ব্যতিক্রমটিও বোঝা যায়। Status code একটি resource-এর ওপর একটি কাজের ফল বলার জন্য তৈরি। REST সেই নকশা মেনে চলে: একটি URL, একটি কাজ, একটি ফল। GraphQL একটি অনুরোধে অনেকগুলো field চায়। ধরুন order এলো, কিন্তু recommendation সার্ভিস পড়ে গেল। পুরো উত্তরটি সফল, না ব্যর্থ? একটি সংখ্যায় এটি বলা যায় না। তাই GraphQL উত্তর দেয় data আর errors দুটি একসাথে, আর ঐতিহ্যগতভাবে HTTP 200.

এর আগেও এই গল্প ঘটেছে। SOAP HTTP-কে শুধু পরিবহনের পথ হিসেবে দেখত; ভুল বোঝানো হতো XML-এর ভেতরের fault দিয়ে। JSON-RPC-ও নিজের error object রাখে। এসব প্রোটোকলে HTTP হলো খাম, আর আসল চিঠি ভেতরে। খামে “পৌঁছেছে” লেখা থাকে, চিঠিতে লেখা “টাকা নেই”।

দাম কিন্তু আছে। CDN, API gateway আর monitoring-এর চোখে সব 200, তাই 5xx-এর গ্রাফে কিছু ধরা পড়ে না। সেই কারণে GraphQL জগতে নিজের মেট্রিক আর টুল লাগে। GraphQL over HTTP-র খসড়া মান এই দূরত্ব কমানোর চেষ্টা করছে; নতুন media type-এ পুরোপুরি ব্যর্থ অনুরোধে 4xx বা 5xx দেওয়ার পথ রাখা হয়েছে বলে জানা যায়। পর্ব ৩৬-এ GraphQL-এর পুরো তুলনা পাবেন।

কিন্তু একটি সাধারণ REST API-তে {"success": false} আর 200 দেওয়ার পক্ষে এই যুক্তি খাটে না। সেখানে একটি কাজ, একটি ফল। তবু অনেক API এমন করে, হয়তো কারণ পুরোনো frontend লাইব্রেরি 4xx-কে exception বানাত। আজ fetch তা করে না; 404-তেও সে সফলভাবে resolve হয়, আর r.ok দেখতে হয়।

মূল উপলব্ধি: নিয়মটি হলো “মধ্যস্থতাকারীরা যা বোঝে, সেই ভাষায় সত্য বলুন”। একটি উত্তরে অনেক ফল থাকলে ভেতরের কাঠামো লাগে, আর সেটির দাম মেনে নিতে হয়।

প্র ০৩“Exactly once” কি সত্যিই সম্ভব? Idempotency key তাহলে কী দিচ্ছে?

নেটওয়ার্কের ওপর “ঠিক একবার পৌঁছানো” অর্জন করা যায় না, আর তার যুক্তি বেশ পুরোনো। ১৯৭০-এর দশকের Two Generals সমস্যা ভাবুন। দুই সেনাপতি বার্তাবাহক দিয়ে আক্রমণের সময় ঠিক করবেন, কিন্তু বার্তাবাহক ধরা পড়তে পারে। “পেয়েছি” বার্তাটিও হারাতে পারে, আর সেই “পেয়েছি”-র “পেয়েছি”-ও। কোনো সীমিত সংখ্যক বার্তায় দুজনেই নিশ্চিত হতে পারেন না। আপনার POST ঠিক এই অবস্থায়। উত্তর না পেলে client জানতে পারে না কাজ হয়েছে কি না।

তাই client-এর হাতে দুটি পথ। না পাঠালে কাজ শূন্যবার হতে পারে। আবার পাঠালে একাধিকবার। প্রথমটি at-most-once, দ্বিতীয়টি at-least-once. Idempotency এই পছন্দটি বদলে দেয় না; সে দ্বিতীয় পথের ক্ষতি সরিয়ে দেয়। অনুরোধ অনেকবার পৌঁছাতে পারে, কিন্তু প্রভাব থাকে একবারের। এজন্য অনেকে বলেন “effectively once”. Kafka-র মতো সিস্টেমের “exactly-once semantics”-ও সাধারণত একটি নির্দিষ্ট সীমার ভেতরে এই অর্থেই।

পেমেন্ট শিল্প এই নকশা বহু আগে থেকে ব্যবহার করে। ২০১৭ সালের দিকে Stripe-এর প্রকৌশল ব্লগে Brandur Leach-এর “Designing robust and predictable APIs with idempotency” লেখাটি ধারণাটি বহু ডেভেলপারের কাছে পৌঁছে দেয়, আর তিনি PostgreSQL-এ এর বাস্তবায়নও লিখেছেন। IETF-এর খসড়ায় এখন বিভিন্ন প্রতিষ্ঠানের ব্যবহারের তালিকা আছে। লক্ষ করুন, এত বছর পরেও এটি খসড়া; অর্থাৎ বাস্তব ব্যবহার মানের অনেক আগে চলে।

তবু কিছু ফাঁক থাকে, আর সেগুলো জানা জরুরি। আপনার key বাইরের সার্ভিসকে বাঁচায় না, যদি সে সার্ভিস নিজে idempotent না হয়। ইমেইল দুবার গেলে ফেরানো যায় না। তাই প্রতিটি পার্শ্বপ্রভাবের জন্য আলাদা ভাবতে হয়: টাকা, ইমেইল, stock.

মূল উপলব্ধি: নেটওয়ার্ক আপনাকে একবার পৌঁছানোর নিশ্চয়তা দেবে না। আপনি নিশ্চিত করতে পারেন শুধু এটুকু, বারবার পৌঁছালেও ফল একবারের মতো থাকবে।

প্র ০৪RFC 9457 নেওয়া কি সত্যিই দরকার? ছোট একটি {"error":"..."}-এ সমস্যা কী?

ইতিহাসটি দিয়ে শুরু করা যাক। Mark Nottingham আর Erik Wilde ২০১৬ সালে RFC 7807 লেখেন, কারণ প্রায় প্রতিটি API নিজের error আকার বানাচ্ছিল। ফলে প্রতিটি নতুন API-র জন্য client-কে নতুন করে শিখতে হতো। ২০২৩ সালের RFC 9457-এ Sanjay Dalal যুক্ত হন বলে জানা যায়। নতুন সংস্করণে প্রধান সংযোজন একটি IANA registry, যাতে সাধারণ সমস্যার ধরন ভাগ করা যায়, আর একই ধরনের একাধিক সমস্যা বোঝানোর নির্দেশনা। লক্ষ করুন, মানটি খুব ছোট। সে শুধু কয়েকটি সদস্যের নাম ঠিক করে দেয়, আর বাড়তি সব আপনার হাতে ছেড়ে দেয়। জনপ্রিয় হওয়ার একটি কারণ এটাই।

কোনটি নেবেন, তা নির্ভর করে API কারা ব্যবহার করেন তার ওপর। Public API, অনেক দলের ব্যবহারের internal API, বা যেখানে gateway আর SDK-র মতো টুল আছে, সেখানে মান নেওয়ার লাভ পরিষ্কার। Spring Framework-এর মতো কিছু framework নিজে থেকেই এই আকার দিতে পারে। একটি generic client একবার লিখলেই অনেক API-তে চলে। উল্টো দিকে একটি ছোট দল, একটি frontend, একটি backend, সেখানে {code, message, fields}-এর মতো আকার সমান কাজ করে, আর ভারী type URI-র দরকার পড়ে না।

কিন্তু “ছোট {"error":"..."}”-এর সমস্যা মান না মানা নয়, সমস্যা তথ্যের অভাব। সেখানে শুধু একটি বাক্য। Client সিদ্ধান্ত নেবে কী দেখে? শেষে বাক্যটি মিলিয়ে if (msg.includes('stock')) লেখা হয়, আর কেউ বানান ঠিক করলে বা বাংলা করলে কোড ভাঙে। Trace id-ও নেই, তাই সহায়তা দল খুঁজে পায় না। অর্থাৎ যে আকারই নিন, স্থির code, মানুষের বার্তা, ঘরভিত্তিক ভুল আর trace id, এই চারটি চাই।

একটি সাবধানতা: মান নিলেও ভুল করা যায়। detail-এ SQL ঢুকিয়ে দিলে RFC মানা হলো, নিরাপত্তা নয়। RFC নিজেই বলে detail client-কে সমাধানে সাহায্যের জন্য, debug-এর জন্য নয়।

মূল উপলব্ধি: আসল প্রশ্ন “কোন আকার” নয়। প্রশ্ন হলো, আপনার ভুলের উত্তর পড়ে একটি প্রোগ্রাম কি সিদ্ধান্ত নিতে পারে, আর একজন মানুষ কি বুঝতে পারেন।

প্র ০৫Lost update ঠেকাতে lock না নিয়ে ETag কেন? দুই পদ্ধতির পার্থক্য কোথায়?

সমস্যাটি ওয়েবের শুরু থেকেই পরিচিত। ১৯৯৯ সালের W3C-র একটি নোট, “Detecting the Lost Update Problem Using Unreserved Checkout”, দেখিয়েছিল কীভাবে HTTP-র ETag আর If-Match দিয়ে লক ছাড়াই এই সংঘাত ধরা যায়। অনেকে একে “mid-air collision” বলেন। ধারণাটি অবশ্য ওয়েবের চেয়ে পুরোনো। ১৯৮১ সালে Kung আর Robinson-এর গবেষণাপত্রে database-এর জন্য optimistic concurrency control প্রস্তাব করা হয়: ধরে নিন সংঘাত বিরল, কাজ করুন, শেষে যাচাই করুন।

Pessimistic lock উল্টো পথে হাঁটে। আগে তালা, তারপর কাজ। একটি একক database transaction-এর ভেতরে, কয়েক মিলিসেকেন্ডে, এটি দারুণ। SELECT ... FOR UPDATE পর্ব ৪৩-এ দেখবেন। কিন্তু HTTP-র ওপর তালা ভয়ংকর। একজন admin ফর্ম খুলে চা খেতে গেলেন; তালা কতক্ষণ থাকবে? ল্যাপটপ বন্ধ হলে কে খুলবে? হাজার client-এর হাজার তালা server মনে রাখবে কীভাবে? HTTP stateless হওয়ার পুরো সুবিধা নষ্ট হয়।

ETag-এ server কিছুই মনে রাখে না, শুধু version নম্বর। তালা নেই, তাই আটকে থাকারও ভয় নেই। দাম হলো, সংঘাত হলে একজনের কাজ ফেরত যায়, আর তাঁকে আবার করতে হয়। পণ্যের দাম বদলানোতে এটি চলে, কারণ সংঘাত বিরল আর ফর্ম ছোট। লম্বা নথি লেখায় নয়; এক ঘণ্টার লেখা 412-তে ফেরত গেলে মানুষ রেগে যান। সেখানে merge বা collaborative editing-এর কৌশল লাগে, যেমন CRDT. আবার মজুত কমানোর মতো কাজে সংঘাত ঘন ঘন, তাই সেখানে stock = stock - 1 ধরনের atomic update সবচেয়ে ভালো, ETag লাগেই না।

আরেকটি বিষয় মনে রাখুন। 412-তে অন্ধ retry চলবে না। 503-তে retry মানে একই কাজ আবার। 412-তে retry মানে অন্যের লেখা না দেখে মুছে দেওয়া, অর্থাৎ যে সমস্যা ঠেকাতে এত কিছু, ঠিক সেটিই।

মূল উপলব্ধি: সংঘাত কতটা ঘন আর ফেরত যাওয়া কাজের দাম কত, এই দুই প্রশ্নে পদ্ধতি ঠিক হয়। ওয়েবের লম্বা, অনিশ্চিত সময়ের জন্য তালার চেয়ে সংস্করণ নম্বর অনেক ভালো মানায়।

অনুশীলন

প্রথমে নিজে চেষ্টা করুন, তারপর উত্তর মিলিয়ে নিন।

  1. সংখ্যার হিসাব: বাজারঘরে সেকেন্ডে ৩০টি POST /orders আসে। Key-এর মেয়াদ ৪৮ ঘণ্টা, প্রতি সারি ১.৫ KB. (ক) সর্বোচ্চ কতগুলো সারি থাকবে? (খ) কত জায়গা লাগবে (1 GB = 106 KB)? (গ) দিনে ৫,০০০ চেকআউটের ০.৪%-এ উত্তর হারায়, গড় order ৳৫০০. Key ছাড়া দিনে কত টাকা ভুল কাটা হবে, যদি সবাই একবার আবার চাপেন?

    (ক) ৪৮ ঘণ্টা = 172,800 সেকেন্ড। 30 × 172,800 = 5,184,000 সারি।

    (খ) 5,184,000 × 1.5 KB = 7,776,000 KB = 7.776 GB.

    (গ) 5000 × 0.004 = ২০টি duplicate. 20 × 500 = ৳১০,০০০.

  2. ফল অনুমান: বাজারঘরের /v2/orders-এ নিচের অনুরোধগুলো ক্রমে গেল। K আর L দুটি বৈধ UUID. Gateway ৪০০ ms নেয়, আর প্রতিটি অনুরোধ আগেরটির ফল আসার পরে যায়, শুধু ২ নম্বরটি ১ নম্বরের ৫০ ms পরে। প্রতিটির status কী, আর শেষে order ও charge কয়টি?
    ১. রাফি, key K, {"sku":"RICE-5KG","qty":1}
    ২. রাফি, key K, {"sku":"RICE-5KG","qty":1}
    ৩. রাফি, key K, {"sku":"RICE-5KG","qty":1}  (১ শেষ হওয়ার পরে)
    ৪. রাফি, key K, {"sku":"RICE-5KG","qty":3}
    ৫. মিনা, key K, {"sku":"RICE-5KG","qty":1}
    ৬. রাফি, key L, {"sku":"RICE-5KG","qty":99}
    ৭. রাফি, key নেই, {"sku":"RICE-5KG","qty":1}

    ১: 201. ২: 409, কারণ ১ এখনো চলছে। ৩: 201, জমানো উত্তর, Idempotency-Replayed: true. ৪: 422, একই key, ভিন্ন fingerprint. ৫: 201, নতুন order, কারণ key প্রতি ব্যবহারকারীর আলাদা। ৬: 422 VALIDATION_FAILED, আর key দখলই হয়নি, কারণ যাচাই আগে। ৭: 400.

    শেষে order ২টি (রাফির ১, মিনার ১), charge ২টি. এই পুরো ক্রমটি lab-এ চালিয়ে মিলিয়ে নেওয়া হয়েছে, সত্যিকারের ledger: {"orders":2,"charges":2,"charged":1400}.

  3. Code review: AI একটি idempotency middleware দিয়েছে। অন্তত পাঁচটি সমস্যা বের করুন।
    const cache = new Map();
    app.post('/orders', async (req, res) => {
      const key = req.get('Idempotency-Key');
      if (cache.has(key)) return res.json(cache.get(key));
      const order = await createAndCharge(req.body);
      cache.set(key, order);
      res.json(order);
    });
    1. দেখা আর লেখার মাঝে ফাঁক: await-এর পরে set, তাই একসাথে আসা অনুরোধ দুটিই চলবে। আগে atomic claim.
    2. Memory-র Map: Restart-এ মোছে, দুই process-এ আলাদা। Database বা ভাগ করা store লাগবে।
    3. ব্যবহারকারী জোড়া নেই: অন্যের key-তে অন্যের order ফিরতে পারে, যা তথ্য ফাঁস।
    4. Fingerprint নেই: ভিন্ন body-তেও পুরোনো order ফিরবে।
    5. Key না থাকা: cache.has(undefined) চলে; দ্বিতীয় key-হীন অনুরোধ আগের কারও order পেতে পারে! 400 দিতে হবে, আর format যাচাই।
    6. মেয়াদ নেই: Map চিরকাল বাড়ে।
    7. Status ভুল: তৈরিতে 201 আর Location, এখানে 200; জমানো status-ও নেই।
    8. এক সাথে লেখা নয়: Order হলো, কিন্তু process মরল; key জমল না। Transaction লাগবে।
  4. কমান্ড: $API/products/1 থেকে ETag নিয়ে সেটি দিয়ে দাম বদলানোর দুটি curl লিখুন। তারপর একই ETag দিয়ে আবার চালান। দ্বিতীয়বার কী status আসবে, আর client-এর এরপরের কাজ কী?
    $ ET=$(curl -si $API/products/1 | tr -d '\r' | grep -i '^etag:' | cut -d' ' -f2-)
    $ curl -si -X PATCH -H 'content-type: application/json' \
        -H 'Authorization: Bearer token-admin' -H "If-Match: $ET" \
        -d '{"price":720}' $API/products/1

    প্রথমবার 200, নতুন ETag: "v2". একই $ET-এ দ্বিতীয়বার 412, code STALE_VERSION. Client-এর কাজ: আবার GET করে নতুন মান দেখানো, মানুষকে সিদ্ধান্ত নিতে দেওয়া, তারপর নতুন ETag-এ PATCH. অন্ধ retry নয়। লক্ষ করুন, $ET-এর মানে উদ্ধৃতিচিহ্নও থাকে, আর সেটিই ঠিক।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমIdempotency key-সহ একটি POST-এর ধাপগুলো সাজান (▲▼ চেপে সরান)

  1. Client অভিপ্রায়ের জন্য একটি key বানায়
  2. Server একটি INSERT দিয়ে key দখল করে
  3. Gateway-তে একই key নিয়ে টাকা কাটা হয়
  4. Order আর জমানো উত্তর একই transaction-এ লেখা হয়
  5. উত্তর হারালে client একই key-তে আবার পাঠায়
  6. Server জমানো উত্তর ফেরত দেয়

লগইন করা একজন ক্রেতা অন্য ক্রেতার order-এর id দিয়ে GET করলেন। Order-টির অস্তিত্ব গোপন রাখা জরুরি। সবচেয়ে ভালো উত্তর কোনটি?

  • 401 Unauthorized
  • 403 Forbidden
  • 404 Not Found, না-থাকা order-এর মতোই
  • 410 Gone

এই কোর্সের নিয়মে {"sku":"RICE-5KG","qty":99} পাঠানো হলো, যেখানে qty-এর সীমা ১০. JSON-টি বৈধ। কোন status?

  • 400 Bad Request
  • 422 Unprocessable Content
  • 409 Conflict
  • 415 Unsupported Media Type

RFC 9457-এর problem details নিয়ে কোন কথাটি ঠিক?

  • Client-এর কোড detail parse করে সিদ্ধান্ত নেবে
  • Body-র status আসল HTTP code থেকে আলাদা হতে পারে
  • পাঁচটি সদস্যের বাইরে কিছু যোগ করা নিষেধ
  • type না থাকলে ধরে নেওয়া হয় about:blank

একই Idempotency-Key-তে দুটি অনুরোধ একসাথে এলো, আর দুটি পরীক্ষাই পাস করে দুবার টাকা কাটা গেল। সম্ভাব্য কারণ কোনটি?

  • কোড আগে SELECT করে, পরে INSERT করে
  • Key একটি UUID
  • Key ব্যবহারকারীর সাথে জোড়া
  • Key-এর মেয়াদ ৪৮ ঘণ্টা

মোট 120,000টি অনুরোধ, পুরো উত্তর 200 KB. 95% অনুরোধে 304 আসে, যার আকার 0.3 KB. মোট ডেটা প্রায় কত?

  • 24,000 MB
  • প্রায় 1,234 MB
  • প্রায় 34 MB
  • প্রায় 2,434 MB

পুরোনো If-Match নিয়ে PATCH করে 412 এলো। Client-এর সঠিক পরের কাজ কোনটি?

  • Backoff দিয়ে একই অনুরোধ আবার পাঠানো
  • If-Match বাদ দিয়ে আবার পাঠানো
  • আবার GET করে নতুন মান দেখানো, তারপর নতুন ETag-এ পাঠানো
  • ব্যবহারকারীকে login পাতায় পাঠানো

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Code একটি সিদ্ধান্ত: হয়েছে কি, দায় কার, আবার পাঠালে লাভ আছে কি। ভুলে 200 দেবেন না।
  2. জোড়াগুলো: 400 পড়া যায়নি, 422 নিয়ম ভেঙেছে; 401 পরিচয় নেই, 403 অনুমতি নেই; গোপন জিনিসে 403-র বদলে একই 404.
  3. Error একটি চুক্তি: স্থির code, মানুষের বার্তা, ঘরভিত্তিক ভুল, trace id; মান হিসেবে RFC 9457.
  4. POST-এর retry: অভিপ্রায়ে একটি key, atomic claim, একই transaction, জমানো উত্তর, নিচের স্তরেও একই key. Idempotency-Key এখনো খসড়া।
  5. ETag: If-None-Match-এ 304, If-Match-এ 412, যাচাই আর লেখা একই SQL-এ। 412-তে অন্ধ retry নয়।

পরের পর্বে: বাজারঘরের তালিকায় এখন ২টি পণ্য, পরে হবে কয়েক লাখ। পর্ব ৩৪-এ দেখব offset আর cursor pagination, filter আর sort, আর কেন বড় OFFSET ধীর হয়।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান