Каталог FastAPI не изменился: отвечаем 304 по If-None-Match

Мобильное приложение регулярно перечитывает небольшой каталог. Большинство ответов одинаковы, но сервер каждый раз отправляет весь JSON. Клиенту достаточно узнать, изменилась ли сохранённая версия. Для этого HTTP позволяет прислать метку предыдущего представления: при совпадении сервер вернёт 304 без тела, а клиент продолжит использовать свой сохранённый ответ.

Метка относится к представлению ответа

ETag — непрозрачная метка версии представления. В примере она вычисляется из тех байтов JSON, которые сервер действительно отправляет. Это связывает проверку с содержимым ответа: поменялись имя, порядок или формат — изменится и метка. Хеш берётся от канонически сформированной строки с фиксированными параметрами сериализации.

Клиент передаёт известные метки в If-None-Match. Для GET применяется слабое сравнение: W/ перед меткой не мешает признать совпадение. Кроме одной метки поле допускает список и звёздочку. Обработка только точного равенства всей строки сломается уже на двух сохранённых вариантах. Наши метки состоят из шестнадцатеричных символов, поэтому их удобно искать в небольшом разборе заголовка.

Ответ 304 не содержит JSON null или пустой объект. Он сообщает клиенту использовать ранее сохранённое представление. Поэтому маршрут возвращает Response с пустым телом и теми заголовками, которые нужны для проверки кэша. Для обычного 200 используется тот же ETag. Cache-Control: private, max-age=0 оставляет хранение частному клиенту и требует повторной проверки свежести.

Проверим обновление и повторное чтение

Нужны FastAPI и HTTPX. Блок отправляет первый GET, условный запрос со списком меток и запрос после изменения каталога. В тесте сравнивается не только код 304, но и отсутствие тела. Это обнаружит случайный возврат словаря через стандартную сериализацию.

Пример не использует базу: изменение списка имитирует новую версию каталога. В настоящем API нужно получить согласованный снимок данных, сформировать байты и вычислить метку из того же снимка. Если метка и тело читаются раздельно, между ними может появиться обновление.

import hashlib
import json
import re
from typing import Annotated
from fastapi import FastAPI, Header, Response
from fastapi.testclient import TestClient

app = FastAPI()
catalog = [{"id": 1, "name": "Desk"}]
TAG = re.compile(r'(?:W/)?"[^"\x00- \x7f]*"')

@app.get("/catalog")
def read(if_none_match: Annotated[str | None, Header()] = None):
    payload = json.dumps(catalog, sort_keys=True, separators=(",", ":")).encode()
    etag = '"' + hashlib.sha256(payload).hexdigest() + '"'
    headers = {"ETag": etag, "Cache-Control": "private, max-age=0"}
    candidates = TAG.findall(if_none_match or "")
    matches = any(tag.removeprefix("W/") == etag for tag in candidates)
    if if_none_match and (if_none_match.strip() == "*" or matches):
        return Response(status_code=304, headers=headers)
    return Response(content=payload, media_type="application/json", headers=headers)

with TestClient(app) as client:
    first = client.get("/catalog")
    tag = first.headers["etag"]
    cached = client.get("/catalog", headers={"If-None-Match": '"other", W/' + tag})
    assert cached.status_code == 304 and cached.content == b""
    assert cached.headers["etag"] == tag
    catalog.append({"id": 2, "name": "Chair"})
    changed = client.get("/catalog", headers={"If-None-Match": tag})
    assert changed.status_code == 200 and changed.headers["etag"] != tag
    assert len(changed.json()) == 2
print(first.status_code, cached.status_code, changed.status_code)

Что меняется при нескольких вариантах ответа

Если результат зависит от языка, разрешений или параметров запроса, метка должна соответствовать выбранному варианту. Одинаковая версия таблицы не всегда означает одинаковый JSON. Для ответа, зависящего от заголовков, проверьте ещё и Vary. Чужой персональный ответ нельзя отдавать только потому, что совпала произвольная метка клиента: авторизация выполняется до условной выдачи.

Хеширование готового JSON уменьшает передачу байтов, но само по себе не устраняет чтение базы и сериализацию. Если именно они дорогие, понадобится хранить версию или подготовленное представление вместе с данными. Тогда изменение всех влияющих полей должно менять эту версию. Сначала измерьте, какую работу вы хотите сэкономить, и только потом переносите вычисление метки раньше.

Сжатие тоже создаёт вариант представления. Если middleware преобразует уже сформированные байты, проверьте согласованность сильного ETag и Content-Encoding. Для более сложной цепочки кэширования полезно выбрать владельца генерации метки: приложение либо промежуточный слой. Два независимых механизма, переписывающих заголовки, усложнят диагностику.

Оставьте в проекте сценарий изменения данных. Один тест повторного GET способен подтвердить вечный 304 даже у ошибочной реализации с постоянной меткой. Проверка нового имени или добавленной строки заставляет алгоритм доказать, что он замечает изменение именно публичного ответа. Отдельно можно проверить звёздочку и слабую метку клиента.

Для первого внедрения выберите небольшой GET без побочных действий. Сохраните исходный 200 и его JSON, добавьте ETag, затем научите клиент повторять запрос с If-None-Match. Только после этого переводите в условный режим более сложные представления. Такой порядок оставляет понятную базовую проверку на каждом этапе.

Следующая практика

Добавьте условное чтение к своему каталогу после обычного GET. Бесплатная программа FastAPI поможет связать маршруты с базой и TestClient; сохраните в проекте сценарий 200 → 304 → 200. Перейдите к бесплатному курсу FastAPI.

Продолжить на практике