Опирается на правила: R-RES-TO-1R-RES-TO-3 и R-RES-TO-X1R-RES-TO-X3 из Resilience Style Guide → раздел 3. Timeouts.

Важно знать

  • Иерархия: connectTimeout < readTimeout < callTimeout. Никаких ∞.
  • connectTimeout — TCP/TLS handshake. Локальные DC: 2s. Через интернет: 5s. Никогда >10s.
  • readTimeout — между байтами ответа. 10s быстрые API, 30s тяжёлые, 60s отчёты. Если >60s — это async-pattern, не sync-вызов.
  • callTimeout — общий cap. Всегда ≥ connectTimeout + readTimeout + 1s buffer.
  • Timeouts — per-system в <system>ClientSettings (@ConfigurationProperties("client.<system>")).
  • При traceparent с TimeBudget — interceptor уважает оставшееся время: clientTimeout = min(callTimeout, remainingBudget - 100ms).
  • OkHttpClient без timeouts = default ∞ = зависание thread навсегда.

Без явных timeouts любой outbound HTTP может зависнуть навечно, если внешняя система медленно умирает (TCP-соединение установилось, но байты не приходят). Это классический сценарий «весь pool висит в read, никто не отвечает на новые запросы». Решение — три уровня timeout с понятной иерархией. Раскрытие раздела 3 гайда.

Иерархия трёх timeouts

R-RES-TO-1: OkHttp умеет три отдельных timeout, и важно понимать разницу:

TimeoutЧто измеряетТиповое
connectTimeoutВремя на TCP-handshake + TLS-handshake2s локально, 5s через интернет
readTimeoutМежду двумя последовательными байтами ответа (socket idle)10s быстрые, 30s тяжёлые, 60s отчёты
callTimeoutОбщий cap на весь запрос (от send до полного ответа)connectTimeout + readTimeout + 1s

Почему все три нужны:

  • connectTimeout сработает, если внешняя система не открывает TCP-сессию (network partition, host down).
  • readTimeout сработает, если соединение установлено, но сервер «застрял» (зависший backend, бесконечный stream).
  • callTimeout — последняя страховка: если по какой-то причине read возобновляется по байту в секунду, общий вызов всё равно прервётся.

Без callTimeout теоретически возможен зависший вызов на connectTimeout + N × readTimeout, если сервер шлёт байт раз в readTimeout - 1 секунд бесконечно.

Per-system конфиг через @ConfigurationProperties

R-RES-TO-2: timeouts — не magic-numbers в коде, а конфиг.

@ConfigurationProperties("client.sber")
@Validated
public record SberClientSettings(
    @NotBlank String baseUrl,
    @NotNull Duration connectTimeout,
    @NotNull Duration readTimeout,
    @NotNull Duration callTimeout,
    @Min(1) @Max(200) int maxConcurrent,
    @Min(1) int maxIdleConnections
) {}
client.sber:
  base-url: https://api.sber.example.com
  connect-timeout: 5s
  read-timeout: 30s
  call-timeout: 36s
  max-concurrent: 20
  max-idle-connections: 24

client.odnakassa:
  base-url: https://api.odnakassa.example.com
  connect-timeout: 5s
  read-timeout: 60s          # OdnaKassa возвращает большие multiset-ответы
  call-timeout: 66s
  max-concurrent: 30
  max-idle-connections: 36

Что важно:

  • @Validated на settings-классе. Невалидная конфигурация падает на старте, не на первом запросе (см. Validation → Configuration validation).
  • Duration-типы, не int seconds. Spring сам парсит 5s, 30s, PT1M30S. Семантика прозрачна.
  • Отклонения от типовых — с комментарием в yml (например, «OdnaKassa возвращает большие multiset-ответы» — это объясняет, почему read-timeout 60s).

Уважение TimeBudget при traceparent

R-RES-TO-3: если входящий запрос содержит traceparent + custom X-Time-Budget header (см. REST API → заголовки и трассировка), client-side timeout сжимается до оставшегося бюджета.

public class TimeBudgetInterceptor implements Interceptor {

    @Override
    public Response intercept(Chain chain) throws IOException {
        Duration remainingBudget = TimeBudgetContext.remaining();
        if (remainingBudget != null && remainingBudget.compareTo(Duration.ofMillis(100)) < 0) {
            throw new InterruptedIOException("time budget exhausted");
        }

        Request original = chain.request();
        if (remainingBudget != null) {
            Duration newTimeout = Duration.ofMillis(
                Math.min(chain.callTimeoutMillis(), remainingBudget.toMillis() - 100)
            );
            return chain
                .withCallTimeout((int) newTimeout.toMillis(), TimeUnit.MILLISECONDS)
                .proceed(original);
        }
        return chain.proceed(original);
    }
}

Зачем: если upstream-вызов сказал «у тебя 2 секунды на ответ», а текущий outbound идёт 30 секунд — это deadline-violation: upstream уже устал и закрыл соединение, а наш сервис ещё ждёт от внешней системы. Сжатие timeout под бюджет даёт fail-fast: лучше отдать 504 сейчас, чем зря держать ресурсы.

Что запрещено

OkHttpClient без timeouts

R-RES-TO-X1: дефолтный new OkHttpClient.Builder().build() имеет timeouts = 0 (∞). Зависание = thread навсегда.

// ПЛОХО — default ∞ на всех уровнях
@Bean
OkHttpClient client() {
    return new OkHttpClient.Builder().build();    // ← никаких timeouts
}

Какое-то время это «работает» — пока внешняя система отвечает. Первый же зависший response убивает thread, второй убивает второй, и через 200 запросов весь thread-pool умер.

callTimeout меньше readTimeout

R-RES-TO-X2: иерархия должна быть строгой. callTimeout < readTimeout — внутреннее противоречие.

# ПЛОХО — callTimeout сработает раньше, чем readTimeout успеет что-то поймать
client.sber:
  connect-timeout: 5s
  read-timeout: 30s
  call-timeout: 20s       # ← меньше read!

Что не так: callTimeout: 20s сработает раньше, чем readTimeout: 30s. Поведение клиента непредсказуемо: иногда падаем по readTimeout, иногда по callTimeout. Метрики путаются, error-handling не работает корректно.

Корректно: callTimeout = connectTimeout + readTimeout + buffer 1s.

readTimeout > 60s для sync-вызова

R-RES-TO-X3: если для синхронного HTTP-вызова нужен read-timeout больше 60 секунд — это признак async-pattern, не sync.

# ПЛОХО — синхронный вызов с read-timeout 5 минут
client.reports:
  connect-timeout: 5s
  read-timeout: 300s      # 5 минут для отчёта
  call-timeout: 310s

Что не так:

  • Worker-thread блокирован на 5 минут. Сервис при концурентной нагрузке кончает thread-pool.
  • Upstream-вызов из браузера/API скорее всего сам имеет timeout <60s — клиент уже сдался, а мы ещё «работаем».
  • Если внешняя система реально нуждается в 5 минутах, ей нужен async-pattern: POST /reports → 202 Accepted + task-id, GET /reports/{id} для статуса.

Корректно: task-queue с polling (см. Async и polling), либо webhook-callback от внешней системы.

Что запрещено — таблица

АнтипаттернПравилоЧто взамен
OkHttpClient без timeouts (default ∞)R-RES-TO-X1Явные connect/read/call
callTimeout < readTimeout или callTimeout < connectTimeoutR-RES-TO-X2callTimeout ≥ connect + read + buffer
readTimeout > 60s для sync-вызоваR-RES-TO-X3Task-queue / async-pattern
Timeouts в коде magic-numbersR-RES-TO-2@ConfigurationProperties per-system
Игнорирование TimeBudget при traceparentR-RES-TO-3Interceptor сжимает timeout до remaining
Один глобальный set timeouts для всех системR-RES-ISO-X1Per-system <system>ClientSettings

Куда дальше

  • Resilience → раздел 3. Timeouts — нормативные R-RES-TO-*.
  • Per-system isolation — отдельный OkHttpClient на каждую систему.
  • Circuit Breaker — slowCallDurationThreshold срабатывает раньше timeout.
  • Async и polling — если нужно >60s.
  • Конфигурация — declarative config Resilience4j.
  • Validation → Configuration validation — @Validated на settings-классе.
  • REST API Style Guidetraceparent и X-Time-Budget.