Популярні бібліотеки

Відмовостійкість з Polly в ASP.NET Core

Глибокий огляд Polly: Retry, Circuit Breaker, Timeout, Fallback, Hedging. Інтеграція з HttpClient та Resilience Pipelines у .NET 8+.

Відмовостійкість з Polly в ASP.NET Core

Ваш додаток викликає зовнішнє API оплати. Раптово API відповідає із затримкою 30 секунд або взагалі не відповідає. Без захисного механізму ваш додаток «замерзає», накопичує відкриті з'єднання і в результаті падає. Polly — це бібліотека відмовостійкості, що реалізує принцип «очікуй відмов і дій відповідно».

1. Що таке Resilience (відмовостійкість)?

Resilience (відмовостійкість) — здатність системи продовжувати функціонувати або коректно деградувати під впливом збоїв: мережевих помилок, тайм-аутів, перевантаження зовнішніх сервісів.

Без резильєнтності навіть тимчасова недоступність зовнішнього сервісу може призвести до каскадного збою всього додатку. Polly вирішує цю проблему через набір resilience strategies (стратегій відмовостійкості):

Retry

Повторює запит при тимчасових збоях. Ідеально для мережевих затримок.

Circuit Breaker

«Вимикач»: після N збоїв підряд припиняє спроби на певний час.

Timeout

Скасовує запит, якщо він виконується довше заданого ліміту.

Fallback

Запасний план: повертає дефолтне значення при збої основного.

Rate Limiter

Обмежує кількість виклчів за одиницю часу для захисту downstream.

Hedging

Паралельно надсилає кілька запитів, повертає перший успішний.

2. Polly у .NET 8+: Resilience Pipelines

Починаючи з .NET 8, Microsoft разом з командою Polly додали підтримку резильєнтності безпосередньо у framework через Microsoft.Extensions.Http.Resilience. Це об'єднує Polly v8 з DI та HttpClient.

Встановлення

dotnet add package Microsoft.Extensions.Http.Resilience
dotnet add package Polly.Core

Вбудований Standard Resilience Handler

Найшвидший спосіб додати резильєнтність — AddStandardResilienceHandler():

Program.cs — Standard Resilience Handler
builder.Services.AddHttpClient<IPaymentGateway, PaymentGateway>(client =>
{
    client.BaseAddress = new Uri("https://payment-api.example.com");
    client.Timeout     = TimeSpan.FromSeconds(30);
})
.AddStandardResilienceHandler();  // Вмикає: Retry + Circuit Breaker + Timeout

AddStandardResilienceHandler() автоматично налаштовує:

  • Retry: 3 спроби з exponential backoff
  • Circuit Breaker: відкривається після 10% помилок протягом 30 секунд
  • Timeout на спробу: 10 секунд на кожну спробу
  • Загальний Timeout: 30 секунд на весь ланцюжок

3. Retry: Повтор при збоях

Концепція Retry

Retry (повтор) — найпростіша стратегія: якщо запит завершився збоєм, спробуємо ще раз. Логіка: більшість мережевих проблем — тимчасові (transient faults).

Program.cs — налаштування Retry вручну
using Polly;
using Polly.Retry;

builder.Services.AddHttpClient<IWeatherService, WeatherService>()
    .AddResilienceHandler("weather-retry", pipelineBuilder =>
    {
        pipelineBuilder.AddRetry(new RetryStrategyOptions<HttpResponseMessage>
        {
            // Максимальна кількість повторів (не рахуючи перший запит)
            MaxRetryAttempts = 3,

            // Базова затримка між спробами (exponential: 1s, 2s, 4s)
            Delay = TimeSpan.FromSeconds(1),
            BackoffType = DelayBackoffType.Exponential,

            // Додаємо випадковий «шум» (jitter) щоб уникнути «thundering herd»
            // Усі клієнти одночасно не ретраються у той самий момент
            UseJitter = true,

            // Які помилки вважати «ретраблями»
            ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
                .Handle<HttpRequestException>()
                .Handle<TimeoutRejectedException>()
                .HandleResult(r => r.StatusCode == HttpStatusCode.ServiceUnavailable)
                .HandleResult(r => r.StatusCode == HttpStatusCode.TooManyRequests),

            // Колбек при кожній спробі
            OnRetry = static args =>
            {
                Console.WriteLine(
                    $"Retry #{args.AttemptNumber}. Delay: {args.RetryDelay.TotalSeconds:F1}s");
                return ValueTask.CompletedTask;
            }
        });
    });

Exponential Backoff + Jitter

Проста стратегія «retry відразу» може погіршити ситуацію: якщо 1000 клієнтів одночасно отримали помилку і одночасно ретраються — сервер отримує навантаження в 1000 разів більше. Рішення:

  • Exponential backoff: кожна наступна спроба чекає вдвічі довше (1с → 2с → 4с → 8с).
  • Jitter: додаємо випадкову величину до затримки (1.3с → 1.7с → 4.2с → 7.8с).
Loading diagram...
graph LR
    A["Запит"] --> B["1-а спроба"]
    B -->|"Збій"| C["Чекаємо 1.3s"]
    C --> D["2-а спроба"]
    D -->|"Збій"| E["Чекаємо 2.7s"]
    E --> F["3-я спроба"]
    F -->|"Успіх"| G["Повертаємо результат"]

    style A fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style G fill:#22c55e,stroke:#16a34a,color:#ffffff
    style C fill:#f59e0b,stroke:#b45309,color:#ffffff
    style E fill:#f59e0b,stroke:#b45309,color:#ffffff

4. Circuit Breaker: Запобіжник

Аналогія з електрикою

Circuit Breaker (автоматичний вимикач) — це «запобіжник» для HTTP-запитів. В електриці: якщо виникає коротке замикання — запобіжник розриває ланцюг, захищаючи пристрої. В мікросервісах: якщо downstream-сервіс стабільно падає — припиняємо надсилати запити, даємо йому «відпочити».

Стани Circuit Breaker

Loading diagram...
stateDiagram-v2
    [*] --> Closed: Початковий стан
    Closed --> Open: Failure rate > threshold
    Open --> HalfOpen: Після break duration
    HalfOpen --> Closed: Пробний запит успішний
    HalfOpen --> Open: Пробний запит невдалий
    
    Closed: 🟢 Closed<br/>Запити проходять
    Open: 🔴 Open<br/>Запити відхиляються одразу
    HalfOpen: 🟡 Half-Open<br/>Один пробний запит
  • Closed (закритий): нормальний стан. Запити проходять.
  • Open (відкритий): сервіс недоступний. Запити відхиляються одразу без мережевого виклику. Це захищає downstream.
  • Half-Open (напіввідкритий): через певний час пропускаємо один «пробний» запит. Якщо успішний — переходимо в Closed. Если ні — назад в Open.
Налаштування Circuit Breaker
pipelineBuilder.AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
{
    // Мінімальна кількість запитів для розрахунку відсотка помилок
    MinimumThroughput = 10,

    // Якщо більше 50% запитів за останні 30 секунд завершились збоєм — відкриваємо
    FailureRatio = 0.5,

    // Вікно спостереження (sampling window)
    SamplingDuration = TimeSpan.FromSeconds(30),

    // Скільки часу тримати circuit breaker відкритим
    BreakDuration = TimeSpan.FromSeconds(15),

    ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
        .Handle<HttpRequestException>()
        .HandleResult(r => r.StatusCode >= HttpStatusCode.InternalServerError),

    OnOpened = static args =>
    {
        Console.WriteLine(
            $"⚡ Circuit Breaker відкрито! Причина: {args.Outcome.Exception?.Message}");
        return ValueTask.CompletedTask;
    },

    OnClosed = static _ =>
    {
        Console.WriteLine("✅ Circuit Breaker закрито — сервіс відновився.");
        return ValueTask.CompletedTask;
    }
});

Коли Circuit Breaker відкритий — помилка BrokenCircuitException

Обробка BrokenCircuitException або BrokenCircuit
try
{
    var result = await _httpClient.GetAsync("/api/products");
    // ...
}
catch (BrokenCircuitException)
{
    // Circuit Breaker відкритий — повертаємо cached або дефолтне значення
    return _cache.Get<List<Product>>("products") ?? [];
}

5. Timeout та Fallback

Timeout

Налаштування Timeout
pipelineBuilder.AddTimeout(new TimeoutStrategyOptions
{
    // Якщо після 5 секунд немає відповіді — скасовуємо
    Timeout = TimeSpan.FromSeconds(5),

    OnTimeout = static args =>
    {
        Console.WriteLine(
            $"⏱ Timeout! Операція тривала більше {args.Timeout.TotalSeconds}s");
        return ValueTask.CompletedTask;
    }
});

Fallback: Запасний план

Fallback (запасний план) — якщо основна операція не вдалася, повертаємо заздалегідь підготований результат:

Fallback стратегія
pipelineBuilder.AddFallback(new FallbackStrategyOptions<HttpResponseMessage>
{
    ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
        .Handle<TimeoutRejectedException>()
        .Handle<BrokenCircuitException>()
        .Handle<HttpRequestException>(),

    // Що повертати при збої
    FallbackAction = static args =>
    {
        var fallbackResponse = new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent(
                """{"products": [], "source": "cache", "degraded": true}""",
                Encoding.UTF8, "application/json")
        };
        return ValueTask.FromResult(fallbackResponse);
    },

    OnFallback = static args =>
    {
        Console.WriteLine(
            $"⚠️ Fallback активовано. Причина: {args.Outcome.Exception?.Message}");
        return ValueTask.CompletedTask;
    }
});

6. Повна Resilience Pipeline

Стратегії можна об'єднувати в один pipeline. Порядок важливий — виконуються зовні досередини:

Program.cs — повна Resilience Pipeline
builder.Services.AddHttpClient<IPaymentGateway, PaymentGateway>(client =>
    client.BaseAddress = new Uri("https://payment-api.example.com"))
.AddResilienceHandler("payment-pipeline", pipeline =>
{
    // 1. Fallback (зовнішній — перший що спрацює при будь-якій помилці)
    pipeline.AddFallback(new FallbackStrategyOptions<HttpResponseMessage>
    {
        ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
            .Handle<Exception>(),
        FallbackAction = _ =>
        {
            var r = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
            {
                Content = new StringContent("""{"error": "Payment service unavailable"}""")
            };
            return ValueTask.FromResult(r);
        }
    });

    // 2. Circuit Breaker
    pipeline.AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
    {
        FailureRatio      = 0.5,
        MinimumThroughput = 5,
        SamplingDuration  = TimeSpan.FromSeconds(10),
        BreakDuration     = TimeSpan.FromSeconds(30)
    });

    // 3. Retry (внутрішній — виконується до Circuit Breaker)
    pipeline.AddRetry(new RetryStrategyOptions<HttpResponseMessage>
    {
        MaxRetryAttempts = 3,
        Delay            = TimeSpan.FromMilliseconds(500),
        BackoffType      = DelayBackoffType.Exponential,
        UseJitter        = true,
        ShouldHandle     = new PredicateBuilder<HttpResponseMessage>()
            .Handle<HttpRequestException>()
            .HandleResult(r => r.StatusCode == HttpStatusCode.ServiceUnavailable)
    });

    // 4. Timeout на кожну спробу (найвнутрішніший)
    pipeline.AddTimeout(TimeSpan.FromSeconds(5));
});

При запиті порядок виконання: Fallback → CircuitBreaker → Retry → Timeout → Запит → Retry → CircuitBreaker → Fallback. Тобто Timeout застосовується до кожної спроби в Retry, а Circuit Breaker рахує загальну кількість помилок.


7. ResiliencePipelineProvider: Ручне використання

Для не-HTTP коду (виклики до Redis, файлової системи) використовуйте ResiliencePipelineProvider<TKey>:

Services/ResilientRedisService.cs
using Polly;
using Polly.Registry;

public class ResilientRedisService
{
    private readonly ResiliencePipeline _pipeline;
    private readonly IConnectionMultiplexer _redis;

    public ResilientRedisService(
        ResiliencePipelineProvider<string> pipelineProvider,
        IConnectionMultiplexer redis)
    {
        _pipeline = pipelineProvider.GetPipeline("redis-pipeline");
        _redis    = redis;
    }

    public async Task<string?> GetAsync(string key)
    {
        return await _pipeline.ExecuteAsync(async ct =>
        {
            var db    = _redis.GetDatabase();
            var value = await db.StringGetAsync(key);
            return (string?)value;
        });
    }
}
Program.cs — реєстрація кастомного Pipeline
builder.Services.AddResiliencePipeline("redis-pipeline", (pipelineBuilder, _) =>
{
    pipelineBuilder
        .AddRetry(new RetryStrategyOptions
        {
            MaxRetryAttempts  = 3,
            Delay             = TimeSpan.FromMilliseconds(100),
            BackoffType       = DelayBackoffType.Exponential,
            ShouldHandle      = new PredicateBuilder()
                .Handle<RedisException>()
                .Handle<TimeoutException>()
        })
        .AddTimeout(TimeSpan.FromSeconds(2));
});

Практичні завдання


Резюме

Polly перетворює ваш код із «наївного» на «захищений»:

Retry

Тимчасові збої — це норма мережевого середовища. Exponential + Jitter запобігає «thundering herd».

Circuit Breaker

Не тривожить нездоровий сервіс. Дає йому час відновитися. Захищає від каскадних збоїв.

.NET 8 Integration

AddStandardResilienceHandler() — готова до виробництва конфігурація одним рядком.

Composable

Стратегії складаються у pipeline. Кожна шар захищає від свого типу збоїв.

Посилання: