Опирается на правила:
R-RES-TO-1…R-RES-TO-3иR-RES-TO-X1…R-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-handshake | 2s локально, 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 < connectTimeout | R-RES-TO-X2 | callTimeout ≥ connect + read + buffer |
readTimeout > 60s для sync-вызова | R-RES-TO-X3 | Task-queue / async-pattern |
| Timeouts в коде magic-numbers | R-RES-TO-2 | @ConfigurationProperties per-system |
Игнорирование TimeBudget при traceparent | R-RES-TO-3 | Interceptor сжимает timeout до remaining |
| Один глобальный set timeouts для всех систем | R-RES-ISO-X1 | Per-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 Guide —
traceparentиX-Time-Budget.