Отказоустойчивость
Модуль для построения отказоустойчивого приложения с помощью таких механизмов, как CircuitBreaker, Retry, Timeout, RateLimiter и Fallback.
Каждый механизм, кроме Fallback, описывается интерфейсом-спецификацией — типизированным контрактом,
который указывает на путь в конфигурации. Аннотация на защищаемом методе ссылается на этот интерфейс, поэтому связь
метода с его настройками отказоустойчивости проверяет компилятор, а не строковое совпадение имён.
ResilientModule объединяет CircuitBreakerModule, RetryModule, TimeoutModule, FallbackModule и RateLimiterModule.
Пошаговое введение перед справочником — в разделе Отказоустойчивость.
Подключение¶
Зависимость build.gradle:
Модуль:
Зависимость build.gradle.kts:
Модуль:
Обработчик аннотаций (annotation-processors) либо KSP-обработчик (symbol-processors) обязателен: он одновременно
генерирует реализации спецификаций и применяет аспекты.
Спецификации¶
Спецификация — это интерфейс, который наследует контракт отказоустойчивости и помечен аннотацией с путём конфигурации:
| Аннотация метода | Аннотация спецификации | Контракт, который наследует интерфейс | Пакет |
|---|---|---|---|
@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.
@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);
}
}
- Полный путь секции конфигурации, которая описывает этот экземпляр.
- Аспект связывается с типом спецификации, а не со строковым именем.
@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)
}
- Полный путь секции конфигурации, которая описывает этот экземпляр.
- Аспект связывается с типом спецификации, а не со строковым именем.
Что обработчик делает со спецификацией:
- генерирует её реализацию и модуль, который её публикует; модуль подхватывается
@KoraAppавтоматически — вручную ничего подключать не нужно; - публикует сам интерфейс спецификации как компонент графа приложения, поэтому его можно внедрить для императивного использования;
- читает конфигурацию ровно из того пути, который указан в аннотации.
Один экземпляр на спецификацию
Все методы, помеченные одной и той же спецификацией, разделяют один экземпляр, а значит одно состояние и один набор метрик. Если два метода не должны влиять на состояние 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, описана в классе 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)!
}
}
}
}
}
- Реализация окна вызовов:
STRIPED_APPROX,FIXED_WINDOW,RING_BUFFERилиTIME_BASED(по умолчанию:STRIPED_APPROX). - Процент неуспешных запросов, необходимый для перехода в
OPEN; значение должно быть от1до100(обязательное, без значения по умолчанию). - Минимальное число запросов, необходимое для начала расчёта состояния (обязательное, без значения по умолчанию).
- Время ожидания в
OPEN, по истечении которого выполняется переход вHALF_OPEN(обязательное, без значения по умолчанию). - Число запросов в
HALF_OPEN, которые должны завершиться успешно для перехода вCLOSED(обязательное, без значения по умолчанию). - Включение или отключение
CircuitBreaker(по умолчанию:true). - Максимальное число запросов, используемых для расчёта
failureRateThresholdи определения состояния (обязательное для всех типов, кромеTIME_BASED, без значения по умолчанию). - Количество независимых полос счётчиков, от
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)!
- Реализация окна вызовов:
STRIPED_APPROX,FIXED_WINDOW,RING_BUFFERилиTIME_BASED(по умолчанию:STRIPED_APPROX). - Процент неуспешных запросов, необходимый для перехода в
OPEN; значение должно быть от1до100(обязательное, без значения по умолчанию). - Минимальное число запросов, необходимое для начала расчёта состояния (обязательное, без значения по умолчанию).
- Время ожидания в
OPEN, по истечении которого выполняется переход вHALF_OPEN(обязательное, без значения по умолчанию). - Число запросов в
HALF_OPEN, которые должны завершиться успешно для перехода вCLOSED(обязательное, без значения по умолчанию). - Включение или отключение
CircuitBreaker(по умолчанию:true). - Максимальное число запросов, используемых для расчёта
failureRateThresholdи определения состояния (обязательное для всех типов, кромеTIME_BASED, без значения по умолчанию). - Количество независимых полос счётчиков, от
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)!
}
}
}
}
- Длительность временного окна, по которому считается доля отказов (обязательное для
TIME_BASED, без значения по умолчанию). - Число корзин, на которые делится окно, от
1до1024(по умолчанию:16). - Число независимых полос счётчиков внутри корзины, от
1до64(по умолчанию:16). - Реализация счётчиков:
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)!
- Длительность временного окна, по которому считается доля отказов (обязательное для
TIME_BASED, без значения по умолчанию). - Число корзин, на которые делится окно, от
1до1024(по умолчанию:16). - Число независимых полос счётчиков внутри корзины, от
1до64(по умолчанию:16). - Реализация счётчиков:
ATOMICдаёт предсказуемый сброс,LONG_ADDERбыстрее при высокой конкуренции ценой более приблизительного сброса на границах корзин (по умолчанию:ATOMIC).
Фильтрация исключений¶
По умолчанию CircuitBreaker считает отказом любую ошибку. Изменить это можно двумя способами.
Самый простой — переопределить isFailure прямо в интерфейсе-спецификации: без отдельного компонента и без конфигурации:
Второй способ — компонент 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;
}
}
- Привязывает предикат к одной спецификации; без тега предикат не будет использован.
- Возврат
trueозначает, что исключение засчитывается как отказ.
@Tag(CustomCircuitBreaker::class) //(1)!
@Component
class MyFailurePredicate : CircuitBreakerPredicate {
override fun isCircuitBreakerFailure(throwable: Throwable): Boolean = //(2)!
throwable !is HttpServerResponseException || throwable.code() >= 500
}
- Привязывает предикат к одной спецификации; без тега предикат не будет использован.
- Возврат
trueозначает, что исключение засчитывается как отказ.
Исключение, отклонённое фильтром, не засчитывается ни как отказ, ни как успех: circuit breaker его просто игнорирует, а вызывающему коду оно возвращается без изменений.
Императивное использование¶
Интерфейс спецификации — обычный компонент графа приложения, поэтому в императивном коде его достаточно внедрить напрямую:
@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
}
}
Методы accept принимают ThrowableCallable<T, E> или ThrowableRunnable<E> из пакета io.koraframework.resilient.common,
поэтому защищаемый код может бросать проверяемые исключения.
Чтобы в состоянии OPEN вернуть резервное значение вместо CallNotPermittedException, используйте перегрузку accept со вторым аргументом:
Если защищаемый вызов не удаётся обернуть в один callable, разрешение можно получать и освобождать вручную.
Вызовите acquire() (бросает CallNotPermittedException, когда состояние OPEN либо HALF_OPEN и все пробные вызовы уже израсходованы), а затем обязательно сообщите результат через releaseOnSuccess() или releaseOnError(Throwable) — иначе разрешение утечёт и учёт вызовов станет неверным:
tryAcquire() — вариант без исключения: он возвращает false, когда вызов не разрешён, и позволяет ветвиться без перехвата CallNotPermittedException.
Если же acquire() бросил исключение, текущее состояние (OPEN или HALF_OPEN) доступно через CallNotPermittedException#state().
Retry¶
Retry даёт возможность настроить повторные вызовы аннотированных методов.
Он позволяет указать, когда метод следует повторить, и настроить параметры повторов, если метод бросает исключение, подходящее под настроенный фильтр.
Декларативное использование¶
Когда все попытки исчерпаны, вызов завершается исключением RetryExhaustedException.
Конфигурация¶
Секция, на которую указывает @RetrySpec, описана в классе RetryConfig:
resilient {
retry {
custom {
delay = "100ms" //(1)!
attempts = 2 //(2)!
delayStep = "100ms" //(3)!
enabled = true //(4)!
}
}
}
- Начальная задержка перед повторным вызовом (обязательное, без значения по умолчанию).
- Количество повторных попыток (обязательное, без значения по умолчанию).
- Приращение задержки для последующих попыток; игнорируется, если задана секция
backoff(по умолчанию:0). - Включение или отключение
Retry(по умолчанию:true).
resilient:
retry:
custom:
delay: "100ms" #(1)!
attempts: 2 #(2)!
delayStep: "100ms" #(3)!
enabled: true #(4)!
- Начальная задержка перед повторным вызовом (обязательное, без значения по умолчанию).
- Количество повторных попыток (обязательное, без значения по умолчанию).
- Приращение задержки для последующих попыток; игнорируется, если задана секция
backoff(по умолчанию:0). - Включение или отключение
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)!
}
}
}
}
- Стратегия роста задержки; поддерживается единственное значение
EXPONENTIAL(по умолчанию:EXPONENTIAL). - Множитель, применяемый на каждой попытке, должен быть больше
0(по умолчанию:2.0). - Верхняя граница вычисленной задержки (опционально, по умолчанию не ограничена).
- Стратегия разброса:
NONEотключает его,FULLрандомизирует задержку (по умолчанию:NONE). - Доля вычисленной задержки, которая может быть вычтена, значение в диапазоне
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)!
- Стратегия роста задержки; поддерживается единственное значение
EXPONENTIAL(по умолчанию:EXPONENTIAL). - Множитель, применяемый на каждой попытке, должен быть больше
0(по умолчанию:2.0). - Верхняя граница вычисленной задержки (опционально, по умолчанию не ограничена).
- Стратегия разброса:
NONEотключает его,FULLрандомизирует задержку (по умолчанию:NONE). - Доля вычисленной задержки, которая может быть вычтена, значение в диапазоне
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)!
}
}
}
}
- Включение или отключение бюджета (по умолчанию:
true). - Сколько токенов добавляет успешный вызов —
0.1разрешает примерно один повтор на десять успешных вызовов (по умолчанию:0.1). - Верхняя граница ведра (по умолчанию:
100). - Начальное количество токенов, не должно превышать
tokensMax(по умолчанию:10). - Гарантированная скорость пополнения, которая работает даже без успешных вызовов (по умолчанию:
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)!
- Включение или отключение бюджета (по умолчанию:
true). - Сколько токенов добавляет успешный вызов —
0.1разрешает примерно один повтор на десять успешных вызовов (по умолчанию:0.1). - Верхняя граница ведра (по умолчанию:
100). - Начальное количество токенов, не должно превышать
tokensMax(по умолчанию:10). - Гарантированная скорость пополнения, которая работает даже без успешных вызовов (по умолчанию:
0.0).
Note
Бюджет выключен, пока секция retryBudget не объявлена. Объявление её с enabled = false также оставляет бюджет выключенным.
Фильтрация исключений¶
По умолчанию Retry повторяет вызов при любой ошибке, а два способа это сузить повторяют подход
circuit breaker: переопределить isFailure в спецификации либо зарегистрировать компонент
RetryPredicate с тегом спецификации. Если есть оба, побеждает компонент с тегом.
@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.
Императивное использование¶
Внедрите интерфейс спецификации и вызывайте его напрямую:
Чтобы после исчерпания всех попыток вернуть резервное значение вместо RetryExhaustedException, передайте второй callable:
Для асинхронного императивного кода есть перегрузка, которая повторяет 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, описана в классе TimeoutConfig:
- Ограничение времени операции, по истечении которого будет брошено
TimeoutExhaustedException(обязательное, без значения по умолчанию). - Включение или отключение
Timeout(по умолчанию:true).
Ключ telemetry внутри той же секции переопределяет общемодульные настройки из раздела Телеметрия.
Note
duration обязателен, без него приложение не стартует.
Значение enabled = false превращает @Timeout в прозрачный проброс — метод выполняется без ограничения времени.
Исключение, брошенное методом до истечения лимита, пробрасывается без изменений, включая проверяемые исключения.
Императивное использование¶
Внедрите интерфейс спецификации и вызывайте его напрямую:
У Timeouter также есть перегрузка execute для операций, ничего не возвращающих, а timeout() возвращает настроенный Duration:
RateLimiter¶
RateLimiter ограничивает, сколько раз метод может быть вызван за период. Ограничитель работает как счётчик с
фиксированным окном: он выдаёт limitForPeriod разрешений, а счётчик восстанавливается до этого значения на первом вызове
после того, как прошёл limitRefreshPeriod. Получение разрешения никогда не блокирует — вызов, для которого разрешений
не осталось, сразу падает с RateLimitExceededException.
Декларативное использование¶
Конфигурация¶
Секция, на которую указывает @RateLimiterSpec, описана в классе RateLimiterConfig:
resilient {
ratelimiter {
custom {
limitForPeriod = 100 //(1)!
limitRefreshPeriod = "1s" //(2)!
enabled = true //(3)!
}
}
}
- Количество вызовов, разрешённых в пределах одного периода (обязательное, без значения по умолчанию).
- Длительность периода, по истечении которого разрешения восстанавливаются (обязательное, без значения по умолчанию).
- Включение или отключение
RateLimiter(по умолчанию:true).
resilient:
ratelimiter:
custom:
limitForPeriod: 100 #(1)!
limitRefreshPeriod: "1s" #(2)!
enabled: true #(3)!
- Количество вызовов, разрешённых в пределах одного периода (обязательное, без значения по умолчанию).
- Длительность периода, по истечении которого разрешения восстанавливаются (обязательное, без значения по умолчанию).
- Включение или отключение
RateLimiter(по умолчанию:true).
Ключ telemetry внутри той же секции переопределяет общемодульные настройки из раздела Телеметрия.
Note
Значение enabled = false превращает @RateLimited в прозрачный проброс — разрешается любой вызов.
Ограничитель работает в пределах одного экземпляра приложения: при нескольких репликах фактический лимит равен limitForPeriod, умноженному на число реплик.
Императивное использование¶
Внедрите интерфейс спецификации и вызывайте его напрямую:
@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
}
}
- Получает разрешение и выполняет операцию, бросая
RateLimitExceededException, когда лимит исчерпан. - Вариант без исключения: возвращает
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
}
}
- Получает разрешение и выполняет операцию, бросая
RateLimitExceededException, когда лимит исчерпан. - Вариант без исключения: возвращает
falseвместо ошибки.
acquire() забирает разрешение, ничего не выполняя, и бросает RateLimitExceededException, когда разрешений не осталось.
Fallback¶
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();
}
}
- Такой параметр допускается не более одного, и он не входит в список аргументов в
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
}
- Такой параметр допускается не более одного, и он не входит в список аргументов в
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)!
}
}
}
- Секции:
circuitBreaker,retry,timeout,fallback,rateLimiter. - Включает логирование механизма (по умолчанию:
false). - Включает метрики механизма (по умолчанию:
false). - Дополнительные теги, добавляемые ко всем метрикам механизма (по умолчанию: пусто).
- Включает трассировку механизма (по умолчанию:
false). - Дополнительные атрибуты, добавляемые ко всем спанам механизма (по умолчанию: пусто).
- Переопределение для конкретной спецификации; незаданные ключи берутся из
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)!
- Секции:
circuitBreaker,retry,timeout,fallback,rateLimiter. - Включает логирование механизма (по умолчанию:
false). - Включает метрики механизма (по умолчанию:
false). - Дополнительные теги, добавляемые ко всем метрикам механизма (по умолчанию: пусто).
- Включает трассировку механизма (по умолчанию:
false). - Дополнительные атрибуты, добавляемые ко всем спанам механизма (по умолчанию: пусто).
- Переопределение для конкретной спецификации; незаданные ключи берутся из
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
}
В примере выше:
- Применяется
@Timeoutи проверяет, что метод не выполняется дольше указанного в конфигурации времени. - Применяется
@Retryableи повторяет выполнение метода настроенное число раз, если в цепочке возникло исключение, в том числе исключение от@Timeout. - Применяется
@CircuitBreakableи работает согласно своей конфигурации и состоянию, в зависимости от успешного результата метода или исключения в цепочке, включая исключения от@Timeoutи@Retryable. - Применяется
@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"
Исключения¶
Все исключения отказоустойчивости наследуют 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— в текущемlimitRefreshPeriodуже разрешеноlimitForPeriodвызовов.
Рекомендации
- Ловите
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.
myMethod(): Tsuspend myMethod(): T(Kotlin Coroutines, требует зависимости какimplementation)myMethod(): Flow<T>(Kotlin Coroutines, требует зависимости какimplementation)
CompletionStage и CompletableFuture в Kotlin не поддерживаются — используйте suspend.
Применение аспекта к неподдерживаемому типу возвращаемого значения приводит к ошибке компиляции вида
@Retryable cannot be applied to '…' because return type '…' is not supported by this aspect.