# Загрузка данных Обмен с порталом сосредоточен в `src/soniks_client/api.py`; куда после этого девается файл, решают модули `src/soniks_client/jobs/`. ## Точки обмена | Функция | Метод | Куда | Когда | |---|---|---|---| | `get_observation_jobs()` | `GET` | `URL__JOBS_PATH` | Раз в минуту — расписание проходов | | `upload_observation_metadata()` | `PUT` | `URL__OBSERVATIONS_PATH//` | Из очереди выгрузки — версия клиента и метаданные, **полями формы**, не `multipart` | | `upload_observation_data()` | `PUT` | `URL__OBSERVATIONS_PATH//` | Каждый файл, `multipart` | | `publish_station_status()` | `POST` | `URL__STATIONS_PATH//status/` | После первой успешной сверки расписания, до первого успеха — режимы из `MODES`, по которым портал фильтрует планирование | Все запросы идут через одну `requests.Session` с ретраями: до четырёх попыток, паузы 0/2/4 с, только на `PUT` и только на временные отказы (429, 500, 502, 503, 504). `404` и `403 «has already been uploaded»` не повторяются — это осмысленные ответы портала. `GET` расписания не повторяется тоже: он и так идёт раз в минуту, а четыре попытки по 45 с съели бы `SCHEDULER__MISFIRE_GRACE_TIME_IN_SECOND` и раздули бы `last_sync_age` в `/healthz`. Настоящий долгий backoff — это очередь `incomplete/`, а не urllib3. Аутентификация — заголовок `Authorization: Token ` во всех запросах. Запрос расписания дополнительно передаёт координаты станции (портал считает видимость проходов на своей стороне) и параметр `capabilities` — перечисление того, что этот клиент понимает в ответе, из константы `CAPABILITIES`. `capabilities` — это опт-ин, и он существует ради станций, которые уже не обновятся. Портал отдаёт новое поле только тому, кто его попросил, поэтому станция старой версии получает ответ байт-в-байт как раньше и сломаться от правки портала не может. Добавлять сюда имя можно только тогда, когда клиент действительно умеет обращаться с полем: заявление честно по построению — клиент и `flowgraph_dispatcher` едут в одном образе. Подробности и мотив — [](../roadmap-network.md), решение 21. `upload_observation_data()` отправляет **по одному файлу за запрос**. Имя поля формы зависит от типа данных: `payload`, `waterfall` или `demoddata`. MIME-тип угадывается по имени файла, с запасным вариантом `application/octet-stream`. ## Три сценария выгрузки ### Во время прохода `send_data_during_observation()` вызывается наблюдателем файловой системы (`CreateFileHandler`) по мере появления файлов `data_*`. Кадры, возникшие за окно `OBSERVATION__BATCH_DELAY`, отправляются одной пачкой. Это не оптимизация, а страховка: проход длится минуты, и если ждать его конца, обрыв связи или падение контейнера унесёт всю телеметрию. Данные оказываются на портале ещё до захода спутника. ### После прохода `send_data_after_observation()` — одноразовое задание, поставленное в конце `execute_observation`. Собирает файлы директории наблюдения по префиксам, пропуская пустые, и выгружает каждый своим обработчиком: | Префикс | Обработчик | Содержимое | |---|---|---| | `metadata` | `add_metadata_to_sending_data` | JSON метаданных прохода, уходит полем формы `client_metadata` | | `payload` | `add_payload_to_sending_data` | Аудио `.ogg`, читается как есть | | `waterfall` | `add_waterfall_to_sending_data` | PNG водопада | | `data` | `add_demoddata_to_sending_data` | Кадр: base64-поле `pdu` из JSON, при неудаче — бинарник целиком | Состав определяется `OBSERVATION__UPLOAD_TYPES__*`: аудио и водопад можно отключить, кадры и метаданные выгружаются всегда. Метаданные попали сюда не сразу. Раньше постобработка отправляла их одним `PUT` прямо из `Observation.post_processing()`, и это был единственный путь «выстрелил и забыл»: при ошибке сети блок `signal` (SNR, девиация, ppm) исчезал навсегда — повторять было нечего и неоткуда. Теперь постобработка пишет `metadata__<время>.json` в директорию наблюдения и на этом заканчивается; дальше файл живёт по общим правилам. Повтор безопасен без content-hash: портал перезаписывает поля наблюдения, а не отвергает дубль, — `403 «has already been uploaded»` к метаданным не относится. Отправитель выбирается по тому же префиксу, что и читатель: метаданные уходят полями формы, остальное — `multipart`. Префикс, добавленный в `get_prefixes_of_files_available_for_upload()` без записи в `_handlers_by_prefix()`, роняет задание выгрузки с `KeyError` — обе правки всегда в одном коммите. ### Повторная отправка `resending_jobs()` раз в `SCHEDULER__RESENDING_INTERVAL_IN_MINUTES` (20 минут) проходит по `incomplete/` и ставит задание `send_observation_data_again()` на каждую директорию наблюдения. Тип файла определяется по префиксу имени. Директория закрывается, только когда ушли **все** файлы. Если хоть один не отправился, в лог идёт предупреждение, и попытка повторится в следующий цикл. ## Судьба файла зависит от исхода выгрузки Это центральное соглашение всего модуля. Исключения из `core/exceptions.py` здесь не «ошибки» в обычном смысле — они сообщают, **что делать с файлом**: ``` успех ├─ OBSERVATION__REMOVE_OBSERVATION_DATA=True → файл удаляется └─ False → файл переезжает в complete/ FileNotUploadedError портал недоступен, сеть, таймаут, любой другой код └─ файл → incomplete/, повтор раз в 20 минут FileRejectedError 400: портал отверг файл, те же байты получат тот же ответ ├─ первая попытка (во время и после прохода) → incomplete/, как выше └─ повторная отправка, снова 400 → файл закрывается как выгруженный: удаляется либо в complete/ ObservationNotFoundError 404: наблюдение удалено на портале ├─ первая попытка (во время и после прохода) │ └─ файлы → incomplete/, отправка прекращается └─ повторная отправка (уже из incomplete/) └─ вся директория наблюдения удаляется ``` По этому дереву ходят все артефакты прохода, метаданные в том числе: файл удаляется только по подтверждённому приёму. Застрявшие метаданные держат директорию в `incomplete/`, а значит видны снаружи — `/healthz` считает именно директории. Логика `ObservationNotFoundError` неочевидна, но верна: если проход удалён на портале, отправлять больше нечего и некуда. Продолжать перебор файлов — значит получить 404 на каждый; поэтому обработчик выходит сразу, а не `continue`. Удаление при этом отложено на одну попытку. Один 404 — слишком слабое основание стирать единственную копию данных: он может прийти и от временной ошибки на стороне портала. Поэтому первый 404 только откладывает файлы в `incomplete/`, а удаляет их повторная отправка, получившая 404 второй раз — не раньше чем через `SCHEDULER__RESENDING_INTERVAL_IN_MINUTES`. Никакого счётчика для этого не нужно: состояние — сам факт нахождения директории в `incomplete/`. `400` устроен так же, как 404, — две ступени. Портал отдаёт его навсегда: имя кадра не по контракту (`malformed_filename`) или метка кадра дальше пяти минут от окна наблюдения (`frame_outside_window`, с 2026-08-28). Код ответа клиент не разбирает — любой `400` на те же байты повторится. Первый отказ откладывает файл в `incomplete/`: кратковременный `400` от ошибки портала, исправленной за время между попытками, не должен стоить кадра. Повторный — закрывает файл по `OBSERVATION__REMOVE_OBSERVATION_DATA`, как выгруженный, и остальные файлы наблюдения больше не держит. До этого один отвергнутый кадр оставлял в `incomplete/` всё наблюдение и уходил на портал каждые 20 минут без предела (решение 24 в [](../roadmap-network.md)). `frame_outside_window` почти всегда означает ушедшие часы станции: метку кадра ставит диспетчер по ним. Поэтому клиент на каждой сверке расписания сравнивает свои часы с заголовком `Date` ответа портала: расхождение уходит в `/healthz` полем `clock_skew_seconds`, а больше 60 секунд — ещё и предупреждением в лог. Все три директории живут под `PATHS__BASE`. Оба compose задают его равным `/var/lib/soniks-client/data` — постоянному тому, а не `/tmp`, который примонтирован как tmpfs. До этого очередь `incomplete/` не переживала перезапуск контейнера, и окно повторной отправки существовало только пока контейнер жив. ## Обработка ошибок HTTP Каждая функция `api.py` разбирает `requests.Timeout`, `requests.HTTPError` и `requests.RequestException` по отдельности и логирует их с идентификатором наблюдения. Наружу уходят только два «файловых» исключения, описанных выше. `get_observation_jobs()` при любой ошибке возвращает `None`, а не бросает исключение: недоступность портала не должна ломать цикл синхронизации — на следующей минуте будет новая попытка. ## Что стоит знать при доработке * **`data_to_send.popitem()`** в `upload_observation_data` разрушает переданный словарь. Он рассчитан ровно на одну пару «поле → содержимое»; повторное использование словаря не сработает. * **Пустые файлы отбрасываются** ещё на этапе сбора путей (`_collect_file_paths_by_prefix`): нулевой размер — молча пропустить. * **Префиксы и имена полей формы — это настройки** (`OBSERVATION__PREFIX__*`, `OBSERVATION__SENDING_KEY__*`), а не константы в коде. Но портал ожидает конкретные значения, так что менять их на станции бессмысленно. * **Метаданные наблюдения включают все параметры потокового графа**, см. [Потоковый граф](flowgraph.md#метаданные).