Налаштування HTTP-кешування (ETag, Last-Modified, Vary) для API
HTTP-кешування вбудоване в протокол, але більшість API його ігнорують—повертають Cache-Control: no-cache або нічого. В результаті клієнти роблять одні й ті самі запити повторно, передаючи однакові дані по мережі. Правильно налаштоване умовне кешування зменшує трафік і навантаження на сервер при мінімальних змінах в коді.
Моделі кешування
Сильне кешування (Cache-Control: max-age): клієнт не звертається до сервера взагалі, поки не закінчиться TTL. Підходить для статичних даних: довідники, версіоновані ресурси.
Умовні запити (ETag / Last-Modified): клієнт звертається до сервера, але передає валідатор. Сервер відповідає 304 Not Modified без тіла, якщо дані не змінилися. Економить трафік, але не час відповіді.
Для API реального часу потрібна комбінація: коротка max-age для проміжних кешів + ETag для умовних запитів.
ETag
ETag—хеш представлення ресурсу. Клієнт отримує його у відповіді, зберігає, передає назад в If-None-Match:
# Перший запит
GET /api/v1/products/42 HTTP/1.1
# Відповідь сервера
HTTP/1.1 200 OK
ETag: "d41d8cd98f00b204e9800998ecf8427e"
Cache-Control: private, max-age=0, must-revalidate
Content-Type: application/json
{"id": 42, "name": "Widget", "price": 99.00}
# Повторний запит
GET /api/v1/products/42 HTTP/1.1
If-None-Match: "d41d8cd98f00b204e9800998ecf8427e"
# Якщо не змінилось
HTTP/1.1 304 Not Modified
ETag: "d41d8cd98f00b204e9800998ecf8427e"
Реалізація в Laravel:
public function show(Product $product): JsonResponse
{
$etag = md5($product->updated_at . $product->id);
if (request()->hasHeader('If-None-Match')) {
$clientEtag = trim(request()->header('If-None-Match'), '"');
if ($clientEtag === $etag) {
return response()->json(null, 304)
->header('ETag', '"' . $etag . '"');
}
}
return response()->json($product)
->header('ETag', '"' . $etag . '"')
->header('Cache-Control', 'private, max-age=0, must-revalidate');
}
Для колекцій ETag обчислюється за максимальним updated_at в вибірці:
$maxUpdated = $products->max('updated_at');
$count = $products->count();
$etag = md5($maxUpdated . $count . $page);
Last-Modified
Простіше за ETag, але менш точний—точність до секунди. Використовується разом або замість ETag:
$lastModified = $product->updated_at->toRfc7231String();
if (request()->hasHeader('If-Modified-Since')) {
$since = Carbon::parse(request()->header('If-Modified-Since'));
if ($product->updated_at->lte($since)) {
return response(null, 304)
->header('Last-Modified', $lastModified);
}
}
return response()->json($product)
->header('Last-Modified', $lastModified)
->header('Cache-Control', 'public, max-age=60');
Vary
Vary говорить проміжним кешам (CDN, proxy), які заголовки запиту впливають на відповідь. Без нього CDN закешує відповідь для одного варіанту і буде видавати її всім.
HTTP/1.1 200 OK
Cache-Control: public, max-age=300
Vary: Accept-Language, Accept-Encoding
Content-Language: ru
Типові випадки застосування Vary:
| Сценарій | Vary |
|---|---|
| Багатомовний API | Accept-Language |
| Стиснення gzip/br | Accept-Encoding |
| Версіонування через заголовок | Accept (якщо використовується content negotiation) |
| CORS з різними origin | Origin |
Проблема: Vary: Authorization — погана ідея для публічних CDN. Кожен унікальний токен створює окремий запис в кеші. Якщо потрібно кешувати авторизовані запити, використовуйте суррогатні ключі або кешуйте тільки на стороні клієнта.
Директиви Cache-Control для API
Cache-Control: public, max-age=300, s-maxage=600
-
public— можна кешувати на CDN/proxy -
private— тільки в браузері/клієнті -
max-age=N— TTL в секундах для клієнта -
s-maxage=N— TTL для shared caches (CDN), переопиняє max-age -
no-cache— завжди валідувати через умовний запит -
no-store— ніколи не кешувати (чутливі дані) -
must-revalidate— не використовувати застарілий кеш -
stale-while-revalidate=N— видавати застарілу відповідь N секунд поки йде оновлення -
stale-if-error=N— видавати застарілу відповідь при помилці upstream
Для фінансових даних, особистих профілів: Cache-Control: private, no-store.
Для публічного каталогу: Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=600.
Інвалідація кешу
ETag/Last-Modified не допомагають з інвалідацією—вони тільки підтверджують актуальність. Для примусового скидання кешу на CDN:
# Cloudflare API
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
--data '{"files":["https://api.example.com/v1/products/42"]}'
Суррогатні ключі (Cloudflare Cache-Tag, Fastly Surrogate-Key):
Cache-Tag: product-42, category-electronics
Surrogate-Key: product-42 category-electronics
Після оновлення товару—інвалідація всіх URL з тегом product-42 одним викликом.
Тестування
# Перевірити заголовки відповіді
curl -I https://api.example.com/v1/products/42
# Умовний запит з ETag
curl -H 'If-None-Match: "abc123"' https://api.example.com/v1/products/42
# Перевірити X-Cache від CDN
curl -v https://api.example.com/v1/products/42 2>&1 | grep -i 'x-cache\|age\|etag\|cache-control'
Терміни
Додавання ETag + Last-Modified до існуючого API: 2–4 дні (реалізація + тести + документація). Повна стратегія з Vary, Cache-Control за типами ресурсів, інвалідацією CDN і суррогатними ключами: 1–2 тижні.







