Files
enduro-trails/docs/work-items/ET-011/10-tech-risks.md
claude-bot 6fe2ecf12b
Some checks failed
CI / lint (push) Failing after 4s
CI / test (push) Successful in 6s
CI / build (push) Has been skipped
architect(ET): auto-commit from architect run_id=64
2026-06-03 20:44:55 +00:00

348 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 в строку
занимает 400500 мс 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`.