Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
614c00edea |
@@ -19,13 +19,6 @@
|
|||||||
- ML Kit различает найденную зону, отсутствие текста и `PrivacyBlocked`.
|
- ML Kit различает найденную зону, отсутствие текста и `PrivacyBlocked`.
|
||||||
- Unit-тесты и `assembleDebug` для версии `0.1.5` завершились успешно.
|
- Unit-тесты и `assembleDebug` для версии `0.1.5` завершились успешно.
|
||||||
|
|
||||||
## 2026-08-29: Emulator check
|
|
||||||
|
|
||||||
- Создан AVD `receipt-api35` на Android 35 Google APIs x86_64.
|
|
||||||
- Запуск в текущем WSL невозможен: Android Emulator требует KVM с VMX/SVM, доступного аппаратного ускорения нет.
|
|
||||||
- `:app:testDebugUnitTest :app:assembleDebug` без эмулятора завершились `BUILD SUCCESSFUL`.
|
|
||||||
- Версия Android-приложения повышена до `0.1.6`.
|
|
||||||
|
|
||||||
## 2026-08-28
|
## 2026-08-28
|
||||||
|
|
||||||
### Вопрос
|
### Вопрос
|
||||||
@@ -266,6 +259,65 @@ thoughts 477, total 1627. Стоимость по тарифу Gemini 3.6 Flash
|
|||||||
candidate output как выход: $0.00092, то есть около 9 копеек при курсе 95
|
candidate output как выход: $0.00092, то есть около 9 копеек при курсе 95
|
||||||
рублей за доллар.
|
рублей за доллар.
|
||||||
|
|
||||||
|
## 2026-08-31: Критическая валидация отчета Opus
|
||||||
|
|
||||||
|
Пользователь запросил документировать анализ Opus, выполнить критическую
|
||||||
|
сверку по коду и сформулировать вопросы до внесения изменений.
|
||||||
|
|
||||||
|
### Что подтверждено по коду
|
||||||
|
|
||||||
|
- `recipe_service/app.py`: есть дублирование вызовов `record(...)` по веткам
|
||||||
|
ответа; единый финализатор уменьшит риск расхождений полей метрик.
|
||||||
|
- `recipe_service/app.py`: `duration_ms` считается только в ветке `401`,
|
||||||
|
в остальных ветках передается `0`.
|
||||||
|
- `recipe_service/app.py`: используется `__import__('time').monotonic()`
|
||||||
|
вместо обычного `import time`.
|
||||||
|
- `recipe_service/app.py`: сравнение Bearer-токена выполнено через `==`,
|
||||||
|
без constant-time сравнения.
|
||||||
|
- `recipe_service/metrics.py`: `record()` вызывает `initialize()` на каждый
|
||||||
|
запрос, что приводит к повторной инициализации схемы БД.
|
||||||
|
- `gemini_proxy/app.py`: endpoint принимает `api_key_override` из формы.
|
||||||
|
- `android-app/app/build.gradle.kts`: `RECEIPT_API_TOKEN` попадает в
|
||||||
|
`BuildConfig`, значит секрет присутствует в клиентском APK.
|
||||||
|
- `android-app/app/src/main/java/ru/obdai/receipt/camera/CameraManager.kt`:
|
||||||
|
на каждом кадре выполняется цепочка YUV -> JPEG -> Bitmap.
|
||||||
|
- `android-app/app/src/main/java/ru/obdai/receipt/MainActivity.kt`:
|
||||||
|
текст результата выводится и в `Column`, и в `ResultOverlay`.
|
||||||
|
- `android-app/app/src/main/java/ru/obdai/receipt/crop/MedicationZoneDetector.kt`:
|
||||||
|
дата в формате `dd.mm.yyyy` сама по себе триггерит `PatientDataDetected`.
|
||||||
|
|
||||||
|
### Что уточнено по замечаниям Opus
|
||||||
|
|
||||||
|
- Замечание про `client_ip=127.0.0.1` корректно как риск достоверности
|
||||||
|
метрик за nginx. Для надежного учета реального IP требуется явная политика
|
||||||
|
доверия прокси и корректная обработка заголовков.
|
||||||
|
- Замечание про детализацию `502` корректно как вопрос баланса между
|
||||||
|
диагностикой и минимизацией утечки деталей провайдера.
|
||||||
|
- Замечание про отсутствие тестов для `recipe_service` подтверждено: в
|
||||||
|
текущем дереве есть `gemini_proxy/test_app.py`, но нет тестового файла
|
||||||
|
для `recipe_service`.
|
||||||
|
|
||||||
|
### Вопросы к Opus (блокеры перед правками)
|
||||||
|
|
||||||
|
1. По `gemini_proxy/app.py`: удаляем `api_key_override` полностью или
|
||||||
|
оставляем только в debug-режиме под отдельным флагом окружения?
|
||||||
|
2. По `recipe_service/app.py` ветке `502`: какой контракт ошибки нужен
|
||||||
|
клиенту - единое сообщение, коды причин, или ограниченный whitelist
|
||||||
|
провайдерских причин без сырого текста?
|
||||||
|
3. По учету IP за nginx: доверяем строго одному прокси (`x_for=1`) или
|
||||||
|
нужен более строгий trust chain по сети/подсети?
|
||||||
|
4. По Android токену: подтверждаем переход на краткоживущие серверные
|
||||||
|
токены (token exchange), или остается текущая схема MVP с явной
|
||||||
|
фиксацией риска?
|
||||||
|
5. По privacy-gate: дата в medication-зоне должна блокировать отправку,
|
||||||
|
или только снижать confidence и требовать подтверждение пользователя?
|
||||||
|
|
||||||
|
### Итог
|
||||||
|
|
||||||
|
Отчет Opus в значительной части подтвержден фактами по текущему коду.
|
||||||
|
Критичные изменения backend можно готовить после ответов на вопросы выше,
|
||||||
|
чтобы не сломать контракт клиента и не ослабить security-policy.
|
||||||
|
|
||||||
Вывод: лимит 500 токенов оказался недостаточным; следующий независимый тест
|
Вывод: лимит 500 токенов оказался недостаточным; следующий независимый тест
|
||||||
проводится с существенно большим лимитом и ещё более короткой JSON-схемой.
|
проводится с существенно большим лимитом и ещё более короткой JSON-схемой.
|
||||||
|
|
||||||
@@ -649,110 +701,48 @@ IP `127.0.0.1`, MIME `image/png`, размер 531101 байт, длину promp
|
|||||||
непустой usage JSON. Тестовый image-файл удалён после запроса; приложение
|
непустой usage JSON. Тестовый image-файл удалён после запроса; приложение
|
||||||
читает изображение в память и не пишет его на диск.
|
читает изображение в память и не пишет его на диск.
|
||||||
|
|
||||||
## 2026-08-31: Реализация backend-фиксов по плану v2
|
## 2026-08-31: критическая валидация отчета Opus и обновление плана
|
||||||
|
|
||||||
По команде пользователя выполнены изменения backend-компонентов и тестов.
|
Проведена проверка утверждений Opus по фактическому коду и конфигурации.
|
||||||
|
|
||||||
### Изменения в recipe_service
|
Подтверждено по backend:
|
||||||
|
|
||||||
- `recipe_service/metrics.py`:
|
- В `recipe_service/app.py` есть многократное дублирование блоков `record(...)` по веткам обработки.
|
||||||
- из `record()` удалён вызов `initialize()`;
|
- В `recipe_service/app.py` длительность `duration_ms` в большинстве веток фиксируется как `0`; фактический расчет есть только для `401`.
|
||||||
- добавлена `count_since(client_ip, started_at_from)` для rate limiting.
|
- В `recipe_service/app.py` используется `__import__('time').monotonic()` вместо нормального `import time`.
|
||||||
- `recipe_service/app.py`:
|
- В `recipe_service/app.py` сравнение bearer-токена выполнено через `==`, без constant-time сравнения.
|
||||||
- добавлен `ProxyFix(..., x_for=1, x_proto=1, x_host=1)`;
|
- В `recipe_service/app.py` в `502` наружу пробрасывается provider detail, что может раскрывать внутренние детали upstream-ошибок.
|
||||||
- сравнение токена переведено на `hmac.compare_digest`;
|
- В `recipe_service/metrics.py` функция `record()` вызывает `initialize()` на каждый запрос.
|
||||||
- введён единый финализатор `finalize(...)` вместо дублирования `record(...)`;
|
- `recipe_service/nginx-recipe.conf` передает `X-Real-IP` и `X-Forwarded-For`, но в Flask используется `request.remote_addr`; без `ProxyFix` это адрес прокси.
|
||||||
- `duration_ms` считается во всех ветках через `time.monotonic()`;
|
|
||||||
- добавлен rate limit `20` запросов/минута на IP (`429 too many requests`);
|
|
||||||
- зафиксирован контракт `502`:
|
|
||||||
`{"error":"upstream recognition failed","code":"upstream_error"}`;
|
|
||||||
- детали апстрима пишутся только в лог сервера с `request_id`.
|
|
||||||
- добавлен `recipe_service/test_app.py` (покрытие: `health`, `401`, `400`,
|
|
||||||
`415`, `413`, `200`, `502`-контракт, `429`).
|
|
||||||
|
|
||||||
### Изменения в gemini_proxy
|
Подтверждено по инфраструктуре:
|
||||||
|
|
||||||
- `gemini_proxy/app.py`: удалён `api_key_override`; ключ только из
|
- `recipe_service/recipe.service` слушает только `127.0.0.1:8770`.
|
||||||
`GEMINI_API_KEY`.
|
- `gemini_proxy/gemini-proxy.service` слушает только `127.0.0.1:8768`.
|
||||||
- `gemini_proxy/test_app.py`: добавлен тест, что `api_key_override` в форме
|
|
||||||
не даёт доступ без `GEMINI_API_KEY`.
|
|
||||||
|
|
||||||
### Изменения зависимостей
|
Подтверждено по Android:
|
||||||
|
|
||||||
- выровнен root `requirements.txt` по version bounds с
|
- `android-app/app/build.gradle.kts` задает `BuildConfig.RECEIPT_API_TOKEN`, токен попадает в клиентское приложение.
|
||||||
`recipe_service/requirements.txt`:
|
- `android-app/app/src/main/java/ru/obdai/receipt/network/ApiClient.kt` отправляет `Authorization: Bearer ...` из `BuildConfig`.
|
||||||
- `Flask>=3.0,<4`
|
- `android-app/app/src/main/java/ru/obdai/receipt/camera/CameraManager.kt` выполняет YUV->JPEG->Bitmap на кадр в analyzer.
|
||||||
- `gunicorn>=21.2,<24`
|
- `android-app/app/src/main/java/ru/obdai/receipt/MainActivity.kt` выводит результат и в `Column`, и в `ResultOverlay`.
|
||||||
- `requests>=2.31,<3`
|
- `android-app/app/src/main/java/ru/obdai/receipt/crop/MedicationZoneDetector.kt` блокирует при любой дате по regex, что дает ложные privacy-срабатывания.
|
||||||
|
- `android-app/app/src/main/AndroidManifest.xml` содержит `usesCleartextTraffic="false"`.
|
||||||
|
|
||||||
### Проверки
|
Подтверждено по тестам и зависимостям:
|
||||||
|
|
||||||
- `py_compile` изменённых Python-файлов: успешно.
|
- Тесты присутствуют только в `gemini_proxy/test_app.py`.
|
||||||
- `recipe_service`: `pytest -q` -> `8 passed`.
|
- Для `recipe_service` тестов нет.
|
||||||
- `gemini_proxy`: `pytest -q` -> `4 passed`.
|
- Корневой `requirements.txt` и `recipe_service/requirements.txt` расходятся по version bounds.
|
||||||
|
|
||||||
### Отдельно зафиксировано
|
Дополнительные риски, подтвержденные параллельным аудитом секретов:
|
||||||
|
|
||||||
Первый запуск тестов `recipe_service` дал `PermissionError` на `/var/lib/recipe`
|
- В локальных репозиториях обнаружены реальные токены/JWT и tracked secret-like файлы; часть секретов не отслеживается Git, но присутствует в рабочих каталогах.
|
||||||
при import-time `initialize()`. Исправлено в тесте ранней установкой
|
- В `tf_provider` обнаружен tracked `secrets/llm.key`; в истории есть многочисленные совпадения по маркерам `sk-`.
|
||||||
`RECIPE_METRICS_DB` в временный путь до импорта `app`.
|
- В `nifi_fork` история содержит совпадения по маркерам `sk-` и `AKIA`; часть совпадений относится к документации/тестам и требует ручной triage по значениям.
|
||||||
|
|
||||||
## 2026-08-31: Безопасная оптимизация без смены поведения
|
Статус:
|
||||||
|
|
||||||
По дополнительной команде пользователя выполнен пакет low-risk улучшений,
|
- Отчет Opus по backend и Android в критических пунктах в основном подтвержден.
|
||||||
направленный на производительность и устойчивость, без изменения основного
|
- Для части исторических совпадений по секретам нужна ручная классификация: реальный ключ, тестовый маркер или документационный пример.
|
||||||
контракта API.
|
- Код в этой итерации не менялся; выполнено документирование и валидация.
|
||||||
|
|
||||||
### Изменения
|
|
||||||
|
|
||||||
- `recipe_service/metrics.py`:
|
|
||||||
- добавлен индекс
|
|
||||||
`idx_requests_client_ip_started_at ON requests(client_ip, started_at)`
|
|
||||||
для ускорения выборки rate limiting.
|
|
||||||
- `recipe_service/app.py`:
|
|
||||||
- ответ `429` унифицирован и дополнен стабильным полем
|
|
||||||
`code="rate_limited"` при сохранении `error="too many requests"`.
|
|
||||||
- `recipe_service/test_app.py`:
|
|
||||||
- обновлена проверка `429` с новым полем `code`;
|
|
||||||
- добавлен тест граничного случая лимитера (`19` запросов -> `200`);
|
|
||||||
- добавлен тест чтения последней записи в SQLite-метриках с проверкой
|
|
||||||
`status_code`, `duration_ms` и `error` после ветки `502`.
|
|
||||||
|
|
||||||
### Проверки
|
|
||||||
|
|
||||||
- `py_compile` изменённых Python-файлов: успешно.
|
|
||||||
- `recipe_service`: `pytest -q` -> `10 passed`.
|
|
||||||
- `gemini_proxy`: `pytest -q` -> `4 passed`.
|
|
||||||
|
|
||||||
### Вывод
|
|
||||||
|
|
||||||
Оптимизации применены без регрессий. Поведение успешного запроса, а также
|
|
||||||
статусы `400/401/413/415/502` сохранены; `429` дополнен машинным кодом
|
|
||||||
ошибки для стабильной клиентской обработки.
|
|
||||||
|
|
||||||
## 2026-08-31: Nginx-level rate limiting (основной лимитер)
|
|
||||||
|
|
||||||
По команде пользователя добавлен основной лимит запросов на уровне nginx,
|
|
||||||
при сохранении app-level fallback в `recipe_service/app.py`.
|
|
||||||
|
|
||||||
### Изменения конфигурации
|
|
||||||
|
|
||||||
- Добавлен новый файл `recipe_service/nginx-rate-limit-http.conf`:
|
|
||||||
- `limit_req_zone $binary_remote_addr zone=recipe_api_per_ip:10m rate=20r/m;`
|
|
||||||
- `limit_req_status 429;`
|
|
||||||
- файл предназначен для single-include внутри `http { ... }`.
|
|
||||||
- Обновлён `recipe_service/nginx-recipe.conf`:
|
|
||||||
- для `location = /recipe`, `location /recipe/`, `location = /receipt`,
|
|
||||||
`location /receipt/` добавлен
|
|
||||||
`limit_req zone=recipe_api_per_ip burst=5 nodelay;`.
|
|
||||||
|
|
||||||
### Результат
|
|
||||||
|
|
||||||
- Лимит теперь применяется единообразно для всех воркеров gunicorn на входе
|
|
||||||
nginx, а не только внутри отдельного процесса приложения.
|
|
||||||
- Python fallback-лимитер сохранён как защитный второй контур.
|
|
||||||
|
|
||||||
### Проверки
|
|
||||||
|
|
||||||
- `py_compile` изменённых Python-файлов: успешно.
|
|
||||||
- `recipe_service`: `pytest -q` -> `10 passed`.
|
|
||||||
|
|||||||
@@ -182,3 +182,38 @@ crop лекарственной строки
|
|||||||
- начать с SQLite и перейти на отдельный PostgreSQL только при появлении требований к параллельной записи, админке или нескольким экземплярам сервиса.
|
- начать с SQLite и перейти на отдельный PostgreSQL только при появлении требований к параллельной записи, админке или нескольким экземплярам сервиса.
|
||||||
|
|
||||||
Перед фиксацией порогов и коэффициентов scoring провести эксперимент на 50–200 обезличенных рецептах.
|
Перед фиксацией порогов и коэффициентов scoring провести эксперимент на 50–200 обезличенных рецептах.
|
||||||
|
|
||||||
|
## 12. Критический план по результатам валидации Opus (2026-08-31)
|
||||||
|
|
||||||
|
### Подтвержденные проблемы и приоритет
|
||||||
|
|
||||||
|
1. `recipe_service/metrics.py`: убрать `initialize()` из `record()`; инициализация должна быть только на старте сервиса.
|
||||||
|
2. `recipe_service/app.py`: устранить дублирование `record(...)` через единый финализатор/обертку завершения запроса.
|
||||||
|
3. `recipe_service/app.py`: считать `duration_ms` во всех ветках, а не только при `401`.
|
||||||
|
4. `recipe_service/app.py`: заменить сравнение токена на constant-time (`hmac.compare_digest`).
|
||||||
|
5. `recipe_service/app.py`: минимизировать детализацию `502` для клиента, подробности оставлять в логах.
|
||||||
|
6. `recipe_service/app.py`: добавить учет заголовков прокси (`ProxyFix`) для корректного client IP.
|
||||||
|
7. Тесты: добавить `recipe_service/test_app.py` с кейсами `401/400/415/413/200`.
|
||||||
|
8. `requirements.txt`: выровнять root и `recipe_service/requirements.txt` по version bounds.
|
||||||
|
|
||||||
|
### Android-блок (после подтверждения модели безопасности)
|
||||||
|
|
||||||
|
1. Убрать долговременный статический секрет из APK или перейти на схему краткоживущих токенов.
|
||||||
|
2. Оптимизировать pipeline камеры: снизить частоту тяжелой конвертации или заменить способ получения bitmap.
|
||||||
|
3. Убрать двойной вывод результата (`Column` и `ResultOverlay`) и оставить один источник отображения.
|
||||||
|
4. Пересмотреть `containsPatientData` в `MedicationZoneDetector`: дата сама по себе не должна блокировать распознавание.
|
||||||
|
|
||||||
|
### Вопросы к Opus (требуют уточнения до правок)
|
||||||
|
|
||||||
|
1. `api_key_override` в `gemini_proxy/app.py`: удалять полностью или оставить только для локального debug-режима за флагом окружения?
|
||||||
|
2. По `502`: какой клиентский формат ошибки нужен — единое сообщение или кодизированные причины без деталей провайдера?
|
||||||
|
3. Для `ProxyFix`: доверяем ровно одному прокси (`x_for=1`) или нужен более строгий trust chain?
|
||||||
|
4. Для Android: подтверждаем перенос от статического API-токена к короткоживущему серверному token exchange?
|
||||||
|
5. По privacy-гейту: дата рецепта в medication-зоне допустима или должна только понижать confidence без блокировки?
|
||||||
|
|
||||||
|
### План выполнения после подтверждения
|
||||||
|
|
||||||
|
1. Backend фаза A: `metrics.py` + `app.py` (инициализация, duration, record-finalizer, compare_digest, ProxyFix).
|
||||||
|
2. Backend фаза B: тесты `recipe_service/test_app.py` + проверка pytest.
|
||||||
|
3. Backend фаза C: harmonize requirements.
|
||||||
|
4. Android фаза D: безопасность токена + оптимизация камеры + privacy-gate.
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "ru.obdai.receipt"
|
applicationId = "ru.obdai.receipt"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 7
|
versionCode = 6
|
||||||
versionName = "0.1.6"
|
versionName = "0.1.5"
|
||||||
|
|
||||||
val apiToken = providers.environmentVariable("RECEIPT_API_TOKEN").orNull ?: ""
|
val apiToken = providers.environmentVariable("RECEIPT_API_TOKEN").orNull ?: ""
|
||||||
buildConfigField("String", "RECEIPT_API_TOKEN", "\"${apiToken.replace("\\", "\\\\").replace("\"", "\\\"")}\"")
|
buildConfigField("String", "RECEIPT_API_TOKEN", "\"${apiToken.replace("\\", "\\\\").replace("\"", "\\\"")}\"")
|
||||||
|
|||||||
+2
-1
@@ -25,6 +25,7 @@ async def recognize(
|
|||||||
image: Annotated[UploadFile, File(...)],
|
image: Annotated[UploadFile, File(...)],
|
||||||
prompt: Annotated[str, Form(...)],
|
prompt: Annotated[str, Form(...)],
|
||||||
generation_config: Annotated[str, Form()] = "{}",
|
generation_config: Annotated[str, Form()] = "{}",
|
||||||
|
api_key_override: Annotated[str | None, Form()] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
if image.content_type not in ALLOWED_TYPES:
|
if image.content_type not in ALLOWED_TYPES:
|
||||||
raise HTTPException(status_code=415, detail="Unsupported image type")
|
raise HTTPException(status_code=415, detail="Unsupported image type")
|
||||||
@@ -33,7 +34,7 @@ async def recognize(
|
|||||||
if len(image_data) > MAX_IMAGE_BYTES:
|
if len(image_data) > MAX_IMAGE_BYTES:
|
||||||
raise HTTPException(status_code=413, detail="Image is too large")
|
raise HTTPException(status_code=413, detail="Image is too large")
|
||||||
|
|
||||||
api_key = os.getenv("GEMINI_API_KEY")
|
api_key = api_key_override or os.getenv("GEMINI_API_KEY")
|
||||||
if not api_key:
|
if not api_key:
|
||||||
raise HTTPException(status_code=503, detail="Gemini is not configured")
|
raise HTTPException(status_code=503, detail="Gemini is not configured")
|
||||||
|
|
||||||
|
|||||||
@@ -29,18 +29,3 @@ def test_missing_key_returns_service_unavailable(monkeypatch) -> None:
|
|||||||
data={"prompt": "test", "generation_config": "{}"},
|
data={"prompt": "test", "generation_config": "{}"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 503
|
assert response.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
def test_rejects_when_only_override_provided(monkeypatch) -> None:
|
|
||||||
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
|
||||||
client = TestClient(app)
|
|
||||||
response = client.post(
|
|
||||||
"/gemini",
|
|
||||||
files={"image": ("input.png", b"not-an-image", "image/png")},
|
|
||||||
data={
|
|
||||||
"prompt": "test",
|
|
||||||
"generation_config": "{}",
|
|
||||||
"api_key_override": "manual-key",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 503
|
|
||||||
+65
-72
@@ -1,24 +1,19 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import hmac
|
|
||||||
import time
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from flask import Flask, jsonify, request
|
from flask import Flask, jsonify, request
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from recipe_service.metrics import count_since, initialize, record, request_context, usage_json
|
from recipe_service.metrics import initialize, record, request_context, usage_json
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
from metrics import count_since, initialize, record, request_context, usage_json
|
from metrics import initialize, record, request_context, usage_json
|
||||||
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
|
|
||||||
MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||||
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
||||||
PROXY_URL = "http://127.0.0.1:8768/gemini"
|
PROXY_URL = "http://127.0.0.1:8768/gemini"
|
||||||
RATE_LIMIT_REQUESTS_PER_MINUTE = 20
|
|
||||||
|
|
||||||
initialize()
|
initialize()
|
||||||
|
|
||||||
@@ -36,18 +31,7 @@ def settings() -> dict:
|
|||||||
def authorized() -> bool:
|
def authorized() -> bool:
|
||||||
expected = os.environ.get("RECIPE_API_TOKEN")
|
expected = os.environ.get("RECIPE_API_TOKEN")
|
||||||
authorization = request.headers.get("Authorization", "")
|
authorization = request.headers.get("Authorization", "")
|
||||||
return bool(expected and hmac.compare_digest(authorization, f"Bearer {expected}"))
|
return bool(expected and authorization == f"Bearer {expected}")
|
||||||
|
|
||||||
|
|
||||||
def minute_start_utc(epoch_seconds: float) -> str:
|
|
||||||
return time.strftime("%Y-%m-%dT%H:%M:00Z", time.gmtime(epoch_seconds))
|
|
||||||
|
|
||||||
|
|
||||||
def is_rate_limited(client_ip: str, now_epoch: float) -> bool:
|
|
||||||
if not client_ip:
|
|
||||||
return False
|
|
||||||
window_start = minute_start_utc(now_epoch)
|
|
||||||
return count_since(client_ip=client_ip, started_at_from=window_start) >= RATE_LIMIT_REQUESTS_PER_MINUTE
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
@@ -65,58 +49,54 @@ def recipe():
|
|||||||
image_mime = None
|
image_mime = None
|
||||||
image_bytes = None
|
image_bytes = None
|
||||||
prompt_chars = None
|
prompt_chars = None
|
||||||
|
status_code = 500
|
||||||
|
response_bytes = None
|
||||||
usage = {}
|
usage = {}
|
||||||
client_ip = request.remote_addr
|
error = None
|
||||||
|
|
||||||
def finalize(response, status_code: int, error: str | None):
|
|
||||||
duration_ms = int((time.monotonic() - started_monotonic) * 1000)
|
|
||||||
response_bytes = len(response.get_data())
|
|
||||||
record(
|
|
||||||
request_id=request_id,
|
|
||||||
started_at=started_at,
|
|
||||||
client_ip=client_ip,
|
|
||||||
user_agent=request.user_agent.string,
|
|
||||||
method=request.method,
|
|
||||||
path=request.path,
|
|
||||||
image_mime=image_mime,
|
|
||||||
image_bytes=image_bytes,
|
|
||||||
prompt_chars=prompt_chars,
|
|
||||||
status_code=status_code,
|
|
||||||
duration_ms=duration_ms,
|
|
||||||
response_bytes=response_bytes,
|
|
||||||
usage_json=usage_json(usage),
|
|
||||||
error=error,
|
|
||||||
)
|
|
||||||
return response, status_code
|
|
||||||
|
|
||||||
if not authorized():
|
if not authorized():
|
||||||
return finalize(jsonify(error="unauthorized"), 401, "unauthorized")
|
status_code = 401
|
||||||
|
error = "unauthorized"
|
||||||
if is_rate_limited(client_ip=client_ip or "", now_epoch=time.time()):
|
response = jsonify(error=error)
|
||||||
return finalize(
|
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
|
||||||
jsonify(error="too many requests", code="rate_limited"),
|
user_agent=request.user_agent.string, method=request.method, path=request.path,
|
||||||
429,
|
image_mime=image_mime, image_bytes=image_bytes, prompt_chars=prompt_chars,
|
||||||
"rate_limited",
|
status_code=status_code, duration_ms=int((__import__('time').monotonic() - started_monotonic) * 1000),
|
||||||
)
|
response_bytes=len(response.get_data()), usage_json=usage_json(usage), error=error)
|
||||||
|
return response, status_code
|
||||||
image = request.files.get("image")
|
image = request.files.get("image")
|
||||||
prompt = request.form.get("prompt")
|
prompt = request.form.get("prompt")
|
||||||
if image is None or not prompt:
|
if image is None or not prompt:
|
||||||
return finalize(jsonify(error="image and prompt are required"), 400, "image and prompt are required")
|
status_code = 400
|
||||||
|
error = "image and prompt are required"
|
||||||
prompt_chars = len(prompt)
|
response = jsonify(error=error)
|
||||||
|
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
|
||||||
|
user_agent=request.user_agent.string, method=request.method, path=request.path,
|
||||||
|
status_code=status_code, duration_ms=0, response_bytes=len(response.get_data()),
|
||||||
|
usage_json=usage_json(usage), error=error)
|
||||||
|
return response, status_code
|
||||||
if image.mimetype not in ALLOWED_TYPES:
|
if image.mimetype not in ALLOWED_TYPES:
|
||||||
image_mime = image.mimetype
|
status_code = 415
|
||||||
return finalize(jsonify(error="unsupported image type"), 415, "unsupported image type")
|
error = "unsupported image type"
|
||||||
|
response = jsonify(error=error)
|
||||||
|
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
|
||||||
|
user_agent=request.user_agent.string, method=request.method, path=request.path,
|
||||||
|
image_mime=image.mimetype, prompt_chars=len(prompt), status_code=status_code,
|
||||||
|
duration_ms=0, response_bytes=len(response.get_data()), usage_json=usage_json(usage), error=error)
|
||||||
|
return response, status_code
|
||||||
image_data = image.read(MAX_IMAGE_BYTES + 1)
|
image_data = image.read(MAX_IMAGE_BYTES + 1)
|
||||||
|
if len(image_data) > MAX_IMAGE_BYTES:
|
||||||
|
status_code = 413
|
||||||
|
error = "image is too large"
|
||||||
|
response = jsonify(error=error)
|
||||||
|
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
|
||||||
|
user_agent=request.user_agent.string, method=request.method, path=request.path,
|
||||||
|
image_mime=image.mimetype, image_bytes=len(image_data), prompt_chars=len(prompt),
|
||||||
|
status_code=status_code, duration_ms=0, response_bytes=len(response.get_data()),
|
||||||
|
usage_json=usage_json(usage), error=error)
|
||||||
|
return response, status_code
|
||||||
image_mime = image.mimetype
|
image_mime = image.mimetype
|
||||||
image_bytes = len(image_data)
|
image_bytes = len(image_data)
|
||||||
|
prompt_chars = len(prompt)
|
||||||
if len(image_data) > MAX_IMAGE_BYTES:
|
|
||||||
return finalize(jsonify(error="image is too large"), 413, "image is too large")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config = settings()
|
config = settings()
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
@@ -127,21 +107,34 @@ def recipe():
|
|||||||
)
|
)
|
||||||
except (requests.RequestException, RuntimeError) as exc:
|
except (requests.RequestException, RuntimeError) as exc:
|
||||||
error = str(exc) if isinstance(exc, RuntimeError) else "Gemini unavailable"
|
error = str(exc) if isinstance(exc, RuntimeError) else "Gemini unavailable"
|
||||||
return finalize(jsonify(error=error), 503, error)
|
status_code = 503
|
||||||
|
response = jsonify(error=error)
|
||||||
|
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
|
||||||
|
user_agent=request.user_agent.string, method=request.method, path=request.path,
|
||||||
|
image_mime=image_mime, image_bytes=image_bytes, prompt_chars=prompt_chars,
|
||||||
|
status_code=status_code, duration_ms=0, response_bytes=len(response.get_data()),
|
||||||
|
usage_json=usage_json(usage), error=error)
|
||||||
|
return response, status_code
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
try:
|
try:
|
||||||
detail = response.json().get("error", {}).get("message", "Gemini request failed")
|
detail = response.json().get("error", {}).get("message", "Gemini request failed")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
detail = "Gemini request failed"
|
detail = "Gemini request failed"
|
||||||
app.logger.warning("upstream_failure request_id=%s detail=%s", request_id, detail)
|
error = detail
|
||||||
return finalize(
|
status_code = 502
|
||||||
jsonify(error="upstream recognition failed", code="upstream_error"),
|
response = jsonify(error=error)
|
||||||
502,
|
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
|
||||||
"upstream_error",
|
user_agent=request.user_agent.string, method=request.method, path=request.path,
|
||||||
)
|
image_mime=image_mime, image_bytes=image_bytes, prompt_chars=prompt_chars,
|
||||||
|
status_code=status_code, duration_ms=0, response_bytes=len(response.get_data()),
|
||||||
|
usage_json=usage_json(usage), error=error)
|
||||||
|
return response, status_code
|
||||||
data = response.json()
|
data = response.json()
|
||||||
usage = data.get("usage", {})
|
usage = data.get("usage", {})
|
||||||
result = jsonify(text=data.get("text"), usage=usage)
|
result = jsonify(text=data.get("text"), usage=usage)
|
||||||
return finalize(result, 200, None)
|
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
|
||||||
|
user_agent=request.user_agent.string, method=request.method, path=request.path,
|
||||||
|
image_mime=image_mime, image_bytes=image_bytes, prompt_chars=prompt_chars,
|
||||||
|
status_code=200, duration_ms=0, response_bytes=len(result.get_data()),
|
||||||
|
usage_json=usage_json(usage), error=None)
|
||||||
|
return result
|
||||||
@@ -37,12 +37,10 @@ def initialize() -> None:
|
|||||||
""")
|
""")
|
||||||
connection.execute("CREATE INDEX IF NOT EXISTS idx_requests_started_at ON requests(started_at)")
|
connection.execute("CREATE INDEX IF NOT EXISTS idx_requests_started_at ON requests(started_at)")
|
||||||
connection.execute("CREATE INDEX IF NOT EXISTS idx_requests_status_code ON requests(status_code)")
|
connection.execute("CREATE INDEX IF NOT EXISTS idx_requests_status_code ON requests(status_code)")
|
||||||
connection.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_requests_client_ip_started_at ON requests(client_ip, started_at)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def record(**values) -> None:
|
def record(**values) -> None:
|
||||||
|
initialize()
|
||||||
columns = [
|
columns = [
|
||||||
"request_id", "started_at", "client_ip", "user_agent", "method",
|
"request_id", "started_at", "client_ip", "user_agent", "method",
|
||||||
"path", "image_mime", "image_bytes", "prompt_chars", "status_code",
|
"path", "image_mime", "image_bytes", "prompt_chars", "status_code",
|
||||||
@@ -56,21 +54,6 @@ def record(**values) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def count_since(client_ip: str, started_at_from: str) -> int:
|
|
||||||
with sqlite3.connect(db_path()) as connection:
|
|
||||||
row = connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM requests
|
|
||||||
WHERE client_ip = ?
|
|
||||||
AND started_at >= ?
|
|
||||||
AND path IN ('/receipt', '/receipt/', '/recipe', '/recipe/')
|
|
||||||
""",
|
|
||||||
(client_ip, started_at_from),
|
|
||||||
).fetchone()
|
|
||||||
return int(row[0] if row else 0)
|
|
||||||
|
|
||||||
|
|
||||||
def request_context() -> tuple[str, str, float]:
|
def request_context() -> tuple[str, str, float]:
|
||||||
return str(uuid.uuid4()), time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), time.monotonic()
|
return str(uuid.uuid4()), time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), time.monotonic()
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
# Include this file once inside nginx `http { ... }` block.
|
|
||||||
# Example: include /etc/nginx/conf.d/recipe-rate-limit-http.conf;
|
|
||||||
|
|
||||||
# Per-client limit for recipe/receipt API requests.
|
|
||||||
limit_req_zone $binary_remote_addr zone=recipe_api_per_ip:10m rate=20r/m;
|
|
||||||
|
|
||||||
# Return 429 for throttled requests.
|
|
||||||
limit_req_status 429;
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
location = /recipe {
|
location = /recipe {
|
||||||
limit_req zone=recipe_api_per_ip burst=5 nodelay;
|
|
||||||
proxy_pass http://127.0.0.1:8770/recipe;
|
proxy_pass http://127.0.0.1:8770/recipe;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -13,7 +12,6 @@ location = /recipe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /recipe/ {
|
location /recipe/ {
|
||||||
limit_req zone=recipe_api_per_ip burst=5 nodelay;
|
|
||||||
proxy_pass http://127.0.0.1:8770/recipe/;
|
proxy_pass http://127.0.0.1:8770/recipe/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -27,7 +25,6 @@ location /recipe/ {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location = /receipt {
|
location = /receipt {
|
||||||
limit_req zone=recipe_api_per_ip burst=5 nodelay;
|
|
||||||
proxy_pass http://127.0.0.1:8770/receipt;
|
proxy_pass http://127.0.0.1:8770/receipt;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -41,7 +38,6 @@ location = /receipt {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /receipt/ {
|
location /receipt/ {
|
||||||
limit_req zone=recipe_api_per_ip burst=5 nodelay;
|
|
||||||
proxy_pass http://127.0.0.1:8770/receipt/;
|
proxy_pass http://127.0.0.1:8770/receipt/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
@@ -1,223 +0,0 @@
|
|||||||
import io
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
os.environ.setdefault(
|
|
||||||
"RECIPE_METRICS_DB",
|
|
||||||
os.path.join(tempfile.gettempdir(), "recipe-service-tests-metrics.sqlite3"),
|
|
||||||
)
|
|
||||||
|
|
||||||
from app import app
|
|
||||||
|
|
||||||
|
|
||||||
class MockResponse:
|
|
||||||
def __init__(self, status_code: int, payload: dict | None = None):
|
|
||||||
self.status_code = status_code
|
|
||||||
self._payload = payload or {}
|
|
||||||
|
|
||||||
def json(self) -> dict:
|
|
||||||
return self._payload
|
|
||||||
|
|
||||||
|
|
||||||
def auth_header() -> dict[str, str]:
|
|
||||||
return {"Authorization": "Bearer test-token"}
|
|
||||||
|
|
||||||
|
|
||||||
def make_image(content: bytes = b"img") -> tuple[io.BytesIO, str, str]:
|
|
||||||
return io.BytesIO(content), "sample.png", "image/png"
|
|
||||||
|
|
||||||
|
|
||||||
def test_health() -> None:
|
|
||||||
client = app.test_client()
|
|
||||||
response = client.get("/health")
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.get_json() == {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_requires_authorization(monkeypatch) -> None:
|
|
||||||
monkeypatch.setenv("RECIPE_API_TOKEN", "test-token")
|
|
||||||
client = app.test_client()
|
|
||||||
response = client.post("/receipt")
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert response.get_json() == {"error": "unauthorized"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_image_or_prompt(monkeypatch) -> None:
|
|
||||||
monkeypatch.setenv("RECIPE_API_TOKEN", "test-token")
|
|
||||||
client = app.test_client()
|
|
||||||
response = client.post("/receipt", headers=auth_header())
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert response.get_json() == {"error": "image and prompt are required"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_unsupported_type(monkeypatch) -> None:
|
|
||||||
monkeypatch.setenv("RECIPE_API_TOKEN", "test-token")
|
|
||||||
client = app.test_client()
|
|
||||||
response = client.post(
|
|
||||||
"/receipt",
|
|
||||||
headers=auth_header(),
|
|
||||||
data={
|
|
||||||
"prompt": "p",
|
|
||||||
"image": (io.BytesIO(b"x"), "bad.txt", "text/plain"),
|
|
||||||
},
|
|
||||||
content_type="multipart/form-data",
|
|
||||||
)
|
|
||||||
assert response.status_code == 415
|
|
||||||
assert response.get_json() == {"error": "unsupported image type"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_image_too_large(monkeypatch) -> None:
|
|
||||||
monkeypatch.setenv("RECIPE_API_TOKEN", "test-token")
|
|
||||||
client = app.test_client()
|
|
||||||
payload = b"a" * (10 * 1024 * 1024 + 1)
|
|
||||||
response = client.post(
|
|
||||||
"/receipt",
|
|
||||||
headers=auth_header(),
|
|
||||||
data={
|
|
||||||
"prompt": "p",
|
|
||||||
"image": (io.BytesIO(payload), "big.png", "image/png"),
|
|
||||||
},
|
|
||||||
content_type="multipart/form-data",
|
|
||||||
)
|
|
||||||
assert response.status_code == 413
|
|
||||||
assert response.get_json() == {"error": "image is too large"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_success(monkeypatch) -> None:
|
|
||||||
monkeypatch.setenv("RECIPE_API_TOKEN", "test-token")
|
|
||||||
|
|
||||||
def fake_post(*args, **kwargs):
|
|
||||||
return MockResponse(200, {"text": "ok", "usage": {"totalTokens": 10}})
|
|
||||||
|
|
||||||
monkeypatch.setattr(requests, "post", fake_post)
|
|
||||||
client = app.test_client()
|
|
||||||
response = client.post(
|
|
||||||
"/receipt",
|
|
||||||
headers=auth_header(),
|
|
||||||
data={
|
|
||||||
"prompt": "p",
|
|
||||||
"image": make_image(),
|
|
||||||
},
|
|
||||||
content_type="multipart/form-data",
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.get_json() == {"text": "ok", "usage": {"totalTokens": 10}}
|
|
||||||
|
|
||||||
|
|
||||||
def test_upstream_502_contract(monkeypatch) -> None:
|
|
||||||
monkeypatch.setenv("RECIPE_API_TOKEN", "test-token")
|
|
||||||
|
|
||||||
def fake_post(*args, **kwargs):
|
|
||||||
return MockResponse(500, {"error": {"message": "provider detail"}})
|
|
||||||
|
|
||||||
monkeypatch.setattr(requests, "post", fake_post)
|
|
||||||
client = app.test_client()
|
|
||||||
response = client.post(
|
|
||||||
"/receipt",
|
|
||||||
headers=auth_header(),
|
|
||||||
data={
|
|
||||||
"prompt": "p",
|
|
||||||
"image": make_image(),
|
|
||||||
},
|
|
||||||
content_type="multipart/form-data",
|
|
||||||
)
|
|
||||||
assert response.status_code == 502
|
|
||||||
assert response.get_json() == {
|
|
||||||
"error": "upstream recognition failed",
|
|
||||||
"code": "upstream_error",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_rate_limit_returns_429(monkeypatch) -> None:
|
|
||||||
monkeypatch.setenv("RECIPE_API_TOKEN", "test-token")
|
|
||||||
|
|
||||||
def fake_count_since(client_ip: str, started_at_from: str) -> int:
|
|
||||||
return 20
|
|
||||||
|
|
||||||
monkeypatch.setattr("app.count_since", fake_count_since)
|
|
||||||
client = app.test_client()
|
|
||||||
response = client.post(
|
|
||||||
"/receipt",
|
|
||||||
headers=auth_header(),
|
|
||||||
data={
|
|
||||||
"prompt": "p",
|
|
||||||
"image": make_image(),
|
|
||||||
},
|
|
||||||
content_type="multipart/form-data",
|
|
||||||
environ_base={"REMOTE_ADDR": "198.51.100.10"},
|
|
||||||
)
|
|
||||||
assert response.status_code == 429
|
|
||||||
assert response.get_json() == {"error": "too many requests", "code": "rate_limited"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_rate_limit_allows_below_threshold(monkeypatch) -> None:
|
|
||||||
monkeypatch.setenv("RECIPE_API_TOKEN", "test-token")
|
|
||||||
|
|
||||||
def fake_count_since(client_ip: str, started_at_from: str) -> int:
|
|
||||||
return 19
|
|
||||||
|
|
||||||
def fake_post(*args, **kwargs):
|
|
||||||
return MockResponse(200, {"text": "ok", "usage": {}})
|
|
||||||
|
|
||||||
monkeypatch.setattr("app.count_since", fake_count_since)
|
|
||||||
monkeypatch.setattr(requests, "post", fake_post)
|
|
||||||
|
|
||||||
client = app.test_client()
|
|
||||||
response = client.post(
|
|
||||||
"/receipt",
|
|
||||||
headers=auth_header(),
|
|
||||||
data={
|
|
||||||
"prompt": "p",
|
|
||||||
"image": make_image(),
|
|
||||||
},
|
|
||||||
content_type="multipart/form-data",
|
|
||||||
environ_base={"REMOTE_ADDR": "198.51.100.11"},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
def test_metrics_record_duration_and_status(monkeypatch) -> None:
|
|
||||||
db_file = os.path.join(tempfile.gettempdir(), "recipe-service-tests-metrics-duration.sqlite3")
|
|
||||||
if os.path.exists(db_file):
|
|
||||||
os.remove(db_file)
|
|
||||||
|
|
||||||
monkeypatch.setenv("RECIPE_METRICS_DB", db_file)
|
|
||||||
monkeypatch.setenv("RECIPE_API_TOKEN", "test-token")
|
|
||||||
|
|
||||||
from metrics import initialize
|
|
||||||
|
|
||||||
initialize()
|
|
||||||
|
|
||||||
def fake_post(*args, **kwargs):
|
|
||||||
return MockResponse(500, {"error": {"message": "provider detail"}})
|
|
||||||
|
|
||||||
monkeypatch.setattr(requests, "post", fake_post)
|
|
||||||
client = app.test_client()
|
|
||||||
response = client.post(
|
|
||||||
"/receipt",
|
|
||||||
headers=auth_header(),
|
|
||||||
data={
|
|
||||||
"prompt": "p",
|
|
||||||
"image": make_image(),
|
|
||||||
},
|
|
||||||
content_type="multipart/form-data",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 502
|
|
||||||
connection = sqlite3.connect(db_file)
|
|
||||||
try:
|
|
||||||
row = connection.execute(
|
|
||||||
"SELECT status_code, duration_ms, error FROM requests ORDER BY rowid DESC LIMIT 1"
|
|
||||||
).fetchone()
|
|
||||||
finally:
|
|
||||||
connection.close()
|
|
||||||
|
|
||||||
assert row is not None
|
|
||||||
status_code, duration_ms, error = row
|
|
||||||
assert status_code == 502
|
|
||||||
assert duration_ms >= 0
|
|
||||||
assert error == "upstream_error"
|
|
||||||
+3
-3
@@ -1,3 +1,3 @@
|
|||||||
Flask>=3.0,<4
|
Flask>=3.0
|
||||||
gunicorn>=21.2,<24
|
gunicorn>=21.2
|
||||||
requests>=2.31,<3
|
requests>=2.31
|
||||||
Reference in New Issue
Block a user