Загрузка данных

Обмен с порталом сосредоточен в src/soniks_client/api.py; куда после этого девается файл, решают модули src/soniks_client/jobs/.

Точки обмена

Функция

Метод

Куда

Когда

get_observation_jobs()

GET

URL__JOBS_PATH

Раз в минуту — расписание проходов

upload_observation_metadata()

PUT

URL__OBSERVATIONS_PATH/<id>/

Из очереди выгрузки — версия клиента и метаданные, полями формы, не multipart

upload_observation_data()

PUT

URL__OBSERVATIONS_PATH/<id>/

Каждый файл, multipart

publish_station_status()

POST

URL__STATIONS_PATH/<id>/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 <STATION__TOKEN> во всех запросах. Запрос расписания дополнительно передаёт координаты станции (портал считает видимость проходов на своей стороне) и параметр capabilities — перечисление того, что этот клиент понимает в ответе, из константы CAPABILITIES.

capabilities — это опт-ин, и он существует ради станций, которые уже не обновятся. Портал отдаёт новое поле только тому, кто его попросил, поэтому станция старой версии получает ответ байт-в-байт как раньше и сломаться от правки портала не может. Добавлять сюда имя можно только тогда, когда клиент действительно умеет обращаться с полем: заявление честно по построению — клиент и flowgraph_dispatcher едут в одном образе. Подробности и мотив — Дорожная карта: надёжность сети, решение 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_<id>_<время>.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 в Дорожная карта: надёжность сети).

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__*), а не константы в коде. Но портал ожидает конкретные значения, так что менять их на станции бессмысленно.

  • Метаданные наблюдения включают все параметры потокового графа, см. Потоковый граф.