1. Таймаути як частина коректності
Віддалений HTTP-виклик — це не «швидкий метод, який іноді повертає JSON». Це подорож у реальний світ, де бувають затори, зачинені двері та загадкові таблички «перерва 15 хвилин» без указання, коли саме вона почалася. Таймаути потрібні не заради швидкості, а заради передбачуваності: щоб ваш застосунок міг чесно сказати: «Я зачекав достатньо, далі — безглуздо».
Якщо таймаутів немає, ви віддаєте керування часу, операційній системі та мережі. Іноді все працюватиме нормально. Іноді виклик висітиме дуже довго, бо десь дорогою пакет загубився, роутер замислився над життям, а ваш потік у цей час просто стоїть і чекає. У консольному застосунку це виглядає як «програма зависла». У бекенд-світі це ще гірше: ви займаєте потік і не можете обслуговувати інші завдання.
У нашому ReadLater Starter зараз є команди на кшталт catalog search .... Користувач запускає команду й очікує, що або отримає результат, або швидко й зрозуміло дізнається, що каталог недоступний. Таймаути — це саме про «швидко й зрозуміло».
На цьому етапі у нас уже є дві базові речі: коректний URI і зрозумілий HttpRequest. Але send() усе одно живе за правилами мережі, а не за правилами звичайного методу Java. Наступне питання для транспортного шару дуже приземлене: скільки ми готові чекати й що робити, якщо HttpResponse так і не з’явився.
2. Підключення і відповідь: два таймаути
Коли ми говоримо «сервіс не відповідає», це звучить як одна проблема, але технічно там щонайменше два різні очікування. Спочатку клієнт має підключитися до віддаленого хоста — це окрема стадія. Потім він має отримати HTTP-відповідь — це вже інша стадія. І таймаути на цих стадіях різні, бо різні й причини «зависання».
Схематично життєвий цикл синхронного запиту виглядає так:
flowchart TD
A[Маємо URI і HttpRequest] --> B[Намагаємося встановити з’єднання]
B -->|успіх| C[Надсилаємо запит]
C --> D[Чекаємо на відповідь]
D -->|успіх| E[Отримали HttpResponse]
B -->|занадто довго| X["таймаут підключення"]
D -->|занадто довго| Y["таймаут запиту"]
Тепер закріпимо різницю у вигляді невеликої таблиці. Вона буде вашою «шпаргалкою на стіну»:
| Що обмежуємо за часом | Де задаємо | Чим вимірюємо | Що зазвичай відбувається, коли час перевищено |
|---|---|---|---|
| Установлення з’єднання (підключення до віддаленого хоста) | |
Duration | викидається виняток із сімейства HttpTimeoutException (часто HttpConnectTimeoutException) |
| Очікування відповіді на конкретний запит | |
Duration | викидається HttpTimeoutException (це теж IOException) |
Важливо: обидва таймаути — це не про «прискорити мережу», а про «дати застосунку право зупинитися». Жодної магії. Ми просто кажемо: «Ось стільки часу я готовий чекати, а далі вважаю виклик проваленим».
3. connectTimeout: час підключення
connectTimeout ми задаємо на рівні HttpClient. Логіка проста: оскільки клієнт — це «двигун», який працює в мережі, то правило «скільки чекати підключення» зазвичай спільне й повторюване. Сьогодні ми не будуємо складну систему конфігурації — це буде пізніше в курсі, — тому візьмемо розумне навчальне значення й зробимо його явним прямо в коді.
Мінімальний приклад. Зверніть увагу: тут немає запиту — це лише налаштування клієнта:
import java.net.http.HttpClient;
import java.time.Duration;
// Налаштовуємо «двигун», який працюватиме в мережі
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2)) // Скільки максимально чекаємо на встановлення з’єднання
.build(); // Клієнт зазвичай створюють один раз і використовують повторно
У цьому місці в новачків часто виникає питання: «А 2 секунди — це нормально?» Відповідь: для навчального проєкту — більш ніж. У реальному світі значення залежать від середовища та вимог, але сама звичка важливіша за цифру. Якщо поставити connectTimeout на 0.1 секунди, ви отримаєте багато хибних відмов. Якщо поставити на хвилину — програма буде «зависати» надто довго. Двох-трьох секунд зазвичай достатньо, щоб відчути: «ми спробували, але не вічність».
Ще один маленький нюанс, який корисно знати: HttpClient розрахований на повторне використання. Тобто «створити один раз і потім використовувати для запитів» — нормальна стратегія. Якщо ви створюєте новий HttpClient на кожен запит, ви не «зламаєте інтернет», але додасте собі зайвого шуму в коді й ускладните контроль над налаштуваннями.
4. HttpRequest.timeout(...): очікування відповіді
Таймаут запиту (HttpRequest.Builder.timeout(...)) задається для конкретного запиту. Це зручно: пошук може бути «швидким», а отримання деталей — трохи «терплячішим», або навпаки. Нам зараз важлива сама механіка і те, як вона змінює поведінку client.send(...).
Приклад GET із таймаутом на рівні запиту:
import java.net.URI;
import java.net.http.HttpRequest;
import java.time.Duration;
URI uri = URI.create("https://catalog.example/search?q=java"); // Адреса віддаленого каталогу
HttpRequest request = HttpRequest.newBuilder(uri)
.header("Accept", "application/json") // Просимо JSON у відповіді
.timeout(Duration.ofSeconds(3)) // Скільки максимально чекаємо на відповідь саме на цей запит
.GET() // HTTP-метод
.build();
Тут хороша читабельність згори донизу: спочатку адреса, потім очікування щодо формату (Accept), потім обмеження за часом (timeout), потім метод. Builder дозволяє зробити так, що запит виглядає як міні-документ: «що я хочу і на яких умовах».
Якщо ви не задасте timeout(...) взагалі, запит може чекати дуже довго. Так, іноді все відбувається швидко. Але за мережевих проблем ви отримаєте «вічне очікування». Тому, якщо ви хочете дорослий клієнт, таймаути — не опція, а значення за замовчуванням.
5. Збої client.send(...): 3 сценарії
Найважливіше тут — перестати думати, що «помилка = статус 500». Для Java HTTP-клієнта є ситуації, коли жодного HttpResponse не буде, бо запит не завершився на мережевому рівні. І тоді ви отримуєте виняток. У client.send(...) у синхронній формі є три базові види неприємностей: timeout, мережевий IOException і InterruptedException.
Подивімося на мінімальний, але чесний шаблон обробки:
import java.io.IOException;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
try {
// Синхронний виклик: поточний потік чекає завершення запиту або винятку
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println("Статус = " + response.statusCode()); // наприклад: Статус = 200
} catch (HttpTimeoutException e) {
// Таймаут — окремий випадок IOException, тому ловимо його окремо і раніше
System.out.println("Каталог відповідає занадто довго"); // приклад повідомлення користувачеві
} catch (IOException e) {
// Сюди потрапляють інші мережеві проблеми (DNS, TLS, розрив з’єднання тощо)
System.out.println("Мережева помилка: " + e.getClass().getSimpleName());
} catch (InterruptedException e) {
// Потік попросили зупинитися: коректно відновлюємо прапорець переривання
Thread.currentThread().interrupt();
System.out.println("Запит було перервано");
}
Зверніть увагу на порядок catch. HttpTimeoutException — це окремий випадок IOException. Якщо ви спочатку зловите IOException, то до HttpTimeoutException код ніколи не дійде, і ви втратите можливість дати користувачеві точніше повідомлення.
Тепер про InterruptedException. Це не «ще одна мережева помилка». Це сигнал: «поточний потік попросили зупинитися». У навчальному CLI це трапляється рідко, але правильно реагувати потрібно завжди. Мінімально коректна реакція — відновити прапорець переривання через Thread.currentThread().interrupt(); і припинити нормальну обробку. Якщо ви проковтнете переривання й продовжите працювати, можна отримати дуже дивну поведінку далі, і, що гірше, це виглядатиме як «рандом».
6. IOException: зберігаємо зміст
IOException — це великий парасольковий тип, під яким ховається багато різних причин: не знайшли хост (UnknownHostException), у з’єднанні відмовлено (часто, коли неправильний порт), проблеми з мережею, проблеми TLS тощо. На цьому етапі курсу нам не потрібно влаштовувати екскурсію по всіх видах мережевих винятків, але важлива одна річ: не перетворюйте IOException на «ну, щось зламалося».
Навіть просте повідомлення, яке виводить тип винятку, уже дає вам діагностичну опору:
} catch (IOException e) {
// Загальний випадок: DNS, відмова в з’єднанні, TLS тощо
System.out.println("Мережева помилка під час виклику каталогу: " + e.getClass().getSimpleName());
System.out.println("Деталі: " + e.getMessage()); // Текст залежить від конкретної причини
}
Зверніть увагу: це не «логування» в сенсі production-логів — у нас поки що просто консольний режим, і ми чесно показуємо користувачу та розробнику, що сталося. Пізніше, за траєкторією курсу, ви перейдете до нормального стеку логування, але звичка зберігати зміст помилки потрібна вже зараз.
Ще один важливий принцип: не робіть catch (Exception e). Він здається зручним, але стирає межу між timeout, мережевою помилкою і перериванням. А ми якраз вчимося бачити відмінності. HTTP-клієнту потрібен не «універсальний ловець», а усвідомлена розвилка.
7. Таймаути без «вдачі»
Іноді новачки намагаються «зловити таймаут» на справжньому API й дивуються: «а він не ловиться, усе швидко». І це нормальна проблема: хороший сервіс зазвичай відповідає швидко, а нам для навчання потрібно побачити гілку з помилкою. Тому для демонстрації таймауту найпростіше зробити хитрий, але трохи жорстокий трюк: поставити занадто малий request timeout і подивитися, як поводиться клієнт.
Наприклад, ось такий запит майже гарантовано не встигне:
import java.net.URI;
import java.net.http.HttpRequest;
import java.time.Duration;
HttpRequest request = HttpRequest.newBuilder(URI.create("https://catalog.example/search?q=java"))
.header("Accept", "application/json") // Просимо JSON у відповіді
.timeout(Duration.ofMillis(1)) // Демонстраційний «майже гарантований» таймаут
.GET() // HTTP-метод
.build();
Під час виклику client.send(...) ви з великою ймовірністю отримаєте HttpTimeoutException, і спрацює ваш catch, який виводить «Каталог відповідає занадто довго». Це штучно, зате дуже наочно: ви бачите, що гілка timeout справді працює й не плутається з IOException.
Із connectTimeout схожа історія: іноді за неправильної адреси ви отримаєте не timeout, а, наприклад, UnknownHostException (домен не знайдено) або «connection refused» (порт закрито). І це теж корисно, бо показує: помилка «сервіс недоступний» може виглядати по-різному, і ваш код повинен бути готовий до цього. Наша мета — не вгадати один конкретний текст помилки, а навчитися розрізняти категорії проблем.
8. Типові помилки під час роботи з таймаутами
Мережевий код — це місце, де майже кожен новачок хоча б раз наступає на одні й ті самі граблі. Це нормально: мережа просто дуже не схожа на звичайний виклик методів. Але ці помилки краще побачити зараз на маленькому навчальному клієнті, ніж потім у великому проєкті, де вони зазвичай трапляються о 3-й годині ночі, коли ви просто хотіли поспати.
Помилка № 1: «таймаути не потрібні, у мене ж усе локально».
Навіть якщо ваш сервіс зазвичай відповідає швидко, це не гарантія. Будь-яка мережа іноді підвисає. Будь-який провайдер іноді деградує. Якщо ви не задаєте таймаути, ваша програма не вибирає, як довго чекати, — за неї це роблять ОС і обставини. У результаті користувач бачить зависання й не розуміє, що відбувається.
Помилка № 2: ловити IOException раніше HttpTimeoutException і дивуватися, що «timeout не працює».
HttpTimeoutException — це IOException. Тому порядок catch важливий. Якщо ви хочете окрему гілку для таймаутів, а ви майже завжди хочете саме так, ловіть HttpTimeoutException першою.
Помилка № 3: проковтнути InterruptedException і продовжити виконання.
Переривання — це не «неприємність», це протокол зупинки. Мінімально коректно: Thread.currentThread().interrupt(); і припинення обробки. Якщо ви цього не робите, можна отримати дивні проблеми в подальшому коді, бо потік «просили зупинитися», а ви зробили вигляд, що не чуєте.
Помилка № 4: ставити екстремально малі таймаути «щоб було швидко».
Таймаут — це не прискорювач інтернету, а обмежувач очікування. Занадто малий таймаут робить із вашого клієнта примхливого відвідувача: «я постояв біля дверей 50 мс і пішов». У реальному світі це породжуватиме хибні помилки. Ставте значення так, щоб вони виглядали як розумне очікування для людини чи системи, а не як спроба перемогти фізику.
Помилка № 5: ловити все через catch(Exception e) і виводити «Помилка».
Такий код здається «надійним», але він убиває зміст. Ви перестаєте розрізняти timeout від мережевої помилки і, особливо, від переривання. У результаті застосунок стає непередбачуваним у поведінці, а діагностика перетворюється на ворожіння на кавовій гущі.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ