Докачка файла в FastAPI: Range, If-Range и FileResponse

В личном кабинете сформировали большой экспорт. После обрыва браузер сохранил часть файла, но повторная загрузка начинается с нуля. У сервера уже есть готовый файл, поэтому он может отправить оставшиеся байты. Сложность возникает, если за тем же адресом успели опубликовать новую версию. Если клиент склеит начало старого отчёта с концом нового, размер может совпасть, а файл окажется испорченным.

Что клиент передаёт при продолжении

Пусть клиент записал первые четыре байта. Следующий запрос отправляет Range: bytes=4-. Нумерация начинается с нуля, поэтому ответ 206 Partial Content содержит данные начиная с пятого байта. Для десятибайтового файла ожидайте Content-Range: bytes 4-9/10. Правая граница включается в ответ. Заголовок сообщает также размер целого представления, поэтому клиент может проверить, что диапазон начинается ровно там, где закончился его файл.

Чтобы проверить версию, клиент добавляет If-Range со значением сильного ETag, полученного при первой загрузке. При совпадении сервер может отправить диапазон. Если представление изменилось, он возвращает полный ответ 200, и клиент начинает файл заново. Эти два заголовка вместе означают «продолжи эту версию файла с указанного места».

Маршрут отдаёт файл только после проверки доступа

Ниже готовый экспорт публикуется под именем с хешем содержимого. Маршрут получает путь из записи экспорта после проверки владельца. Для примера используются два заранее заданных токена, чтобы проверять запросы через TestClient. В сервисе их место занимает ваша проверка сессии или токена. Starlette вычисляет границы диапазона, формирует заголовки и читает нужные байты через FileResponse.

Для воспроизведения установите проверенные версии командой python -m pip install fastapi==0.141.1 starlette==1.6.0 httpx==0.28.1. Сохраните пример как demo.py и запустите python demo.py.

from hashlib import sha256
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Annotated

from fastapi import FastAPI, Header, HTTPException
from fastapi.responses import FileResponse
from fastapi.testclient import TestClient


def build_app(directory: Path) -> FastAPI:
    content = b"abcdefghij"
    digest = sha256(content).hexdigest()
    path = directory / digest
    path.write_bytes(content)

    app = FastAPI()
    exports = {42: ("alice", path, f'"{digest}"')}
    accounts = {"Bearer demo-alice": "alice", "Bearer demo-bob": "bob"}

    @app.get("/exports/{export_id}", response_class=FileResponse)
    def download(export_id: int, authorization: Annotated[str | None, Header()] = None):
        account = accounts.get(authorization)
        if account is None:
            raise HTTPException(401, headers={"WWW-Authenticate": "Bearer"})
        export = exports.get(export_id)
        if export is None or export[0] != account:
            raise HTTPException(404)
        _, artifact, etag = export
        return FileResponse(
            artifact, media_type="application/octet-stream", filename="export.bin",
            headers={"ETag": etag, "Cache-Control": "private, no-store"},
        )

    return app


with TemporaryDirectory() as directory, TestClient(build_app(Path(directory))) as client:
    url = "/exports/42"
    auth = {"Authorization": "Bearer demo-alice"}
    prefix = client.get(url, headers={**auth, "Range": "bytes=0-3"})
    assert prefix.status_code == 206 and prefix.content == b"abcd"
    etag = prefix.headers["etag"]
    tail = client.get(url, headers={**auth, "Range": "bytes=4-", "If-Range": etag})
    assert tail.status_code == 206
    assert tail.headers["content-range"] == "bytes 4-9/10"
    assert prefix.content + tail.content == b"abcdefghij"
    stale = client.get(url, headers={**auth, "Range": "bytes=4-", "If-Range": '"previous"'})
    assert stale.status_code == 200 and stale.content == b"abcdefghij"
    assert client.get(url, headers={**auth, "Range": "bytes=10-"}).status_code == 416
    assert client.get(url, headers={
        "Authorization": "Bearer demo-bob", "Range": "bytes=4-", "If-Range": etag,
    }).status_code == 404
    print(tail.headers["content-range"], tail.content)

Пример скачивает начало как отдельный диапазон, чтобы воспроизвести обрыв без сетевых манипуляций. Затем тест собирает оригинальный файл из двух ответов. При устаревшем валидаторе тест получает весь файл. Недостижимый диапазон даёт 416, а запрос второго пользователя останавливается до открытия файла.

Если диапазон работает, а совпадающий If-Range неожиданно приводит к 200, проверьте версию Starlette. В версии 0.41.3 валидатор диапазона вычислялся по времени изменения и размеру файла, даже когда приложение устанавливало свой ETag. В 0.42.0 это исправлено. Проверка с собственным тегом важна, если экспорт хранится на нескольких серверах и одинаковый файл мог получить разное время изменения при копировании. Хеш содержимого даёт одинаковый валидатор для одних и тех же байтов.

Опубликуйте файл как неизменяемую версию

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

Для клиентского загрузчика заведите три ветки. При 206 проверьте начало диапазона и допишите байты. При 200 замените локальный файл, а рядом сохраните новый валидатор. При 416 запросите файл заново, если клиент ещё не проверял его размер и версию. Прогоните те же запросы через внешний адрес сервиса. Если прокси меняет представление файла, локальный тест напрямую к FastAPI этого не заметит.

На бесплатном курсе Latorn вы соберёте FastAPI-сервис с базой, авторизацией и проверками HTTP-ответов. В практике с TestClient вы сможете отработать маршруты и их отказы до подключения браузера. Открыть программу курса FastAPI.

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

  • FastAPI для начинающих: API с базой данных и тестами ↗

    Бесплатный курс backend-разработки после основ Python. Соберёте свой API на FastAPI: база данных, регистрация, JWT-авторизация и тесты pytest. Один сквозной проект, 34 коротких урока и 140 заданий с автоматической проверкой.