---
type: tech-risks
work_item_id: ET-011
title: "Технические риски — ET-011: Скачивание трека из popup"
version: 1
status: approved
created_at: 2026-06-03
authors:
  - "agent:architect"
---

# Технические риски — ET-011

Технические риски этапа добавления GPX-download эндпоинта и UI-кнопки
в popup публичного трека. Бизнес-риски — в BRD §8 ET-011. Шкала:
вероятность (Н/С/В) × влияние (Н/С/В).

## R-1 — iOS Safari игнорирует `Content-Disposition: attachment`

- **Описание:** Исторически iOS Safari склонен открывать XML inline
  вместо скачивания. Если эндпоинт отдаёт правильный header, но Safari
  показывает GPX как текст в новой вкладке — UX сломан.
- **Вероятность / Влияние:** С (был — В, де факто митигирован) / С.
- **Митигация:**
  - **Архитектурное решение (ADR-014 §A)**: используем `fetch + Blob +
    URL.createObjectURL + <a download>` паттерн — тот же, что
    `app.js::downloadGPX()` для построенного маршрута. Этот паттерн в
    проде работает на iOS Safari (проверено в ET-006 / PH-3).
  - При downloads с `a.download` от blob-URL iOS Safari 13+ корректно
    сохраняет файл с указанным именем в downloads.
  - E2E-01/02 (Playwright) проверяет на desktop + mobile viewport;
    iOS-specific quirk проверяется ручным smoke на физическом iPhone
    (BRD §8 R-1).
- **Наследник от:** существующий `downloadGPX()` (PH-3 / ET-006 patterns).

## R-2 — Кириллица в имени файла ломается в downloaders некоторых браузеров

- **Описание:** Headers `Content-Disposition: filename="<кириллица>.gpx"`
  без RFC 5987 ASCII-fallback ломаются в старых Edge, не-Unicode
  Windows-устройствах.
- **Вероятность / Влияние:** С / Н.
- **Митигация:**
  - **Архитектурное решение (ADR-014 §F)**: всегда отдаём ОБА
    параметра: ASCII-fallback `filename=` + UTF-8 `filename*=UTF-8''`.
    Современные браузеры читают `filename*`, древние — ASCII-fallback
    (= `track-<id>.gpx`).
  - Тест IT-06 проверяет наличие обоих параметров.
  - UT-04 проверяет санитизацию (запрещённые символы → `_`, длина ≤ 80
    байт UTF-8).

## R-3 — Утечка proprietary metadata через merged GPX (ADR-015 §B trade-off)

- **Описание:** Трек с `sources=["osm", "wikiloc"]` (после dedup-merge)
  проходит license-guard по правилу ANY (есть OSM ⇒ download разрешён).
  Но `tracks.name` / `tracks.description` могут быть взяты из Wikiloc
  (если у Wikiloc был выше source_priority). В скачанный GPX попадает
  proprietary текст.
- **Вероятность / Влияние:** С / С.
- **Митигация:**
  - **Архитектурное решение (ADR-014 §G)**: `<copyright>` ставим
    только для OSM (`license = openstreetmap.org/copyright`); для не-
    OSM `<copyright>` опускаем. Это защищает от ложной атрибуции.
  - **Архитектурное ограничение (ADR-015 §B)**: per-field source
    tracking не вводим (требует ALTER TABLE — out of ET-011 scope).
  - **Compensation**: `source_priority` в ET-009 фиксирует osm=100 >
    enduro_russia=80 > wikiloc=70. При merge OSM-метаданные перекрывают
    остальные. На практике для треков с `"osm" ∈ sources` `name` и
    `description` уже от OSM.
  - **Эскалация**: если в Build review-стадии review-агент найдёт
    конкретный случай утечки (например, фикстура с `wikiloc.description
    = "<длинный proprietary текст>"`) — возврат в Analysis для
    расширения схемы.

## R-4 — Запрос на трек 200000+ точек срывает worker по timeout

- **Описание:** Сборка `xml.etree.ElementTree` для 200000 trkpt в строку
  занимает 400–500 мс CPU. Несколько параллельных таких запросов могут
  превысить uvicorn `--timeout-keep-alive` или nginx
  `proxy_read_timeout`.
- **Вероятность / Влияние:** Н / С.
- **Митигация:**
  - **Архитектурное решение (REQ-NF-02, ADR-014 §H)**: cap 200000 →
    413 ДО сборки XML.
  - Проверка делается через `tracks.points_count` (read-only field в
    схеме ET-008, indexed PK lookup — < 1 ms).
  - Тест IT-04 проверяет 413 для фиктивной записи `points_count=300000`.
  - В случае массового тяжёлого трафика — отдельный rate-limit
    middleware (out of scope, см. `07-infra-requirements.md` §3.2).

## R-5 — Массовые скачивания одного трека забивают RAM сервера

- **Описание:** Cap 200k → ~20 МБ XML per request. 10 параллельных
  скачиваний = 200 МБ heap. test-сервер имеет ~1 ГБ свободно у
  контейнера app.
- **Вероятность / Влияние:** Н / С.
- **Митигация:**
  - 200 МБ < free RAM × запас 5×. Не блокирующий.
  - Если в проде проявится — переключение на `StreamingResponse`
    (ADR-014 §C опция C2). Это не меняет API-контракт и тесты, можно
    делать без нового ADR.
  - Garbage collection после `Response(...)` корректно освобождает heap
    (Python ссылается только на raw bytes для отправки в TCP).

## R-6 — Кнопка «Скачать» появляется для треков с `download_allowed: false` → 403 после клика

- **Описание:** Frontend (ADR-014 §3.b) показывает кнопку **всегда**.
  При клике на трек EnduroRussia/Wikiloc/ttrails backend возвращает
  403. Пользователь думает «функция сломана».
- **Вероятность / Влияние:** В / Н.
- **Митигация:**
  - **Сознательный компромисс** (ADR-014 §«Отрицательные»): прятать
    кнопку требует знать `download_allowed` на клиенте — расширение
    MVT/GeoJSON-контракта на новое поле. Не делаем в ET-011.
  - **Toast с CTA**: при 403 → `showToast('Источник запрещает
    скачивание. Откройте трек на сайте источника.')` + кликабельная
    ссылка на `external_urls[0]` (см. ADR-015 §5).
  - **Release-notes** (если ведутся): «Качаем пока только OSM-треки».
  - При негативном UX-фидбэке в проде — расширение GeoJSON-properties
    флагом `downloadable: bool` в отдельной итерации.

## R-7 — Сборка GPX-XML без экранирования спецсимволов в `tracks.name`

- **Описание:** Имя трека может содержать `&`, `<`, `>`, `"` —
  обязательные для XML escape-symbols. Если builder использует f-string
  templates без escape — broken XML, провал AC-5 (XSD validation).
- **Вероятность / Влияние:** В (если бы выбрали f-string) / В.
- **Митигация:**
  - **Архитектурное решение (ADR-014 §B)**: `xml.etree.ElementTree`
    автоматически экранирует текст и атрибуты при сериализации.
  - Тест UT-01 (см. test-plan) использует `name = "Trail & <special>"`
    или подобные кейсы.
  - Тест UT-03 / IT-07 валидирует против XSD.

## R-8 — Валидация по XSD требует `lxml` в test-deps

- **Описание:** `xml.etree.ElementTree` (stdlib) **не** умеет валидацию
  по XSD. Для UT-03 / IT-07 нужен `lxml.etree.XMLSchema`.
- **Вероятность / Влияние:** Случилось / Н.
- **Митигация:**
  - **Архитектурное решение (ADR-014 §B, §5)**: добавить `lxml` в
    `requirements-dev.txt` (только для тестов).
  - Если `lxml` уже присутствует через `defusedxml` транзитивно —
    нет действия.
  - Альтернатива: `xmllint --schema` через subprocess — добавляет
    C-зависимость в CI image, более хрупкая. `lxml` через pip проще.

## R-9 — Юридическая ошибка в whitelist `download_allowed`

- **Описание:** Архитектор закрыл BRD Q-1 как «только OSM» (default).
  Если Owner после merge'a определит, что EnduroRussia/Wikiloc разрешено
  отдавать — нужен update ADR-015 + правка `gps_sources.yaml`. В
  обратную сторону: если кто-то ошибочно выставит `download_allowed:
  true` для proprietary источника — нарушение ToS.
- **Вероятность / Влияние:** С / В.
- **Митигация:**
  - **Default-deny** в Pydantic-модели (ADR-015 §«Решение C»): отсутствие
    поля = `false`.
  - **Документация в ADR-015 §«Решение D»** — явный whitelist с
    юридическим обоснованием для каждого источника.
  - **Code review check** при изменении `gps_sources.yaml`: любая
    смена `download_allowed: false → true` требует ссылки на обновлённый
    licensing-ADR.
  - **Integration test IT-05** фиксирует поведение для запрещённого
    источника (страж-тест).
- **Наследник от:** ET-008 R-9 (regression of accepted ADR to proposed).

## R-10 — Регрессия существующих эндпоинтов `/api/gps-tracks/*`

- **Описание:** Расширение `endpoint.py::create_gps_router` новым
  route и аргументом `sources_config_path` может случайно сломать
  существующий контракт (`""`, `/tiles`, `/health`, `/cache/clear`).
- **Вероятность / Влияние:** Н / С.
- **Митигация:**
  - **Архитектурное решение**: новый аргумент `sources_config_path`
    опциональный, default — `None` (= `{"osm"}` whitelist). Старые
    тесты, вызывающие `create_gps_router(db_path)`, продолжают работать.
  - **Тест IT-08** — smoke-проверка, что GET `""`, `/tiles/...`,
    `/health` отвечают так же, как до ET-011.
  - **AC-15** — регрессионный пункт acceptance для UI: sheet-gpx,
    sheet-route, фильтры публичных треков работают как раньше.

## R-11 — Frontend парсинг `Content-Disposition` некорректен на каком-то браузере

- **Описание:** Если `_parseFilenameFromCD()` (см. ADR-014 §3.b) не
  справляется с экзотическими header-форматами (например, кавычки в
  `filename="track \"name\".gpx"`), файл сохраняется с дефолтным именем.
- **Вероятность / Влияние:** Н / Н.
- **Митигация:**
  - Backend контролирует header — мы сами знаем, что отдаём
    `filename="<ascii_no_quote>.gpx"` без escaped quotes (санитизация
    в `safe_filename` заменяет `"` на `_`).
  - Fallback `track-<id>.gpx` если парсинг не удался — файл всё равно
    сохраняется.

## R-12 — XSD-фикстура `gpx.xsd` устаревает

- **Описание:** `gpx.xsd` от topografix может обновиться (хотя
  спецификация GPX 1.1 заморожена с 2004 года). Снимок 2026-06 будет
  валиден неопределённое время.
- **Вероятность / Влияние:** Н / Н.
- **Митигация:**
  - GPX 1.1 — frozen spec; topografix не выпускают новые версии 1.1.
  - Снимок коммитится один раз; если что-то изменится — refresh.

## R-13 — Race-condition: трек удалён из БД между HEAD и GET

- **Описание:** Если в момент tap'а на popup трек удалили из БД
  (например, через ad-hoc `DELETE`), эндпоинт вернёт 404. Popup уже
  показал кнопку, пользователь увидит «Трек не найден» в toast.
- **Вероятность / Влияние:** Н / Н.
- **Митигация:**
  - Принято as-is. Toast «Трек не найден» — корректный UX.
  - В проекте нет ручного `DELETE FROM tracks` в нормальном потоке;
    GC pipeline (ET-008) удаляет orphan-записи раз в месяц.

## R-14 — Кнопка «Скачать» некорректно тапается на ультра-маленьких viewport

- **Описание:** REQ-NF-04 требует ≥ 32×32 CSS px тапабельной зоны.
  При CSS-typo или ошибке в стилях кнопка может вписаться в padding'и
  popup'а, сжимаясь.
- **Вероятность / Влияние:** Н / С.
- **Митигация:**
  - **Архитектурное решение (ADR-014 §3.c)**: `width: 32px; height:
    32px` в `.track-popup-download-btn`.
  - **E2E-02 (mobile)** проверяет bounding box ≥ 32×32 px.
  - **TC-UI-02 (Playwright UI test cases)** — визуальная проверка на
    iPhone SE (375×667).

## R-15 — Tooltip не объявляется screen-reader'у

- **Описание:** REQ-F-01 / AC-14: tooltip «Скачать GPX». Если builder
  забудет `aria-label` — screen-reader пользователь не услышит
  название действия.
- **Вероятность / Влияние:** Н / С.
- **Митигация:**
  - **Архитектурное решение (ADR-014 §3.a)**: явно прописываем
    `aria-label="Скачать GPX"` И `title="Скачать GPX"` на `<button>`.
  - Code-review checklist: проверить наличие `aria-label` для всех
    icon-only buttons.

## R-16 — Зависание popup при медленном API (типичное скачивание > 1 сек)

- **Описание:** При построении GPX на 50000 точек + плохой downlink
  у пользователя — visual stall на кнопке. Если индикатор не показан,
  кажется «не работает».
- **Вероятность / Влияние:** С / Н.
- **Митигация:**
  - **Архитектурное решение (ADR-014 §3.b)**: CSS-класс `.is-loading`
    с visual spinner через `::after` псевдоэлемент. Применяется на
    время `fetch()`.
  - Снимается в `finally` блоке (даже при ошибке).
  - REQ-NF-01 = 300 ms p95 на 50k точек на test-сервере — нормально
    без видимого индикатора в большинстве случаев.

## R-17 — `gps_sources.yaml` не существует на runtime → `download` падает

- **Описание:** Если `SOURCES_CONFIG_PATH` указывает на несуществующий
  файл (например, после refactor'а директорий), `create_gps_router`
  при старте упадёт.
- **Вероятность / Влияние:** Н / В.
- **Митигация:**
  - **Архитектурное решение (ADR-015 §«Решение F»)**: если конфиг
    недоступен — fallback `allowed_sources = {"osm"}`. Это совпадает
    с production-дефолтом, поэтому функциональность сохраняется.
  - Логируется WARNING в stdout: `gps_sources.yaml not found, falling
    back to safe-deny whitelist`.
  - Test-fixtures без конфига работают через тот же fallback.

## R-18 — gzip middleware не сжимает GPX → большой объём egress

- **Описание:** Если starlette `GZipMiddleware` не настроен или
  настроен на minimum size > 1 МБ, GPX-ответ для маленького трека (5k
  точек ≈ 650 КБ) уходит несжатым.
- **Вероятность / Влияние:** Н / Н.
- **Митигация:**
  - Не блокирует функциональность. Egress test-сервера ≥ 100 Mbps,
    нагрузка от download'ов минимальна.
  - Опционально (out of scope): добавить `GZipMiddleware` в
    `src/api/main.py`, если ещё не добавлен. Это affects **все**
    эндпоинты, не только download — отдельная задача.
  - GPX-XML сжимается gzip'ом обычно ×3..5.

## R-19 — Параллельные клики на «Скачать» создают N запросов

- **Описание:** Если пользователь нервно тапает кнопку 5 раз подряд —
  N параллельных fetch к одному треку. Тратятся ресурсы.
- **Вероятность / Влияние:** С / Н.
- **Митигация:**
  - **Архитектурное решение (ADR-014 §3.b)**: `btnEl.classList.add('is-loading')`
    + CSS `pointer-events: none` блокирует повторные клики до
    `finally`.
  - Backend идемпотентен (read-only), повторный запрос не вредит
    state.

## Сводная таблица

| ID | Риск | Вер. | Влияние | Класс | Статус |
|---|---|---|---|---|---|
| R-1 | iOS Safari игнорирует Content-Disposition | С | С | Средний | переиспользование рабочего паттерна `downloadGPX()` |
| R-2 | Кириллица в filename | С | Н | Низкий | RFC 5987 `filename*` + ASCII-fallback |
| R-3 | Утечка proprietary metadata через merged GPX | С | С | Средний | `<copyright>` только OSM; per-field tracking — отдельный work item |
| R-4 | Patho-трек срывает timeout | Н | С | Низкий | cap REQ-NF-02 = 200k → 413 |
| R-5 | RAM от параллельных скачиваний | Н | С | Низкий | 200 МБ при 10 параллельных, < free RAM × 5 |
| R-6 | Кнопка всегда видна → 403 после клика | В | Н | Низкий | сознательный UX-compromise + toast c CTA |
| R-7 | XML-escape `tracks.name` | В (без ET) / **Н** (с ET) | В | Средний | `xml.etree.ElementTree` авто-escape |
| R-8 | `lxml` в test-deps | Случилось | Н | Низкий | optional add в `requirements-dev.txt` |
| R-9 | Юридическая ошибка в `download_allowed` whitelist | С | В | **Высокий** | default-deny + ADR-015 §D + IT-05 + review |
| R-10 | Регрессия существующих эндпоинтов | Н | С | Низкий | IT-08 smoke + opt arg `sources_config_path` |
| R-11 | Frontend парсинг Content-Disposition | Н | Н | Низкий | fallback `track-<id>.gpx` |
| R-12 | XSD-фикстура устаревает | Н | Н | Низкий | GPX 1.1 frozen |
| R-13 | Race delete | Н | Н | Низкий | 404 = корректный UX |
| R-14 | Кнопка не тапается на маленьких viewport | Н | С | Низкий | CSS `32px × 32px` + E2E-02 + TC-UI-02 |
| R-15 | Screen-reader не получает label | Н | С | Низкий | `aria-label` + `title` + review |
| R-16 | Visual stall при медленном API | С | Н | Низкий | `.is-loading` spinner |
| R-17 | Конфиг не существует на runtime | Н | В | **Высокий** | fallback `{"osm"}` + WARNING log |
| R-18 | gzip не сжимает | Н | Н | Низкий | optional middleware add |
| R-19 | Параллельные клики | С | Н | Низкий | `pointer-events: none` + idempotent backend |

**Высокие классы:**
- **R-9** — legal/license risk. Митигация многослойная: default-deny в
  Pydantic + явный whitelist в ADR-015 + integration-тест + code-review
  чеклист.
- **R-17** — runtime safety. Митигация: silent-fallback на consistent
  с production default (= `{"osm"}`), не падаем при стартe.

**Средние классы:**
- **R-1** — переиспользуем de facto проверенный паттерн.
- **R-3** — известный compromise, задокументирован в ADR-015 §B; полное
  решение — отдельный work item.

**Блокирующих рисков нет.** Высокие классы требуют внимания на этапе
разработки и code review.

## Эскалация

- **arch:major-change** — **не выставляется** (см. ADR-014 §«Классификация»,
  ADR-015 §«Классификация»). ET-011 не вводит новых архитектурных
  компонентов.
- **back-to:analysis** — не требуется. ТЗ полное, BRD-вопросы Q-1/Q-2/Q-3
  закрыты дефолтными значениями (см. BRD §9).
- Эскалация в Architecture требуется **только** если:
  1. Owner закрывает Q-1 как разрешающий — обновление ADR-015 (но не
     back-to:analysis).
  2. Review-агент находит конкретный случай утечки proprietary
     metadata (R-3) — `back-to:analysis` для расширения схемы БД.
  3. iOS Safari возвращает регресс по R-1 — `back-to:build` (не
     `back-to:analysis`) для добавления fallback'а на `window.location.href`.
