348 lines
23 KiB
Markdown
348 lines
23 KiB
Markdown
---
|
||
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`.
|