Перейти к содержанию
V1 V2

Отказоустойчивость

Модуль для построения отказоустойчивого приложения с помощью таких механизмов, как CircuitBreaker, Retry, Timeout, RateLimiter и Fallback.

Каждый механизм, кроме Fallback, описывается интерфейсом-спецификацией — типизированным контрактом, который указывает на путь в конфигурации. Аннотация на защищаемом методе ссылается на этот интерфейс, поэтому связь метода с его настройками отказоустойчивости проверяет компилятор, а не строковое совпадение имён.

ResilientModule объединяет CircuitBreakerModule, RetryModule, TimeoutModule, FallbackModule и RateLimiterModule.

Пошаговое введение перед справочником — в разделе Отказоустойчивость.

Подключение

Зависимость build.gradle:

implementation "io.koraframework:resilient-kora"

Модуль:

@KoraApp
public interface Application extends ResilientModule { }

Зависимость build.gradle.kts:

implementation("io.koraframework:resilient-kora")

Модуль:

@KoraApp
interface Application : ResilientModule

Обработчик аннотаций (annotation-processors) либо KSP-обработчик (symbol-processors) обязателен: он одновременно генерирует реализации спецификаций и применяет аспекты.

Распределённое состояние

По умолчанию каждый ограничитель частоты и бюджет повторов хранит своё состояние в памяти одного экземпляра приложения. Распределённый rate limiter и распределённый бюджет повторов делят это состояние между экземплярами через Redis. Для них нужен дополнительный модуль на клиенте Lettuce:

Зависимость build.gradle:

implementation "io.koraframework:resilient-kora-distributed-redis-lettuce"

Модуль:

@KoraApp
public interface Application extends ResilientModule, LettuceDistributedResilientModule { }

Зависимость build.gradle.kts:

implementation("io.koraframework:resilient-kora-distributed-redis-lettuce")

Модуль:

@KoraApp
interface Application : ResilientModule, LettuceDistributedResilientModule

Артефакт подтягивает resilient-kora-distributed (независимые от хранилища контракты DistributedRateLimiterClient и DistributedRetryBudgetClient, алгоритмы и @RateLimiterDistributedSpec) и redis-lettuce. LettuceDistributedResilientModule расширяет LettuceModule, поэтому подключение настраивается в секции lettuce так же, как для кеша Redis. Поддерживаются и одиночный, и кластерный клиент, а все операции выполняются обычными командами Redis без серверных Lua-скриптов.

Чтобы выделить компонентам отказоустойчивости собственный клиент Redis вместо общего, зарегистрируйте AbstractRedisClient с тегом @Tag(LettuceDistributedResilientModule.class).

Спецификации

Спецификация — это интерфейс, который наследует контракт отказоустойчивости и помечен аннотацией с путём конфигурации:

Аннотация метода Аннотация спецификации Контракт, который наследует интерфейс Пакет
@CircuitBreakable @CircuitBreakerSpec CircuitBreaker io.koraframework.resilient.circuitbreaker
@Retryable @RetrySpec Retry io.koraframework.resilient.retry
@Timeout @TimeoutSpec Timeouter io.koraframework.resilient.timeout
@RateLimited @RateLimiterSpec RateLimiter io.koraframework.resilient.ratelimiter
@Fallback — — io.koraframework.resilient.fallback.annotation

Аннотации методов лежат в подпакете annotation рядом с контрактом, например io.koraframework.resilient.circuitbreaker.annotation.CircuitBreakable.

@RateLimited также принимает интерфейс с аннотацией @RateLimiterDistributedSpec (io.koraframework.resilient.distributed.ratelimiter.annotation), лимит которого общий для всех экземпляров — см. Распределённый rate limiter.

@CircuitBreakerSpec("resilient.circuitbreaker.pet") //(1)!
public interface PetCircuitBreaker extends CircuitBreaker { }

@RetrySpec("resilient.retry.pet")
public interface PetRetry extends Retry { }

@TimeoutSpec("resilient.timeout.pet")
public interface PetTimeouter extends Timeouter { }

@Component
public class PetService {

    @CircuitBreakable(PetCircuitBreaker.class) //(2)!
    @Retryable(PetRetry.class)
    @Timeout(PetTimeouter.class)
    public Optional<Pet> findById(long id) {
        return petRepository.findById(id);
    }
}
  1. Полный путь секции конфигурации, которая описывает этот экземпляр.
  2. Аспект связывается с типом спецификации, а не со строковым именем.
@CircuitBreakerSpec("resilient.circuitbreaker.pet") //(1)!
interface PetCircuitBreaker : CircuitBreaker

@RetrySpec("resilient.retry.pet")
interface PetRetry : Retry

@TimeoutSpec("resilient.timeout.pet")
interface PetTimeouter : Timeouter

@Component
open class PetService {

    @CircuitBreakable(PetCircuitBreaker::class) //(2)!
    @Retryable(PetRetry::class)
    @Timeout(PetTimeouter::class)
    open fun findById(id: Long): Pet? = petRepository.findById(id)
}
  1. Полный путь секции конфигурации, которая описывает этот экземпляр.
  2. Аспект связывается с типом спецификации, а не со строковым именем.

Что обработчик делает со спецификацией:

  • генерирует её реализацию и модуль, который её публикует; модуль подхватывается @KoraApp автоматически — вручную ничего подключать не нужно;
  • публикует сам интерфейс спецификации как компонент графа приложения, поэтому его можно внедрить для императивного использования;
  • читает конфигурацию ровно из того пути, который указан в аннотации.

Сгенерированные реализации наследуют toString(), который выводит текущее состояние — состояние прерывателя и его счётчики, оставшиеся разрешения локального rate limiter, настройки повтора и токены бюджета повторов, — поэтому по записи внедрённой спецификации в лог или по её виду в отладчике видно, что она делает прямо сейчас.

Один экземпляр на спецификацию

Все методы, помеченные одной и той же спецификацией, разделяют один экземпляр, а значит одно состояние и один набор метрик. Если два метода не должны влиять на состояние circuit breaker друг друга, им нужны два интерфейса-спецификации, указывающих на две разные секции конфигурации.

Путь конфигурации абсолютный и ни с чем не объединяется

Путь в аннотации — это полный путь до секции. Секции default, от которой наследуется именованная секция, больше нет: все обязательные значения должны присутствовать именно по этому пути. Подойдёт любой путь, в том числе вне префикса resilient — @CircuitBreakerSpec("payment") читает корневую секцию payment.

Типичные ошибки компиляции:

  • @CircuitBreakerSpec can only be applied to an interface — аннотация поставлена на класс или запись.
  • @CircuitBreakerSpec annotated interface 'X' must extend io.koraframework.resilient.circuitbreaker.CircuitBreaker — интерфейс не наследует контракт.
  • config path can't be blank — в значении аннотации пустая строка.
  • @CircuitBreakable on 'X#y()' references an invalid resilient component type — класс, переданный в аннотацию метода, не реализует ожидаемый контракт.

CircuitBreaker

CircuitBreaker — это прокси, который управляет потоком запросов к конкретному методу и может временно запретить его выполнение, если метод бросает много исключений, подходящих под настроенный фильтр.

Смысл применения CircuitBreaker в том, чтобы дать системе время исправить ошибку, вызвавшую сбой, прежде чем позволить приложению повторить операцию. Шаблон CircuitBreaker обеспечивает стабильность на время восстановления системы после сбоя и снижает влияние на производительность. CircuitBreaker может находиться в одном из состояний: CLOSED, OPEN, HALF_OPEN.

  • CLOSED: запрос приложения передаётся в защищаемую операцию. Прокси считает недавние отказы в пределах настроенного количества операций (countBased.windowSize), проходящих через него, и увеличивает счётчик, когда операция завершается неуспешно. Если число запросов превысило минимально необходимое для расчёта (minimumRequiredCalls), а доля недавних отказов превысила настроенный порог (failureRateThreshold), прокси переходит в OPEN.
  • OPEN: в этом состоянии запрос приложения немедленно завершается ошибкой, и приложению возвращается исключение. В этот момент прокси запускает таймер ожидания (waitDurationInOpenState), по истечении которого переходит в HALF_OPEN.
  • HALF_OPEN: ограниченному числу запросов (permittedCallsInHalfOpenState) разрешается пройти и вызвать операцию. Если эти запросы успешны, считается, что ошибка, ранее вызвавшая сбой, устранена, и CircuitBreaker переходит в состояние CLOSED (счётчик отказов сбрасывается). Если хотя бы один запрос завершается отказом, CircuitBreaker считает, что неисправность сохраняется, возвращается в состояние OPEN и перезапускает таймер ожидания (waitDurationInOpenState), давая системе дополнительное время на восстановление.

Состояние HALF_OPEN помогает избежать лавинообразного роста запросов к сервису: после начала восстановления сервис какое-то время может выдерживать лишь ограниченное число запросов.

Изначально находится в состоянии CLOSED.

Декларативное использование

Если CircuitBreaker находится в состоянии OPEN, вызов завершается исключением CallNotPermittedException.

@CircuitBreakerSpec("resilient.circuitbreaker.custom")
public interface CustomCircuitBreaker extends CircuitBreaker { }

@Component
public class SomeService {

    @CircuitBreakable(CustomCircuitBreaker.class)
    public String getValue() {
        throw new IllegalStateException("Ops");
    }
}
@CircuitBreakerSpec("resilient.circuitbreaker.custom")
interface CustomCircuitBreaker : CircuitBreaker

@Component
open class SomeService {

    @CircuitBreakable(CustomCircuitBreaker::class)
    open fun value(): String = throw IllegalStateException("Ops")
}

Конфигурация

Секция, на которую указывает @CircuitBreakerSpec, описана в классе CircuitBreakerConfig:

resilient {
    circuitbreaker {
        custom {
            type = STRIPED_APPROX //(1)!
            failureRateThreshold = 50 //(2)!
            minimumRequiredCalls = 10 //(3)!
            waitDurationInOpenState = "25s" //(4)!
            permittedCallsInHalfOpenState = 15 //(5)!
            enabled = true //(6)!
            countBased {
                windowSize = 100 //(7)!
                stripedApprox {
                    stripes = 16 //(8)!
                }
            }
        }
    }
}
  1. Реализация окна вызовов: STRIPED_APPROX, FIXED_WINDOW, RING_BUFFER или TIME_BASED (по умолчанию: STRIPED_APPROX).
  2. Процент неуспешных запросов, необходимый для перехода в OPEN; значение должно быть от 1 до 100 (обязательное, без значения по умолчанию).
  3. Минимальное число запросов, необходимое для начала расчёта состояния (обязательное, без значения по умолчанию).
  4. Время ожидания в OPEN, по истечении которого выполняется переход в HALF_OPEN (обязательное, без значения по умолчанию).
  5. Число запросов в HALF_OPEN, которые должны завершиться успешно для перехода в CLOSED (обязательное, без значения по умолчанию).
  6. Включение или отключение CircuitBreaker (по умолчанию: true).
  7. Максимальное число запросов, используемых для расчёта failureRateThreshold и определения состояния (обязательное для всех типов, кроме TIME_BASED, без значения по умолчанию).
  8. Количество независимых полос счётчиков, от 1 до 64; используется только реализацией STRIPED_APPROX (по умолчанию: 16).
resilient:
  circuitbreaker:
    custom:
      type: STRIPED_APPROX #(1)!
      failureRateThreshold: 50 #(2)!
      minimumRequiredCalls: 10 #(3)!
      waitDurationInOpenState: "25s" #(4)!
      permittedCallsInHalfOpenState: 15 #(5)!
      enabled: true #(6)!
      countBased:
        windowSize: 100 #(7)!
        stripedApprox:
          stripes: 16 #(8)!
  1. Реализация окна вызовов: STRIPED_APPROX, FIXED_WINDOW, RING_BUFFER или TIME_BASED (по умолчанию: STRIPED_APPROX).
  2. Процент неуспешных запросов, необходимый для перехода в OPEN; значение должно быть от 1 до 100 (обязательное, без значения по умолчанию).
  3. Минимальное число запросов, необходимое для начала расчёта состояния (обязательное, без значения по умолчанию).
  4. Время ожидания в OPEN, по истечении которого выполняется переход в HALF_OPEN (обязательное, без значения по умолчанию).
  5. Число запросов в HALF_OPEN, которые должны завершиться успешно для перехода в CLOSED (обязательное, без значения по умолчанию).
  6. Включение или отключение CircuitBreaker (по умолчанию: true).
  7. Максимальное число запросов, используемых для расчёта failureRateThreshold и определения состояния (обязательное для всех типов, кроме TIME_BASED, без значения по умолчанию).
  8. Количество независимых полос счётчиков, от 1 до 64; используется только реализацией STRIPED_APPROX (по умолчанию: 16).

Ключ telemetry внутри той же секции переопределяет общемодульные настройки из раздела Телеметрия.

Ограничения

Перечисленное ниже проверяется при построении графа — нарушение любого правила прерывает старт приложения с явным сообщением вида CircuitBreaker '<name>' property '<key>' ...: countBased обязателен для всех типов, кроме TIME_BASED, а timeBased обязателен для TIME_BASED; failureRateThreshold в диапазоне 1..100; countBased.windowSize ≥ 1; minimumRequiredCalls ≥ 1 и ≤ countBased.windowSize; permittedCallsInHalfOpenState в диапазоне 1..65535; waitDurationInOpenState не может быть отрицательным; countBased.stripedApprox.stripes в диапазоне 1..64, а countBased.windowSize не может превышать stripes * 65535; для RING_BUFFER значение countBased.windowSize не может превышать 4194304.

Note

Значение enabled = false превращает аспект в прозрачный проброс — метод вызывается напрямую без защиты. Остальные значения при этом всё равно читаются и валидируются, потому что объект конфигурации создаётся в любом случае.

Метрики модуля описаны в разделе Справочник метрик.

Реализации

type выбирает способ сбора статистики в состоянии CLOSED. Сама машина состояний во всех четырёх реализациях одинакова и строго атомарна.

type Окно Статистика Когда использовать
STRIPED_APPROX по числу вызовов, countBased.windowSize приблизительная — запись распределяется по независимым полосам по умолчанию; самый быстрый вариант на горячих и высоконагруженных путях
FIXED_WINDOW по числу вызовов, countBased.windowSize фиксированный счётчик, сбрасываемый при заполнении окна минимальные накладные расходы, один упакованный счётчик, без точной истории последних N вызовов
RING_BUFFER по числу вызовов, countBased.windowSize точная история последних N вызовов в глобальном порядке когда точная семантика по числу вызовов важнее накладных расходов на синхронизацию
TIME_BASED по времени, timeBased.windowDuration последнее временное окно, согласованность в пределах смены корзины когда интенсивность нагрузки меняется и фиксированное число вызовов не является осмысленным окном

TIME_BASED игнорирует countBased и читает собственную секцию:

resilient {
    circuitbreaker {
        custom {
            type = TIME_BASED
            failureRateThreshold = 50
            minimumRequiredCalls = 10
            waitDurationInOpenState = "25s"
            permittedCallsInHalfOpenState = 15
            timeBased {
                windowDuration = "10s" //(1)!
                sampleCount = 16 //(2)!
                counterStripes = 16 //(3)!
                counterType = ATOMIC //(4)!
            }
        }
    }
}
  1. Длительность временного окна, по которому считается доля отказов (обязательное для TIME_BASED, без значения по умолчанию).
  2. Число корзин, на которые делится окно, от 1 до 1024 (по умолчанию: 16).
  3. Число независимых полос счётчиков внутри корзины, от 1 до 64 (по умолчанию: 16).
  4. Реализация счётчиков: ATOMIC даёт предсказуемый сброс, LONG_ADDER быстрее при высокой конкуренции ценой более приблизительного сброса на границах корзин (по умолчанию: ATOMIC).
resilient:
  circuitbreaker:
    custom:
      type: TIME_BASED
      failureRateThreshold: 50
      minimumRequiredCalls: 10
      waitDurationInOpenState: "25s"
      permittedCallsInHalfOpenState: 15
      timeBased:
        windowDuration: "10s" #(1)!
        sampleCount: 16 #(2)!
        counterStripes: 16 #(3)!
        counterType: ATOMIC #(4)!
  1. Длительность временного окна, по которому считается доля отказов (обязательное для TIME_BASED, без значения по умолчанию).
  2. Число корзин, на которые делится окно, от 1 до 1024 (по умолчанию: 16).
  3. Число независимых полос счётчиков внутри корзины, от 1 до 64 (по умолчанию: 16).
  4. Реализация счётчиков: ATOMIC даёт предсказуемый сброс, LONG_ADDER быстрее при высокой конкуренции ценой более приблизительного сброса на границах корзин (по умолчанию: ATOMIC).

Фильтрация исключений

По умолчанию CircuitBreaker считает отказом любую ошибку, кроме исключений, реализующих интерфейс-маркер NonCircuitableException. Пометить им собственный тип исключения — самый простой способ не пускать ожидаемую бизнес-ошибку в статистику прерывателя, а тот же тип может реализовать и NonRetryableException, чтобы не попадать под Retry:

public final class InvalidPetException extends RuntimeException
    implements NonCircuitableException, NonRetryableException { //(1)!

    public InvalidPetException(String message) {
        super(message);
    }
}
  1. io.koraframework.resilient.circuitbreaker.NonCircuitableException и io.koraframework.resilient.retry.NonRetryableException — интерфейсы-маркеры без методов.
class InvalidPetException(message: String) : RuntimeException(message),
    NonCircuitableException, NonRetryableException //(1)!
  1. io.koraframework.resilient.circuitbreaker.NonCircuitableException и io.koraframework.resilient.retry.NonRetryableException — интерфейсы-маркеры без методов.

Если решение нельзя выразить через тип исключения, изменить поведение по умолчанию можно двумя способами.

Первый — переопределить isFailure прямо в интерфейсе-спецификации: без отдельного компонента и без конфигурации:

@CircuitBreakerSpec("resilient.circuitbreaker.custom")
public interface CustomCircuitBreaker extends CircuitBreaker {

    @Override
    default boolean isFailure(Throwable throwable) {
        return !(throwable instanceof HttpServerResponseException e) || e.code() >= 500;
    }
}
@CircuitBreakerSpec("resilient.circuitbreaker.custom")
interface CustomCircuitBreaker : CircuitBreaker {

    override fun isFailure(throwable: Throwable): Boolean =
        throwable !is HttpServerResponseException || throwable.code() >= 500
}

Второй способ — компонент CircuitBreakerPredicate, привязанный к спецификации через @Tag. Он имеет приоритет над isFailure и подходит, когда самому фильтру нужны зависимости:

@Tag(CustomCircuitBreaker.class) //(1)!
@Component
public final class MyFailurePredicate implements CircuitBreakerPredicate {

    @Override
    public boolean isCircuitBreakerFailure(Throwable throwable) { //(2)!
        return !(throwable instanceof HttpServerResponseException e) || e.code() >= 500;
    }
}
  1. Привязывает предикат к одной спецификации; без тега предикат не будет использован.
  2. Возврат true означает, что исключение засчитывается как отказ.
@Tag(CustomCircuitBreaker::class) //(1)!
@Component
class MyFailurePredicate : CircuitBreakerPredicate {

    override fun isCircuitBreakerFailure(throwable: Throwable): Boolean = //(2)!
        throwable !is HttpServerResponseException || throwable.code() >= 500
}
  1. Привязывает предикат к одной спецификации; без тега предикат не будет использован.
  2. Возврат true означает, что исключение засчитывается как отказ.

Исключение, отклонённое фильтром, не засчитывается ни как отказ, ни как успех: circuit breaker его просто игнорирует, а вызывающему коду оно возвращается без изменений.

Собственный фильтр заменяет проверку маркера

NonCircuitableException проверяется реализацией isFailure по умолчанию. Переопределённый isFailure или CircuitBreakerPredicate с тегом заменяют эту проверку, поэтому собственный фильтр, который должен по-прежнему учитывать маркер, проверяет throwable instanceof NonCircuitableException сам.

Императивное использование

Интерфейс спецификации — обычный компонент графа приложения, поэтому в императивном коде его достаточно внедрить напрямую:

@Component
public final class SomeService {

    private final CustomCircuitBreaker circuitBreaker;

    public SomeService(CustomCircuitBreaker circuitBreaker) {
        this.circuitBreaker = circuitBreaker;
    }

    public String doWork() {
        return circuitBreaker.accept(this::doSomeWork);
    }

    private String doSomeWork() {
        // do some work
    }
}
@Component
class SomeService(private val circuitBreaker: CustomCircuitBreaker) {

    fun doWork(): String {
        return circuitBreaker.accept(ThrowableCallable { doSomeWork() })
    }

    private fun doSomeWork(): String {
        // do some work
    }
}

Методы accept принимают ThrowableCallable<T, E> или ThrowableRunnable<E> из пакета io.koraframework.resilient.common, поэтому защищаемый код может бросать проверяемые исключения.

Чтобы в состоянии OPEN вернуть резервное значение вместо CallNotPermittedException, используйте перегрузку accept со вторым аргументом:

public String doWork() {
    return circuitBreaker.accept(this::doSomeWork, () -> "fallback");
}
fun doWork(): String {
    return circuitBreaker.accept(ThrowableCallable { doSomeWork() }, ThrowableCallable { "fallback" })
}

Если защищаемый вызов не удаётся обернуть в один callable, разрешение можно получать и освобождать вручную. Вызовите acquire() (бросает CallNotPermittedException, когда состояние OPEN либо HALF_OPEN и все пробные вызовы уже израсходованы), а затем обязательно сообщите результат через releaseOnSuccess() или releaseOnError(Throwable) — иначе разрешение утечёт и учёт вызовов станет неверным:

public String doWork() {
    circuitBreaker.acquire(); // throws CallNotPermittedException when the call is not permitted
    try {
        var result = doSomeWork();
        circuitBreaker.releaseOnSuccess();
        return result;
    } catch (Throwable e) {
        circuitBreaker.releaseOnError(e);
        throw e;
    }
}
fun doWork(): String {
    circuitBreaker.acquire() // throws CallNotPermittedException when the call is not permitted
    try {
        val result = doSomeWork()
        circuitBreaker.releaseOnSuccess()
        return result
    } catch (e: Throwable) {
        circuitBreaker.releaseOnError(e)
        throw e
    }
}

tryAcquire() — вариант без исключения: он возвращает false, когда вызов не разрешён, и позволяет ветвиться без перехвата CallNotPermittedException. Если же acquire() бросил исключение, текущее состояние (OPEN или HALF_OPEN) доступно через CallNotPermittedException#state().

Retry

Retry даёт возможность настроить повторные вызовы аннотированных методов. Он позволяет указать, когда метод следует повторить, и настроить параметры повторов, если метод бросает исключение, подходящее под настроенный фильтр.

Декларативное использование

Когда все попытки исчерпаны, вызов завершается исключением RetryExhaustedException.

@RetrySpec("resilient.retry.custom")
public interface CustomRetry extends Retry { }

@Component
public class SomeService {

    @Retryable(CustomRetry.class)
    public String getValue() {
        throw new IllegalStateException("Ops");
    }
}
@RetrySpec("resilient.retry.custom")
interface CustomRetry : Retry

@Component
open class SomeService {

    @Retryable(CustomRetry::class)
    open fun execute(arg: String): Unit = throw IllegalStateException("Ops")
}

Конфигурация

Секция, на которую указывает @RetrySpec, описана в классе RetryConfig:

resilient {
    retry {
        custom {
            delay = "100ms" //(1)!
            attempts = 2 //(2)!
            delayStep = "100ms" //(3)!
            enabled = true //(4)!
        }
    }
}
  1. Начальная задержка перед повторным вызовом (обязательное, без значения по умолчанию).
  2. Количество повторных попыток (обязательное, без значения по умолчанию).
  3. Приращение задержки для последующих попыток; игнорируется, если задана секция backoff (по умолчанию: 0).
  4. Включение или отключение Retry (по умолчанию: true).
resilient:
  retry:
    custom:
      delay: "100ms" #(1)!
      attempts: 2 #(2)!
      delayStep: "100ms" #(3)!
      enabled: true #(4)!
  1. Начальная задержка перед повторным вызовом (обязательное, без значения по умолчанию).
  2. Количество повторных попыток (обязательное, без значения по умолчанию).
  3. Приращение задержки для последующих попыток; игнорируется, если задана секция backoff (по умолчанию: 0).
  4. Включение или отключение Retry (по умолчанию: true).

Необязательные секции backoff, jitter и retryBudget описаны в разделах Backoff и jitter и Бюджет повторов, а ключ telemetry переопределяет настройки из раздела Телеметрия.

Ограничения и рост задержки

delay и attempts обязательны, без них приложение не стартует. attempts считает повторы после первоначального вызова, поэтому attempts = 2 допускает до 3 выполнений суммарно, а attempts = 0 превращает аспект в прозрачный проброс. Без секции backoff каждый повтор ждёт на delayStep (по умолчанию 0) дольше предыдущего, то есть задержки составляют delay, delay + delayStep, delay + 2·delayStep, … .

Note

Значение enabled = false превращает @Retryable в прозрачный проброс (метод выполняется один раз). Когда попытки заканчиваются, бросается RetryExhaustedException, у которого getCause() — последний отказ, а в подавленных (suppressed) исключениях лежат все предыдущие.

Backoff и jitter

Секция backoff заменяет линейный рост delayStep на экспоненциальный, а jitter разводит задержки параллельных вызовов, чтобы они не повторяли запрос синхронно:

resilient {
    retry {
        custom {
            delay = "100ms"
            attempts = 4
            backoff {
                type = EXPONENTIAL //(1)!
                multiplier = 2.0 //(2)!
                delayMax = "5s" //(3)!
            }
            jitter {
                type = FULL //(4)!
                ratio = 1.0 //(5)!
            }
        }
    }
}
  1. Стратегия роста задержки; поддерживается единственное значение EXPONENTIAL (по умолчанию: EXPONENTIAL).
  2. Множитель, применяемый на каждой попытке, должен быть больше 0 (по умолчанию: 2.0).
  3. Верхняя граница вычисленной задержки (опционально, по умолчанию не ограничена).
  4. Стратегия разброса: NONE отключает его, FULL рандомизирует задержку (по умолчанию: NONE).
  5. Доля вычисленной задержки, которая может быть вычтена, значение в диапазоне 0..1 (по умолчанию: 1.0).
resilient:
  retry:
    custom:
      delay: "100ms"
      attempts: 4
      backoff:
        type: EXPONENTIAL #(1)!
        multiplier: 2.0 #(2)!
        delayMax: "5s" #(3)!
      jitter:
        type: FULL #(4)!
        ratio: 1.0 #(5)!
  1. Стратегия роста задержки; поддерживается единственное значение EXPONENTIAL (по умолчанию: EXPONENTIAL).
  2. Множитель, применяемый на каждой попытке, должен быть больше 0 (по умолчанию: 2.0).
  3. Верхняя граница вычисленной задержки (опционально, по умолчанию не ограничена).
  4. Стратегия разброса: NONE отключает его, FULL рандомизирует задержку (по умолчанию: NONE).
  5. Доля вычисленной задержки, которая может быть вычтена, значение в диапазоне 0..1 (по умолчанию: 1.0).

С приведёнными настройками вычисленная задержка для попытки n равна delay * multiplier^(n-1) и ограничена сверху delayMax: 100ms, 200ms, 400ms, 800ms. Затем jitter выбирает фактическую задержку равномерно из отрезка [computed - computed * ratio, computed], поэтому ratio = 1.0 означает любое значение от 0 до вычисленной задержки.

Бюджет повторов

Бюджет повторов ограничивает, сколько дополнительной нагрузки могут создать повторы. Это ведро токенов: каждый повтор забирает один токен, каждый успешный вызов возвращает ratio токенов, а когда ведро пусто, повтор запрещается и исходное исключение пробрасывается как есть — без ожидания и без RetryExhaustedException.

resilient {
    retry {
        custom {
            delay = "100ms"
            attempts = 3
            retryBudget {
                enabled = true //(1)!
                ratio = 0.1 //(2)!
                tokensMax = 100 //(3)!
                tokensInitial = 10 //(4)!
                minTokensPerSecond = 0.0 //(5)!
            }
        }
    }
}
  1. Включение или отключение бюджета (по умолчанию: true).
  2. Сколько токенов добавляет успешный вызов — 0.1 разрешает примерно один повтор на десять успешных вызовов (по умолчанию: 0.1).
  3. Верхняя граница ведра (по умолчанию: 100).
  4. Начальное количество токенов, не должно превышать tokensMax (по умолчанию: 10).
  5. Гарантированная скорость пополнения, которая работает даже без успешных вызовов (по умолчанию: 0.0).
resilient:
  retry:
    custom:
      delay: "100ms"
      attempts: 3
      retryBudget:
        enabled: true #(1)!
        ratio: 0.1 #(2)!
        tokensMax: 100 #(3)!
        tokensInitial: 10 #(4)!
        minTokensPerSecond: 0.0 #(5)!
  1. Включение или отключение бюджета (по умолчанию: true).
  2. Сколько токенов добавляет успешный вызов — 0.1 разрешает примерно один повтор на десять успешных вызовов (по умолчанию: 0.1).
  3. Верхняя граница ведра (по умолчанию: 100).
  4. Начальное количество токенов, не должно превышать tokensMax (по умолчанию: 10).
  5. Гарантированная скорость пополнения, которая работает даже без успешных вызовов (по умолчанию: 0.0).

Note

Бюджет выключен, пока секция retryBudget не объявлена. Объявление её с enabled = false также оставляет бюджет выключенным.

Собственный и распределённый бюджет

Сам бюджет — это RetryBudget (tryAcquireRetryToken(), onSuccess(), availableTokens()), и каждая спецификация повтора получает свой экземпляр от компонента RetryBudgetFactory через get(name, config), где name — простое имя интерфейса-спецификации, а config — её RetryConfig. Если фабрика возвращает null, повтор работает без бюджета. RetryModule регистрирует DefaultRetryBudgetFactory, которая строит хранящийся в памяти KoraRetryBudget из секции retryBudget, поэтому по умолчанию у каждого экземпляра приложения своя корзина.

Фабрика заменяется обычным способом: компонент RetryBudgetFactory без тега переопределяет фабрику по умолчанию для всех повторов, а фабрика с тегом спецификации, @Tag(PetRetry.class), используется только для этого повтора и имеет приоритет над фабрикой без тега.

DistributedRetryBudgetFactory из распределённого модуля хранит один баланс токенов на спецификацию в Redis, поэтому бюджет ограничивает повторы всех экземпляров вместе. Автоматически она не регистрируется:

@KoraApp
public interface Application extends ResilientModule, LettuceDistributedResilientModule {

    default RetryBudgetFactory distributedRetryBudgetFactory(DistributedRetryBudgetClient client) { //(1)!
        return new DistributedRetryBudgetFactory(client);
    }

    @Tag(PetRetry.class) //(2)!
    default RetryBudgetFactory petRetryBudgetFactory(DistributedRetryBudgetClient client) {
        return new DistributedRetryBudgetFactory(client, "pets:retrybudget", Duration.ofMinutes(30)); //(3)!
    }
}
  1. Фабрика без тега заменяет DefaultRetryBudgetFactory для всех повторов.
  2. Фабрика с тегом спецификации используется только PetRetry и побеждает фабрику без тега.
  3. Префикс ключа и время жизни неиспользуемого ключа баланса (по умолчанию: kora:retrybudget и 10 минут).
@KoraApp
interface Application : ResilientModule, LettuceDistributedResilientModule {

    fun distributedRetryBudgetFactory(client: DistributedRetryBudgetClient): RetryBudgetFactory = //(1)!
        DistributedRetryBudgetFactory(client)

    @Tag(PetRetry::class) //(2)!
    fun petRetryBudgetFactory(client: DistributedRetryBudgetClient): RetryBudgetFactory =
        DistributedRetryBudgetFactory(client, "pets:retrybudget", Duration.ofMinutes(30)) //(3)!
}
  1. Фабрика без тега заменяет DefaultRetryBudgetFactory для всех повторов.
  2. Фабрика с тегом спецификации используется только PetRetry и побеждает фабрику без тега.
  3. Префикс ключа и время жизни неиспользуемого ключа баланса (по умолчанию: kora:retrybudget и 10 минут).

Баланс хранится под ключом <keyPrefix>:<простое имя спецификации>, и каждое изменение продлевает срок жизни ключа, поэтому неиспользуемый бюджет исчезает из Redis и начинается заново с tokensInitial. Сервисы, работающие с одним Redis и использующие спецификации с одинаковым простым именем, делят один баланс, если их префиксы ключей не различаются.

Note

Распределённый бюджет читает ту же секцию retryBudget, и она по-прежнему должна быть объявлена: enabled, ratio, tokensMax и tokensInitial сохраняют свой смысл, а minTokensPerSecond игнорируется — общий баланс пополняется только успешными вызовами.

Фильтрация исключений

По умолчанию Retry повторяет вызов при любой ошибке, кроме исключений, реализующих интерфейс-маркер NonRetryableException (см. пример в разделе circuit breaker). Два способа сузить это дальше повторяют подход circuit breaker: переопределить isFailure в спецификации либо зарегистрировать компонент RetryPredicate с тегом спецификации. Если есть оба, побеждает компонент с тегом, и любой из них заменяет проверку NonRetryableException по умолчанию.

@RetrySpec("resilient.retry.custom")
public interface CustomRetry extends Retry {

    @Override
    default boolean isFailure(Throwable throwable) {
        return throwable instanceof IOException;
    }
}

@Tag(CustomRetry.class)
@Component
public final class MyRetryPredicate implements RetryPredicate {

    @Override
    public boolean isRetryFailure(Throwable throwable) {
        return throwable instanceof IOException;
    }
}
@RetrySpec("resilient.retry.custom")
interface CustomRetry : Retry {

    override fun isFailure(throwable: Throwable): Boolean = throwable is IOException
}

@Tag(CustomRetry::class)
@Component
class MyRetryPredicate : RetryPredicate {

    override fun isRetryFailure(throwable: Throwable): Boolean = throwable is IOException
}

Исключение, отклонённое фильтром, пробрасывается сразу — без дальнейших попыток и без оборачивания в RetryExhaustedException.

Императивное использование

Внедрите интерфейс спецификации и вызывайте его напрямую:

@Component
public final class SomeService {

    private final CustomRetry retry;

    public SomeService(CustomRetry retry) {
        this.retry = retry;
    }

    public String doWork() {
        return retry.retry(this::doSomeWork);
    }

    private String doSomeWork() {
        // do some work
    }
}
@Component
class SomeService(private val retry: CustomRetry) {

    fun doWork(): String {
        return retry.retry(ThrowableCallable { doSomeWork() })
    }

    private fun doSomeWork(): String {
        // do some work
    }
}

Чтобы после исчерпания всех попыток вернуть резервное значение вместо RetryExhaustedException, передайте второй callable:

return retry.retry(this::doSomeWork, () -> "fallback");
return retry.retry(ThrowableCallable { doSomeWork() }, ThrowableCallable { "fallback" })

Для асинхронного императивного кода есть перегрузка, которая повторяет Supplier<CompletionStage<T>> и возвращает CompletionStage<T>, планируя каждую попытку после настроенной задержки и не блокируя вызывающий поток.

Ручное управление состоянием повтора

Для полного контроля над циклом повторов используйте retry.asState(), возвращающий Retry.RetryState. Он реализует AutoCloseable, поэтому оборачивайте его в try-with-resources (Java) или use (Kotlin), чтобы метрики записались по завершении. На каждое пойманное исключение вызывайте onException(Throwable), который возвращает RetryStatus:

  • ACCEPTED — очередная попытка разрешена; вызовите doDelay() (блокирует на текущую задержку) и повторите вызов.
  • REJECTED — исключение отклонено фильтром либо бюджет повторов исчерпан, повторять нельзя; пробросьте его дальше.
  • EXHAUSTED — все попытки израсходованы; бросьте RetryExhaustedException (или верните значение по умолчанию).

getAttempts() / getAttemptsMax() показывают прогресс, а getDelayNanos() возвращает следующую задержку.

public String doWork() {
    try (var state = retry.asState()) {
        while (true) {
            try {
                return doSomeWork();
            } catch (Exception e) {
                switch (state.onException(e)) {
                    case ACCEPTED -> state.doDelay();   // wait, then loop and retry
                    case REJECTED -> throw e;           // not retryable
                    case EXHAUSTED -> throw new RetryExhaustedException("custom", state.getAttemptsMax(), e);
                }
            }
        }
    }
}
fun doWork(): String {
    retry.asState().use { state ->
        while (true) {
            try {
                return doSomeWork()
            } catch (e: Exception) {
                when (state.onException(e)) {
                    Retry.RetryState.RetryStatus.ACCEPTED -> state.doDelay()   // wait, then loop and retry
                    Retry.RetryState.RetryStatus.REJECTED -> throw e           // not retryable
                    Retry.RetryState.RetryStatus.EXHAUSTED -> throw RetryExhaustedException("custom", state.attemptsMax, e)
                }
            }
        }
    }
}

Timeout

Timeout задаёт максимальное время выполнения аннотированного метода. Синхронные методы выполняются на виртуальном потоке и прерываются по достижении лимита, а для Kotlin suspend-функций корутина отменяется через withTimeout.

Декларативное использование

Если метод не завершается в пределах duration, вызов падает с TimeoutExhaustedException.

@TimeoutSpec("resilient.timeout.custom")
public interface CustomTimeouter extends Timeouter { }

@Component
public class SomeService {

    @Timeout(CustomTimeouter.class)
    public String getValue() {
        try {
            Thread.sleep(3000);
            return "OK";
        } catch (InterruptedException e) {
            throw new IllegalStateException(e);
        }
    }
}
@TimeoutSpec("resilient.timeout.custom")
interface CustomTimeouter : Timeouter

@Component
open class SomeService {

    @Timeout(CustomTimeouter::class)
    open fun value(): String = try {
        Thread.sleep(3000)
        "OK"
    } catch (e: InterruptedException) {
        throw IllegalStateException(e)
    }
}

Конфигурация

Секция, на которую указывает @TimeoutSpec, описана в классе TimeoutConfig:

resilient {
    timeout {
        custom {
            duration = "1s" //(1)!
            enabled = true //(2)!
        }
    }
}
  1. Ограничение времени операции, по истечении которого будет брошено TimeoutExhaustedException (обязательное, без значения по умолчанию).
  2. Включение или отключение Timeout (по умолчанию: true).
resilient:
  timeout:
    custom:
      duration: "1s" #(1)!
      enabled: true #(2)!
  1. Ограничение времени операции, по истечении которого будет брошено TimeoutExhaustedException (обязательное, без значения по умолчанию).
  2. Включение или отключение Timeout (по умолчанию: true).

Ключ telemetry внутри той же секции переопределяет общемодульные настройки из раздела Телеметрия.

Note

duration обязателен, без него приложение не стартует. Значение enabled = false превращает @Timeout в прозрачный проброс — метод выполняется без ограничения времени. Исключение, брошенное методом до истечения лимита, пробрасывается без изменений, включая проверяемые исключения.

Императивное использование

Внедрите интерфейс спецификации и вызывайте его напрямую:

@Component
public final class SomeService {

    private final CustomTimeouter timeouter;

    public SomeService(CustomTimeouter timeouter) {
        this.timeouter = timeouter;
    }

    public String doWork() {
        return timeouter.execute(this::doSomeWork);
    }

    private String doSomeWork() {
        // do some work
    }
}
@Component
class SomeService(private val timeouter: CustomTimeouter) {

    fun doWork(): String {
        return timeouter.execute(ThrowableCallable { doSomeWork() })
    }

    private fun doSomeWork(): String {
        // do some work
    }
}

У Timeouter также есть перегрузка execute для операций, ничего не возвращающих, а timeout() возвращает настроенный Duration:

Duration limit = timeouter.timeout();  // configured duration
timeouter.execute(this::cleanup);      // void operation, throws TimeoutExhaustedException on timeout
val limit: Duration = timeouter.timeout()           // configured duration
timeouter.execute(ThrowableRunnable { cleanup() })  // void operation, throws TimeoutExhaustedException on timeout

RateLimiter

RateLimiter ограничивает, сколько раз метод может быть вызван за период: limitForPeriod разрешений на limitRefreshPeriod. Способ выдачи разрешений определяет алгоритм, по умолчанию это token bucket. Получение разрешения никогда не блокирует — вызов, для которого нет доступного разрешения, сразу падает с RateLimitExceededException.

Декларативное использование

@RateLimiterSpec("resilient.ratelimiter.custom")
public interface CustomRateLimiter extends RateLimiter { }

@Component
public class SomeService {

    @RateLimited(CustomRateLimiter.class)
    public String getValue() {
        return "OK";
    }
}
@RateLimiterSpec("resilient.ratelimiter.custom")
interface CustomRateLimiter : RateLimiter

@Component
open class SomeService {

    @RateLimited(CustomRateLimiter::class)
    open fun value(): String = "OK"
}

Конфигурация

Секция, на которую указывает @RateLimiterSpec, описана в классе RateLimiterConfig:

resilient {
    ratelimiter {
        custom {
            limitForPeriod = 100 //(1)!
            limitRefreshPeriod = "1s" //(2)!
            type = TOKEN_BUCKET //(3)!
            enabled = true //(4)!
        }
    }
}
  1. Количество вызовов, разрешённых в пределах одного периода (обязательное, без значения по умолчанию).
  2. Длительность периода, по истечении которого разрешения восстанавливаются (обязательное, без значения по умолчанию).
  3. Алгоритм ограничения: TOKEN_BUCKET или FIXED_WINDOW (по умолчанию: TOKEN_BUCKET).
  4. Включение или отключение RateLimiter (по умолчанию: true).
resilient:
  ratelimiter:
    custom:
      limitForPeriod: 100 #(1)!
      limitRefreshPeriod: "1s" #(2)!
      type: TOKEN_BUCKET #(3)!
      enabled: true #(4)!
  1. Количество вызовов, разрешённых в пределах одного периода (обязательное, без значения по умолчанию).
  2. Длительность периода, по истечении которого разрешения восстанавливаются (обязательное, без значения по умолчанию).
  3. Алгоритм ограничения: TOKEN_BUCKET или FIXED_WINDOW (по умолчанию: TOKEN_BUCKET).
  4. Включение или отключение RateLimiter (по умолчанию: true).

Ключ telemetry внутри той же секции переопределяет общемодульные настройки из раздела Телеметрия.

Note

Значение enabled = false превращает @RateLimited в прозрачный проброс — разрешается любой вызов. Ограничитель работает в пределах одного экземпляра приложения: при нескольких репликах фактический лимит равен limitForPeriod, умноженному на число реплик. Для лимита, общего для всех реплик, нужен распределённый rate limiter.

Алгоритмы

Ключ type выбирает одну из двух неблокирующих реализаций, каждая из которых хранит всё состояние в одном атомарном значении:

type Поведение Когда использовать
TOKEN_BUCKET (по умолчанию) token bucket на основе GCRA: разрешения восполняются непрерывно, по одному каждые limitRefreshPeriod / limitForPeriod, а простаивающий ограничитель допускает всплеск до limitForPeriod вызовов ровный темп без пиков на границах периодов; выбор общего назначения
FIXED_WINDOW счётчик на limitForPeriod разрешений для каждого следующего окна длиной limitRefreshPeriod, отсчитываемого от создания ограничителя строгая квота «N вызовов на окно»; на стыке двух соседних окон может пройти до двух limitForPeriod вызовов

FIXED_WINDOW упаковывает счётчик в 24 бита, поэтому его limitForPeriod должен быть меньше 16777216.

Императивное использование

Внедрите интерфейс спецификации и вызывайте его напрямую:

@Component
public final class SomeService {

    private final CustomRateLimiter rateLimiter;

    public SomeService(CustomRateLimiter rateLimiter) {
        this.rateLimiter = rateLimiter;
    }

    public String doWork() {
        return rateLimiter.execute(this::doSomeWork); //(1)!
    }

    public boolean doWorkIfPermitted() {
        if (!rateLimiter.tryAcquire()) { //(2)!
            return false;
        }
        doSomeWork();
        return true;
    }

    private String doSomeWork() {
        // do some work
    }
}
  1. Получает разрешение и выполняет операцию, бросая RateLimitExceededException, когда лимит исчерпан.
  2. Вариант без исключения: возвращает false вместо ошибки.
@Component
class SomeService(private val rateLimiter: CustomRateLimiter) {

    fun doWork(): String {
        return rateLimiter.execute(ThrowableCallable { doSomeWork() }) //(1)!
    }

    fun doWorkIfPermitted(): Boolean {
        if (!rateLimiter.tryAcquire()) { //(2)!
            return false
        }
        doSomeWork()
        return true
    }

    private fun doSomeWork(): String {
        // do some work
    }
}
  1. Получает разрешение и выполняет операцию, бросая RateLimitExceededException, когда лимит исчерпан.
  2. Вариант без исключения: возвращает false вместо ошибки.

acquire() забирает разрешение, ничего не выполняя, и бросает RateLimitExceededException, когда разрешений не осталось.

Распределённый rate limiter

Локальный ограничитель считает только вызовы своего экземпляра. Когда лимит относится ко всему сервису — например, квота API партнёра, — объявите спецификацию через @RateLimiterDistributedSpec вместо @RateLimiterSpec: тогда лимит соблюдается через Redis для всех экземпляров вместе. Распределённый модуль должен быть подключён.

@RateLimiterDistributedSpec("resilient.ratelimiter.partner") //(1)!
public interface PartnerRateLimiter extends RateLimiter { }

@Component
public class PartnerService {

    @RateLimited(PartnerRateLimiter.class) //(2)!
    public String getValue() {
        return "OK";
    }
}
  1. Вместо @RateLimiterSpec; интерфейс по-прежнему расширяет RateLimiter.
  2. Аннотация метода, поддерживаемые сигнатуры и RateLimitExceededException те же, что у локального ограничителя.
@RateLimiterDistributedSpec("resilient.ratelimiter.partner") //(1)!
interface PartnerRateLimiter : RateLimiter

@Component
open class PartnerService {

    @RateLimited(PartnerRateLimiter::class) //(2)!
    open fun value(): String = "OK"
}
  1. Вместо @RateLimiterSpec; интерфейс по-прежнему расширяет RateLimiter.
  2. Аннотация метода, поддерживаемые сигнатуры и RateLimitExceededException те же, что у локального ограничителя.

Секция описана классом DistributedRateLimiterConfig:

resilient {
    ratelimiter {
        partner {
            limitForPeriod = 100 //(1)!
            limitRefreshPeriod = "1s" //(2)!
            keyPrefix = "pets" //(3)!
            algorithm = TOKEN_BUCKET //(4)!
            enabled = true //(5)!
        }
    }
}
  1. Количество вызовов, разрешённых в пределах одного периода для всех экземпляров вместе (обязательное, без значения по умолчанию).
  2. Длительность периода (обязательное, без значения по умолчанию).
  3. Префикс ключей Redis, отделяющий ограничители этого сервиса (обязательное, без значения по умолчанию).
  4. Алгоритм: TOKEN_BUCKET или FIXED_WINDOW, с тем же смыслом, что у локального ограничителя (по умолчанию: TOKEN_BUCKET).
  5. Включение или отключение ограничителя (по умолчанию: true).
resilient:
  ratelimiter:
    partner:
      limitForPeriod: 100 #(1)!
      limitRefreshPeriod: "1s" #(2)!
      keyPrefix: "pets" #(3)!
      algorithm: TOKEN_BUCKET #(4)!
      enabled: true #(5)!
  1. Количество вызовов, разрешённых в пределах одного периода для всех экземпляров вместе (обязательное, без значения по умолчанию).
  2. Длительность периода (обязательное, без значения по умолчанию).
  3. Префикс ключей Redis, отделяющий ограничители этого сервиса (обязательное, без значения по умолчанию).
  4. Алгоритм: TOKEN_BUCKET или FIXED_WINDOW, с тем же смыслом, что у локального ограничителя (по умолчанию: TOKEN_BUCKET).
  5. Включение или отключение ограничителя (по умолчанию: true).

Ключ telemetry и настройки resilient.telemetry.rateLimiter действуют так же, как для локального ограничителя.

Ключ алгоритма называется algorithm, а не type

В DistributedRateLimiterConfig ключ алгоритма называется algorithm, тогда как в локальном RateLimiterConfig — type. Ключ type в распределённой секции игнорируется, и ограничитель остаётся на TOKEN_BUCKET.

Как это работает:

  • Сгенерированная реализация наследует KoraDistributedRateLimiter и требует DistributedRateLimiterClient в графе; его предоставляет LettuceDistributedResilientModule.
  • Состояние ограничителя хранится под ключом <keyPrefix>:<простое имя спецификации>. FIXED_WINDOW использует отдельный ключ на каждое окно, <keyPrefix>:<простое имя спецификации>:<номер окна>, причём окна выровнены по системным часам, а не по моменту запуска приложения. Ключи истекают сами, когда становятся не нужны.
  • TOKEN_BUCKET берёт текущее время из системных часов вызывающего экземпляра, поэтому расхождение часов между экземплярами делает лимит приблизительным. Он также немного приблизителен сразу после простоя и точен в установившемся режиме.
  • Каждое разрешение стоит синхронных команд Redis, то есть добавляет к защищаемому вызову сетевой обмен. Ошибка Redis не подавляется: она выходит из @RateLimited к вызывающему коду.

Fallback

Fallback указывает метод, который будет вызван при сбое аннотированного метода. В отличие от остальных механизмов у него нет ни интерфейса-спецификации, ни собственной секции конфигурации: весь контракт — это сам резервный метод.

Резервный метод обязан совпадать по типу возвращаемого значения с аннотированным методом и должен быть объявлен в том же классе.

Декларативное использование

Пример резервного метода без аргументов:

@Component
public class SomeService {

    @Fallback(method = "getFallback()")
    public String getValue() {
        return "value";
    }

    protected String getFallback() {
        return "fallback";
    }
}
@Component
open class SomeService {

    @Fallback(method = "getFallback()")
    open fun value(): String = "value"

    fun getFallback(): String = "fallback"
}

Пример Fallback с аргументами:

@Component
public class SomeService {

    @Fallback(method = "getFallback(arg3, arg1)")     // Passes the arguments of the annotated method in the specified order to the Fallback method
    public String getValue(String arg1, Integer arg2, Long arg3) {
        return "value";
    }

    protected String getFallback(Long argLong, String argString) {
        return "fallback";
    }
}
@Component
open class SomeService {

    // Passes the arguments of the annotated method in the specified order to the Fallback method
    @Fallback(method = "getFallback(arg3, arg1)")
    open fun getValue(arg1: String, arg2: Int, arg3: Long): String = "value"

    fun getFallback(argLong: Long, argString: String): String = "fallback"
}

Ссылка проверяется на этапе компиляции. Типичные ошибки:

  • @Fallback method reference '…' has invalid syntax — значение должно иметь вид name() или name(arg1, arg2).
  • @Fallback method reference '…' uses unknown source arguments — указан аргумент, которого нет у аннотированного метода.
  • @Fallback method '…' was not found — метода с таким именем нет в том же классе.
  • @Fallback method '…' does not match requested signature — резервный метод должен принимать ровно перечисленные аргументы плюс необязательный параметр @Fallback.Reason.

Фильтрация исключений

По умолчанию резервный метод вызывается на любой Throwable. Единственный параметр @Fallback.Reason в резервном методе одновременно передаёт вызвавшее фолбэк исключение и сужает условие срабатывания: исключение, не являющееся экземпляром объявленного типа параметра, пробрасывается дальше, а резервный метод не вызывается.

@Component
public class SomeService {

    @Fallback(method = "getFallback()")
    public String getValue() {
        throw new IllegalStateException("Ops");
    }

    protected String getFallback(@Fallback.Reason RuntimeException reason) { //(1)!
        return "fallback: " + reason.getMessage();
    }
}
  1. Такой параметр допускается не более одного, и он не входит в список аргументов в method = "...".
@Component
open class SomeService {

    @Fallback(method = "getFallback()")
    open fun value(): String = throw IllegalStateException("Ops")

    fun getFallback(@Fallback.Reason reason: RuntimeException): String = //(1)!
        "fallback: " + reason.message
}
  1. Такой параметр допускается не более одного, и он не входит в список аргументов в method = "...".

В Java тип параметра должен соответствовать тому, что может бросить аннотированный метод: RuntimeException, если у него нет throws, Exception, если объявлены проверяемые исключения, и Throwable, если объявлено throws Throwable. Более узкий тип приводит к ошибке компиляции, поэтому для более тонкой фильтрации используйте обычную проверку instanceof внутри резервного метода.

Телеметрия

Логирование, метрики и трассировка настраиваются отдельно для каждого механизма в секции resilient.telemetry, а любая спецификация может переопределить общемодульные значения ключом telemetry внутри своей секции конфигурации:

resilient {
    telemetry {
        circuitBreaker { //(1)!
            logging.enabled = false //(2)!
            metrics {
                enabled = false //(3)!
                tags { "service" = "pets" } //(4)!
            }
            tracing {
                enabled = false //(5)!
                attributes { "component" = "resilient" } //(6)!
            }
        }
        retry {}
        timeout {}
        fallback {}
        rateLimiter {}
    }
    circuitbreaker {
        custom {
            telemetry.metrics.enabled = true //(7)!
        }
    }
}
  1. Секции: circuitBreaker, retry, timeout, fallback, rateLimiter.
  2. Включает логирование механизма (по умолчанию: false).
  3. Включает метрики механизма (по умолчанию: false).
  4. Дополнительные теги, добавляемые ко всем метрикам механизма (по умолчанию: пусто).
  5. Включает трассировку механизма (по умолчанию: false).
  6. Дополнительные атрибуты, добавляемые ко всем спанам механизма (по умолчанию: пусто).
  7. Переопределение для конкретной спецификации; незаданные ключи берутся из resilient.telemetry.
resilient:
  telemetry:
    circuitBreaker: #(1)!
      logging:
        enabled: false #(2)!
      metrics:
        enabled: false #(3)!
        tags:
          service: "pets" #(4)!
      tracing:
        enabled: false #(5)!
        attributes:
          component: "resilient" #(6)!
    retry: {}
    timeout: {}
    fallback: {}
    rateLimiter: {}
  circuitbreaker:
    custom:
      telemetry:
        metrics:
          enabled: true #(7)!
  1. Секции: circuitBreaker, retry, timeout, fallback, rateLimiter.
  2. Включает логирование механизма (по умолчанию: false).
  3. Включает метрики механизма (по умолчанию: false).
  4. Дополнительные теги, добавляемые ко всем метрикам механизма (по умолчанию: пусто).
  5. Включает трассировку механизма (по умолчанию: false).
  6. Дополнительные атрибуты, добавляемые ко всем спанам механизма (по умолчанию: пусто).
  7. Переопределение для конкретной спецификации; незаданные ключи берутся из resilient.telemetry.

Весь блок resilient.telemetry необязателен — если его не указывать, все механизмы получат перечисленные значения по умолчанию. Имена метрик перечислены в разделе Справочник метрик.

Комбинирование

Все перечисленные аннотации можно комбинировать одновременно над одним методом.

Порядок применения аннотаций зависит от порядка их объявления. Порядок можно менять как угодно и сочетать с другими аннотациями, которые также применяются в порядке объявления.

@Component
public class SomeService {

    @Fallback(method = "getFallback(arg1)")           // 4
    @CircuitBreakable(CustomCircuitBreaker.class)     // 3
    @Retryable(CustomRetry.class)                     // 2
    @Timeout(CustomTimeouter.class)                   // 1
    public String getValueSync(String arg1) {
        return "result-" + arg1;
    }

    protected String getFallback(String arg1) {       // 4
        return "fallback-" + arg1;
    }
}
@Component
open class SomeService {

    @Fallback(method = "getFallback(arg1)")             // 4
    @CircuitBreakable(CustomCircuitBreaker::class)      // 3
    @Retryable(CustomRetry::class)                      // 2
    @Timeout(CustomTimeouter::class)                    // 1
    open fun getValueSync(arg1: String): String = "result-$arg1"

    protected fun getFallback(arg1: String): String = "fallback-$arg1"  // 4
}

В примере выше:

  1. Применяется @Timeout и проверяет, что метод не выполняется дольше указанного в конфигурации времени.
  2. Применяется @Retryable и повторяет выполнение метода настроенное число раз, если в цепочке возникло исключение, в том числе исключение от @Timeout.
  3. Применяется @CircuitBreakable и работает согласно своей конфигурации и состоянию, в зависимости от успешного результата метода или исключения в цепочке, включая исключения от @Timeout и @Retryable.
  4. Применяется @Fallback и вызывает метод getFallback с аргументом arg1, если в цепочке возникло исключение, включая исключения от @Timeout, @Retryable и @CircuitBreakable.

Порядок вызова аспектов соответствует порядку аннотаций на методе: сверху вниз, то есть самая верхняя аннотация — самая внешняя обёртка. @RateLimited встраивается в ту же цепочку и обычно ставится выше @CircuitBreakable, чтобы отклонённые вызовы вообще не доходили до circuit breaker.

Пример конфигурации для всех аспектов:

resilient {
    circuitbreaker {
        custom {
            type = FIXED_WINDOW
            countBased.windowSize = 1
            minimumRequiredCalls = 1
            failureRateThreshold = 100
            permittedCallsInHalfOpenState = 1
            waitDurationInOpenState = "1s"
        }
    }
    timeout {
        custom {
            duration = "300ms"
        }
    }
    retry {
        custom {
            delay = "100ms"
            attempts = 2
        }
    }
    ratelimiter {
        custom {
            limitForPeriod = 100
            limitRefreshPeriod = "1s"
        }
    }
}
resilient:
  circuitbreaker:
    custom:
      type: FIXED_WINDOW
      countBased:
        windowSize: 1
      minimumRequiredCalls: 1
      failureRateThreshold: 100
      permittedCallsInHalfOpenState: 1
      waitDurationInOpenState: "1s"
  timeout:
    custom:
      duration: "300ms"
  retry:
    custom:
      delay: "100ms"
      attempts: 2
  ratelimiter:
    custom:
      limitForPeriod: 100
      limitRefreshPeriod: "1s"

Порядок политик на практике

При явных вызовах API порядок применения механизмов виден непосредственно в коде:

// Общее ограничение времени на все попытки; CircuitBreaker учитывает каждую попытку.
var result = timeouter.execute(() -> retry.retry(() -> breaker.accept(() -> client.call())));

// CircuitBreaker учитывает итоговый результат; время каждой попытки ограничено отдельно.
var another = breaker.accept(() -> retry.retry(() -> timeouter.execute(() -> client.call())));
// Общее ограничение времени на все попытки; CircuitBreaker учитывает каждую попытку.
val result = timeouter.execute { retry.retry { breaker.accept { client.call() } } }

// CircuitBreaker учитывает итоговый результат; время каждой попытки ограничено отдельно.
val another = breaker.accept { retry.retry { timeouter.execute { client.call() } } }

Внедряйте Timeouter, Retry и CircuitBreaker с тегами соответствующих спецификаций, показанными выше. Не повторяйте вызов при CallNotPermittedException: настройте фильтр исключений, если CircuitBreaker находится внутри Retry. Стандартный фильтр повторов принимает исключения, которые не реализуют NonRetryableException; классифицировать ошибки приложения нужно самостоятельно.

Попытки и ограничения времени

attempts — число повторов после первого вызова: attempts = 2 допускает до трёх вызовов. При ограничении 500ms на вызов и задержках 100ms и 200ms максимальная длительность составит примерно 3 × 500 + 100 + 200 = 1800ms плюс накладные расходы. Внешнее ограничение времени действует на всю последовательность, внутреннее — на одну попытку.

Timeouter выполняет работу на отдельном виртуальном потоке и вызывает cancel(true) при превышении ограничения времени. Прерывание требует поддержки со стороны выполняемого кода: операция, которая игнорирует прерывание, может продолжиться. Настройте также ограничение времени самого клиента. Рабочий поток автоматически не наследует ScopedValue запроса; открывайте JDBC-транзакцию внутри функции, переданной в Timeouter, а не рассчитывайте на перенос внешней транзакции в другой поток.

Избегайте повторных попыток на нескольких уровнях

Три сервиса, каждый из которых делает до трёх попыток, могут вызвать зависимый сервис 3 × 3 × 3 = 27 раз. Выберите один уровень для повторных попыток, используйте возрастающую задержку со случайным разбросом и бюджет повторных попыток. Повторяйте запись только для идемпотентных операций. Учитывайте также повторы драйвера и HTTP-клиента. Стандартный бюджет действует в пределах одного экземпляра приложения; фабрика распределённого бюджета позволяет использовать общий бюджет через Redis. Ограничение частоты вызовов действует на число вызовов за период времени, а не на число одновременно выполняемых операций или размер пула соединений с базой.

Исключения

Все исключения отказоустойчивости наследуют io.koraframework.resilient.exception.ResilientException (наследник RuntimeException), который предоставляет name() — простое имя интерфейса-спецификации, вызвавшего ошибку.

Исключение Кем бросается Дополнительный API
ResilientException базовый тип для всех перечисленных ниже name()
CallNotPermittedException @CircuitBreakable / CircuitBreaker#acquire(), когда состояние OPEN либо HALF_OPEN без оставшихся пробных вызовов state() возвращает CircuitBreaker.State (OPEN / HALF_OPEN)
RetryExhaustedException @Retryable / Retry#retry(...), когда все попытки завершились неуспешно name(); в сообщении указано число попыток, последний отказ доступен через getCause(), предыдущие — в suppressed
TimeoutExhaustedException @Timeout / Timeouter#execute(...), когда метод превысил duration name()
RateLimitExceededException @RateLimited / RateLimiter#acquire(), когда нет доступного разрешения name()

Каждое исключение лежит в подпакете exception своего механизма, например io.koraframework.resilient.circuitbreaker.exception.CallNotPermittedException.

Описание — аспекты отказоустойчивости сигнализируют о сбое, выбрасывая одно из этих непроверяемых исключений из защищаемого метода.

Причины

  • CallNotPermittedException — circuit breaker обрывает вызовы, потому что доля отказов достигла failureRateThreshold; вызов отклонён без обращения к методу.
  • RetryExhaustedException — метод продолжал бросать повторяемое исключение, пока не были исчерпаны attempts; исходная ошибка доступна через getCause().
  • TimeoutExhaustedException — метод не завершился в пределах duration.
  • RateLimitExceededException — уже разрешённые вызовы исчерпали limitForPeriod разрешений: token bucket ещё не восполнился либо текущее окно FIXED_WINDOW исчерпано.

Рекомендации

  • Ловите ResilientException, чтобы единообразно обрабатывать любой отказ механизмов, либо конкретный тип, когда обработка различается.
  • При комбинировании аспектов исключение нижележащего аспекта поднимается по цепочке: например, TimeoutExhaustedException от @Timeout увидит @Retryable, затем @CircuitBreakable и в конце @Fallback. Предпочтительнее обработать это методом @Fallback или императивным резервным значением, чем превращать в ошибку, видимую пользователю.
  • Исключение, отклонённое фильтром, пробрасывается как есть и никогда не оборачивается, поэтому вызывающий код видит исходный тип.

Пример обработки:

try {
    return service.getValue();
} catch (CallNotPermittedException e) {
    log.warn("CircuitBreaker '{}' is {}", e.name(), e.state());
    return cachedValue();
} catch (TimeoutExhaustedException | RetryExhaustedException | RateLimitExceededException e) {
    log.warn("Resilient '{}' failed", e.name(), e);
    return cachedValue();
}
try {
    return service.value()
} catch (e: CallNotPermittedException) {
    log.warn("CircuitBreaker '{}' is {}", e.name(), e.state())
    return cachedValue()
} catch (e: ResilientException) { // TimeoutExhaustedException, RetryExhaustedException, RateLimitExceededException, ...
    log.warn("Resilient '{}' failed", e.name(), e)
    return cachedValue()
}

Сигнатуры

Доступные сигнатуры методов, которые поддерживают эти аннотации из коробки. Реактивные типы возвращаемого значения (Mono, Flux и любой другой Publisher) не поддерживаются ни одним из аспектов отказоустойчивости.

Класс должен быть не final, чтобы аспекты работали.

Под T подразумевается тип возвращаемого значения.

  • void myMethod()
  • T myMethod()
  • Optional<T> myMethod()
  • CompletionStage<T> myMethod() / CompletableFuture<T> myMethod() (CompletionStage) — поддерживается всеми аннотациями, кроме @RateLimited

Класс должен быть open, чтобы аспекты работали.

Под T подразумевается тип возвращаемого значения, либо T?, либо Unit.

CompletionStage и CompletableFuture в Kotlin не поддерживаются — используйте suspend.

Применение аспекта к неподдерживаемому типу возвращаемого значения приводит к ошибке компиляции вида @Retryable cannot be applied to '…' because return type '…' is not supported by this aspect.