অধ্যায় ২

The Retry Pattern

"If something fails temporarily, try again before giving up."

ফেইল হলেই হাল না ছেড়ে আবার চেষ্টা করা — DB transaction, HTTP client, queue job আর retry() helper-এ একই আইডিয়া।

~১২ মিনিট১২টি স্লাইড

সমস্যাটা কী?

আপনি একটি পেমেন্ট গেটওয়ে ইন্টিগ্রেট করছেন। ডেভেলপমেন্টে সব ঠিকঠাক, কিন্তু প্রোডাকশনে API মাঝে মাঝে টাইমআউট দেয় — হয়তো কয়েকশো রিকোয়েস্টে একবার। কিন্তু ইউজাররা এরর দেখার জন্য সেটাই যথেষ্ট।

API-কে বেশি রিলায়েবল বানানো আপনার হাতে নেই। সমাধান হলো — ‘আবার চেষ্টা করুন’।

রিকোয়েস্ট ফেইল ✗ একটু অপেক্ষা আবার চেষ্টা সফল ✓

Retry কোনো এক জায়গার ফিচার নয়। এটা আছে database transaction, HTTP request, queued job এবং একটি স্বতন্ত্র helper-এ। API একটু আলাদা, কিন্তু কনসেপ্ট সবসময় এক: কিছু সাময়িকভাবে ফেইল করলে, হাল ছাড়ার আগে আবার চেষ্টা করো।

Transient বনাম Permanent

Transient failure হলো সাময়িক সমস্যা — নেটওয়ার্ক গ্লিচ, সার্ভিসের মুহূর্তের আউটেজ, ডাটাবেস ডেডলক। এগুলো আপনার লজিকের বাগ নয়, পরিবেশগত। প্রায়ই শুধু আবার চেষ্টা করলেই ঠিক হয়ে যায়।

Retry করার মতো (Transient)

  • নেটওয়ার্ক টাইমআউট
  • সার্ভার এক মুহূর্তের জন্য unreachable
  • ডাটাবেস deadlock
  • HTTP 503 / connection reset

Retry করে লাভ নেই (Permanent)

  • Validation error (HTTP 422)
  • ভুল credentials / পাসওয়ার্ড
  • Query syntax error
  • ভুল ডেটা পাঠানো

Permanent failure retry করলে সময় নষ্ট হয় এবং আসল সমস্যা চাপা পড়ে যায়।

Retry না থাকলে ছোট ফেইলিওর বড় হয়ে যায়: একটা API timeout ইউজারের checkout ভেঙে দেয়, একটা queue job নেটওয়ার্ক হেঁচকিতে ফেইল করে আর কখনো চলে না, একটা deadlock এমন transaction বাতিল করে যেটা এক মুহূর্ত পরেই সফল হতো।

Database Transactions

Deadlock হলো retry-র ক্লাসিক উদাহরণ। দুটি কোয়েরি বিপরীত ক্রমে row lock করে, একটি kill হয়, আর transaction ফেইল করে — অথচ আপনার কোডে আসলে কোনো ভুল ছিল না।

DB::transaction()-এর দ্বিতীয় আর্গুমেন্ট হলো কতবার চেষ্টা করবে:

use Illuminate\Support\Facades\DB;

DB::transaction(function () {
    DB::table('users')->where('id', 1)->update(['votes' => 1]);
    DB::table('posts')->where('user_id', 1)->delete();
}, 5); // deadlock হলে সর্বোচ্চ ৫ বার চেষ্টা

Deadlock হলে লারাভেল exception ধরে closure আবার চালায়। ডিফল্ট মান ১ (মানে কোনো retry নেই):

// মেথড সিগনেচার — ডিফল্ট ১ বার (no retries)
public function transaction(Closure $callback, $attempts = 1) { /* ... */ }

এটা শুধু deadlock-জাতীয় exception ধরে, সব ফেইলিওর নয়। Query syntax error retry হবে না — আর সেটাই সঠিক।

টিপ: যেসব টেবিলে একসাথে অনেক write হয়, সেখানে সবসময় explicit attempt count দিন। এতে কোড পড়েই উদ্দেশ্য বোঝা যায়।

Docs: Handling Deadlocks

HTTP Requests

External API স্বভাবতই অনির্ভরযোগ্য — টাইমআউট হয়, rate limit লাগে, সার্ভার মাঝে মাঝে ডাউন হয়। লারাভেলের HTTP client-এ retry বিল্ট-ইন:

use Illuminate\Support\Facades\Http;

$response = Http::retry(3, 100)->post('https://api.example.com/data', [
    'name' => 'Alice',
    'role' => 'admin',
]);

সর্বোচ্চ ৩ বার চেষ্টা, প্রতিবারের মাঝে ১০০ মিলিসেকেন্ড বিরতি। এই বিরতিটা জরুরি — সার্ভার ওভারলোডেড থাকলে সাথে সাথে আবার হিট করলে অবস্থা আরও খারাপ হয়।

কোন ফেইলিওর retry করবো — নিজে ঠিক করুন

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;

$response = Http::retry(5, 200, function ($exception, $request) {
    return $exception instanceof ConnectionException
        || $exception instanceof RequestException;
})->get('https://api.example.com/resource');

Closure true রিটার্ন করলে retry হবে, false হলে থামবে। কারণ সব HTTP ফেইলিওর transient নয় — 422 Unprocessable Entity মানে আপনার ডেটাই ভুল, retry করে ঠিক হবে না।

বোনাস: প্রতিবার আলাদা বিরতি দিতে চাইলে array দিন — Http::retry([100, 200, 500])। প্রথম retry ১০০ms, পরেরটা ২০০ms, তারপর ৫০০ms।

Docs: HTTP Client Retries

Queued Jobs

Job ফেইল করে — downstream সার্ভিস অনুপলব্ধ, rate limit, নেটওয়ার্ক ড্রপ। লারাভেল প্রতিটি job-এর retry আচরণ আলাদাভাবে নিয়ন্ত্রণ করতে দেয়:

class ProcessOrder implements ShouldQueue
{
    use Queueable, InteractsWithQueue, SerializesModels;

    public $tries = 5;     // সর্বোচ্চ ৫ বার চেষ্টা
    public $backoff = 10;  // প্রতি চেষ্টার মাঝে ১০ সেকেন্ড

    public function handle()
    {
        // অর্ডার প্রসেস করুন!
    }

    public function failed(Throwable $e)
    {
        // সব চেষ্টা শেষ — এখন লগ, নোটিফাই বা ক্লিনআপ
        Log::error('Order processing failed permanently: ' . $e->getMessage());
    }
}
প্রপার্টি / মেথডকাজ
$triesসর্বোচ্চ কতবার চেষ্টা হবে
$backoffদুই চেষ্টার মাঝে কত সেকেন্ড অপেক্ষা
failed()সব চেষ্টা ব্যর্থ হলে একবার চলে

সময়ভিত্তিক Retry ও Backoff

কতবার নয়, কতক্ষণ — retryUntil()

অনেক সময় জানেন না কতবার চেষ্টা লাগবে, কিন্তু জানেন কতক্ষণ অপেক্ষা করতে রাজি:

public function retryUntil()
{
    return now()->addMinutes(10);
}

টাইমস্ট্যাম্প পার না হওয়া পর্যন্ত লারাভেল চেষ্টা চালিয়ে যাবে — যতবারই লাগুক। যেসব external সিস্টেমের রিকভারি টাইম অনিশ্চিত, তাদের জন্য দারুণ।

Exponential Backoff

High-traffic সিস্টেমে প্রতিবার বিরতি বাড়ানো ভালো। $backoff-এ array দিন:

public $backoff = [1, 5, 10, 30]; // ১ম retry ১s, ২য় ৫s, ৩য় ১০s, এরপর ৩০s

ডায়নামিক লজিক লাগলে প্রপার্টির বদলে tries() ও backoff() মেথড হিসেবেও ডিফাইন করা যায়।

Worker লেভেলে গ্লোবাল সেটিং

php artisan queue:work --tries=3

এই worker-এর সব job-এ সর্বোচ্চ ৩ বার চেষ্টা। তবে job-এ $tries ডিফাইন করা থাকলে সেটাই প্রাধান্য পাবে।

Docs: Max Job Attempts

ফেইলড Job ম্যানুয়ালি Retry

সব চেষ্টা শেষ করে যে job স্থায়ীভাবে ফেইল করে, লারাভেল সেটা failed_jobs টেবিলে রেখে দেয়। পরে Artisan দিয়ে আবার চালানো যায়:

# নির্দিষ্ট একটি job
php artisan queue:retry {id}

# একটি queue-র সব ফেইলড job
php artisan queue:retry --queue=emails

# সবকিছু
php artisan queue:retry all

রাত ২টায় আপনার SMS প্রোভাইডার ৩০ মিনিট ডাউন ছিল। সকালে এসে দেখলেন ২০০টা OTP job ফেইল করেছে। প্রোভাইডার এখন ঠিক আছে — শুধু php artisan queue:retry --queue=sms চালান, সব আবার প্রসেস হবে।

কী কী ফেইল করেছে দেখতে php artisan queue:failed চালান।

Docs: Retrying Failed Jobs

retry() Helper

উপরের সবকিছু নির্দিষ্ট সাবসিস্টেমের জন্য। কিন্তু অন্য কোথাও retry লাগলে? লারাভেলের retry() helper যেকোনো জায়গায় কাজ করে:

$result = retry(5, function () {
    return SomeExternalService::fetch('resource-id');
}, 150); // ৫ বার চেষ্টা, মাঝে ১৫০ms

Closure সফল হলে তার রিটার্ন ভ্যালু পাবেন। সব চেষ্টা ব্যর্থ হলে শেষ exception-টি আবার throw হবে।

এটাই প্যাটার্নের সবচেয়ে সাধারণ রূপ। File lock, cache write, থার্ড-পার্টি SDK কল — যা-ই সাময়িকভাবে ফেইল করতে পারে, retry() দিয়ে মুড়ে দিন। ফ্রেমওয়ার্কের আলাদা সাপোর্ট লাগবে না।

আরও নিয়ন্ত্রণ

retry(
    3,
    fn () => $sdk->upload($file),
    fn (int $attempt) => $attempt * 100,          // প্রতিবার বিরতি বাড়বে
    fn (Throwable $e) => $e instanceof TimeoutException // শুধু timeout হলে retry
);
Docs: retry()

যা মাথায় রাখবেন

Retry লজিক ভেবেচিন্তে প্রয়োগ করতে হয়।

কোনটা transient চিনুন

HTTP-তে closure দিয়ে exception ফিল্টার করুন। Job-এ ভাবুন — এই ফেইলিওর কি আরেকবার চেষ্টায় আসলেই ঠিক হবে?

বিরতি দিন

চাপে থাকা সার্ভিসকে সাথে সাথে retry করলে চাপ বাড়ে। ছোট বিরতিও রিকভারির সময় দেয়। হাই ট্রাফিকে exponential backoff।

লিমিট বুঝে সেট করুন

৫ বার × ২০০ms = রিকোয়েস্টে বাড়তি ~১ সেকেন্ড। ৫ বার × ৩০s backoff = job কয়েক মিনিট চলতে পারে। UX-এর সাথে মেলান।

ব্যর্থ হলে প্ল্যান রাখুন

Job-এ failed(), HTTP-তে retry চেইনের পর response চেক। নীরব ফেইলিওর জমতে দেবেন না।

Retry-After মানুন

সার্ভিস Retry-After হেডার দিলে বা rate limit থাকলে সেই অনুযায়ী delay দিন। অন্ধভাবে retry করলে কোটা দ্রুত শেষ হয়।

মনিটর করুন

কতবার retry হচ্ছে, কতবার সফল — ট্র্যাক করুন। অতিরিক্ত retry মানে অস্থির dependency বা ভুল কনফিগারেশন।

Idempotency: যে কাজ retry হবে, সেটা দুবার চললে যেন ক্ষতি না হয় (যেমন দুবার চার্জ কাটা)। পেমেন্ট API-তে idempotency key ব্যবহার করুন।

পুরো লারাভেলে Retry

Retry ফ্রেমওয়ার্কে অন্তত চারটি আলাদা জায়গায় আছে, প্রতিটি নিজের প্রেক্ষাপটে মানানসই:

কোথায়APIবিশেষত্ব
DB TransactionDB::transaction($fn, 5)শুধু deadlock ধরে
HTTP ClientHttp::retry(3, 100, $when)Delay ও ফিল্টারিং
Queue Job$tries, $backoff, retryUntil()সময়ভিত্তিক উইন্ডো, failed() hook
Artisanqueue:retryস্থায়ীভাবে ফেইলড job আবার চালানো
যেকোনো কোডretry($times, $fn, $sleep)সবচেয়ে সাধারণ রূপ

মূল কথা

মূল আইডিয়া হুবহু এক। কনসেপ্টটা একবার শিখুন — সব জায়গায় চিনতে পারবেন।

সারসংক্ষেপ ও যাচাই

  • Retry শুধু transient ফেইলিওরের জন্য; permanent ফেইলিওর retry করবেন না।
  • DB::transaction()-এর ডিফল্ট attempt ১ — explicit count দিন।
  • HTTP-তে closure দিয়ে ঠিক করুন কোন exception retry যোগ্য।
  • Job-এ $tries, $backoff, retryUntil() ও failed() একসাথে ভাবুন।
  • যেকোনো কোডের জন্য retry() helper।

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

DB::transaction($fn) — deadlock হলে কতবার retry হবে?

একবারও না। ডিফল্ট $attempts = 1, মানে শুধু প্রথম চেষ্টা।

HTTP 422 পেলে retry করা উচিত?

না। 422 মানে রিকোয়েস্ট ডেটা ভুল — এটা permanent failure। closure দিয়ে এটা বাদ দিন।

$tries আর retryUntil()-এর পার্থক্য কী?

$tries চেষ্টার সংখ্যা সীমিত করে; retryUntil() সময় সীমিত করে — সেই সময় পর্যন্ত যতবার লাগে চেষ্টা চলবে।

৫ বার retry, ২০০ms delay — সবচেয়ে খারাপ ক্ষেত্রে কত বাড়তি সময় লাগবে?

মোটামুটি ৪টি বিরতি × ২০০ms ≈ ০.৮–১ সেকেন্ড, সাথে প্রতিটি রিকোয়েস্টের নিজের সময়।