فراخوانی درگاه پرداخت ناپایدار: Timeout، Retry و Circuit Breaker در دات‌نت

پاسخ کوتاه: برای هر فراخوانی درگاه یک timeout کوتاه‌تر از حوصله کاربر بگذارید، فقط عملیاتی را تکرار کنید که تکرارش امن است (استعلام، verify، برگشت وجه با شناسه)، جلوی درگاه یک circuit breaker بگذارید تا قطعی سریع شکست بخورد، و timeout روی charge را یک نتیجه نامعلوم بدانید که با استعلام روشن می‌شود، نه با تلاش مجدد کورکورانه.

یک مثال واقعی: کسر دوباره وجه

کاربر «پرداخت» را می‌زند. سرور endpoint کسر وجه درگاه را صدا می‌زند. درگاه پول را کم می‌کند ولی پاسخش کند است، timeout پیش‌فرض HttpClient صد ثانیه است، reverse proxy بعد از ۳۰ ثانیه قطع می‌کند و یک سیاست retry خوش‌نیت charge را دوباره می‌فرستد. پول کاربر دو بار کم می‌شود و اپ «پرداخت ناموفق» نشان می‌دهد.

سه اشتباه روی هم افتاده: timeout آگاهانه وجود ندارد، عملیات غیر idempotent تکرار شده، و «جوابی نگرفتم» با «شکست خورد» یکی فرض شده.

قواعد فراخوانی درگاه پرداخت

  1. Timeout اجباری است. برای هر تلاش ۲ تا ۵ ثانیه و برای کل عملیات ۱۰ تا ۱۵ ثانیه.
  2. هر endpoint را دسته‌بندی کنید. استعلام و verify تکرارپذیرند. charge نیست، مگر شناسه idempotency بفرستید که درگاه به آن احترام بگذارد.
  3. Retry با backoff و jitter، و فقط برای خطاهای گذرا: خطای شبکه، timeout، 408، 429 و 5xx.
  4. مدار را قطع کنید وقتی بیشتر فراخوانی‌ها شکست می‌خورند، تا درگاه از کار افتاده را بمباران نکنید و سریع با پیام روشن شکست بخورید.
  5. نامعلوم یک وضعیت است. charge‌ای که timeout خورده PendingVerification می‌شود و یک job از درگاه می‌پرسد چه اتفاقی افتاده.

کد قابل اجرا

با پکیج Microsoft.Extensions.Http.Resilience (که زیرش Polly 8 است):

using Microsoft.Extensions.Http.Resilience;
using Polly;

builder.Services.AddHttpClient<GatewayClient>(c =>
    {
        c.BaseAddress = new Uri("https://gateway.example.com/");
        c.Timeout = Timeout.InfiniteTimeSpan; // timeoutها دست pipeline است
    })
    .AddResilienceHandler("gateway", pipeline =>
    {
        pipeline.AddTimeout(TimeSpan.FromSeconds(12)); // کل عملیات

        var retry = new HttpRetryStrategyOptions
        {
            MaxRetryAttempts = 3,
            BackoffType = DelayBackoffType.Exponential,
            UseJitter = true,
            Delay = TimeSpan.FromMilliseconds(400),
        };
        retry.DisableForUnsafeHttpMethods(); // POST/PUT/PATCH/DELETE هرگز تکرار نمی‌شوند
        pipeline.AddRetry(retry);

        pipeline.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
        {
            FailureRatio = 0.5,
            MinimumThroughput = 10,
            SamplingDuration = TimeSpan.FromSeconds(30),
            BreakDuration = TimeSpan.FromSeconds(20),
        });

        pipeline.AddTimeout(TimeSpan.FromSeconds(3)); // هر تلاش
    });

ShouldHandle پیش‌فرض HttpRetryStrategyOptions خطای شبکه، timeout، 408، 429 و 5xx را گذرا حساب می‌کند. DisableForUnsafeHttpMethods یعنی charge (که POST است) فقط یک بار انجام می‌شود ولی استعلام (GET) retry دارد.

مدیریت نتیجه نامعلوم:

public async Task<PaymentResult> ChargeAsync(Payment payment, CancellationToken ct)
{
    try
    {
        var response = await gateway.ChargeAsync(payment.Reference, payment.AmountRials, ct);
        return payment.Apply(response);
    }
    catch (BrokenCircuitException)
    {
        return PaymentResult.Unavailable("درگاه بانک در دسترس نیست. مبلغی کسر نشد.");
    }
    catch (Exception ex) when (ex is TimeoutRejectedException or HttpRequestException)
    {
        payment.MarkPendingVerification();      // هنوز نمی‌دانیم
        await db.SaveChangesAsync(ct);
        return PaymentResult.Pending("در حال استعلام پرداخت شما از بانک هستیم.");
    }
}

بعد یک job پس‌زمینه برای هر پرداخت PendingVerification endpoint استعلام درگاه را صدا می‌زند و آن را موفق یا ناموفق می‌کند. مغایرت‌گیری شبانه هم هر چیزی را که باز هم جا بماند پیدا می‌کند.

مزایا و معایب

مزایا

  • کاربر زمان محدود و قابل پیش‌بینی منتظر می‌ماند، نه صد ثانیه.
  • قطعی درگاه به پیام خطای سریع و صادقانه تبدیل می‌شود، نه انبوه threadهای معطل.
  • کسر دوباره وجه به خاطر retry اتفاق نمی‌افتد، چون فراخوانی‌های ناامن تکرار نمی‌شوند.

معایب

  • وضعیت‌های بیشتر در مدل پرداخت (PendingVerification) و یک job برای روشن کردنشان.
  • breaker بد تنظیم‌شده با یک اختلال کوتاه باز می‌شود؛ متریک‌ها را ببینید و تنظیمش کنید.
  • retry بار درگاهِ در حال مشکل را چند برابر می‌کند؛ تعداد را کم نگه دارید و همیشه jitter بگذارید.

درگاه بانکی یا API پرداخت شخص ثالث را یکپارچه می‌کنید و می‌خواهید امن شکست بخورد؟ ببینید چطور می‌توانم کمک کنم.

پرسش‌های متداول

درخواست کسر وجه (charge) را دوباره تلاش کنم؟

فقط اگر درگاه کلید یا شناسه idempotency پشتیبانی کند که تکرار charge را بی‌اثر کند و در هر تلاش همان شناسه را بفرستید. در غیر این صورت هرگز charge را تکرار نکنید؛ بعد از timeout وضعیت همان شناسه را از درگاه استعلام کنید.

فرق دو timeout در کد چیست؟

timeout بیرونی کل عملیات را محدود می‌کند، شامل همه تلاش‌ها و تأخیرها، تا کاربر بیشتر از سقفی که تعیین کرده‌اید منتظر نماند. timeout درونی هر تلاش را محدود می‌کند تا یک اتصال معلق کل بودجه را نخورد.

AddStandardResilienceHandler کافی نیست؟

برای فراخوانی‌های خواندنی پیش‌فرض خوبی است: rate limiter، timeout کل، retry، circuit breaker و timeout هر تلاش را اضافه می‌کند. برای پرداخت هنوز باید retry را برای متدهای ناامن خاموش کنید و حالت «نتیجه نامعلوم» را خودتان مدیریت کنید.