tradein: 429 от Nominatim — сдерживатель темпа стоит на успешной ветке, при череде неудач его нет #2953

Closed
opened 2026-08-20 07:39:03 +00:00 by bot-backend · 4 comments
Collaborator

Что видно на проде

HTTPStatusError: 429 Too many requests от nominatim.openstreetmap.org194 события за последние 7 суток (GlitchTip, Trade-In). Это не внешняя случайность: мы сами превышаем политику 1 req/sec, причём именно там, где превышать не следует.

Сдерживатель темпа стоит не там

geocoder.py — три места, и ни одно не даёт глобального ограничения:

место что делает
geocode() :1894 await asyncio.sleep(1.0)внутри if result is not None, то есть только после УСПЕХА
_nominatim_lookup() :793 sleep(1.0) между typo-вариантами, но НЕ перед tier-1
@retry(stop_after_attempt(3)) :761 при исключении перезапускает всю функцию — tier-1 плюс до 4 вариантов заново

Итог для неудачного адреса: tier-1 в t=0, четыре варианта к t=4 — 5 запросов за 4 секунды, дальше сразу следующий адрес без паузы. При исключении (а 429 приходит именно исключением, HTTPStatusError) вся веерная попытка повторяется ещё дважды.

То есть темп сбрасывается ровно тогда, когда сервер нас и просит притормозить, и разгоняется ровно на тех адресах, которые не резолвятся.

Цикл бэкфилла (tasks/geocode_missing.py:171) зовёт geocode() последовательно и своей паузы не имеет — единственный сдерживатель тот, что на успешной ветке.

Почему череда неудач — не редкость, а норма прогона

Разбор 16 947 объявлений без координат:

без координат всего 16 947
из них активных 1 948
активных с пригодным адресом 1 471
доступно бэкфиллу прямо сейчас 47
в семидневном backoff (пробовали и не смогли) 1 424

97 % активных «без координат» — повторные неудачи. Каждые 7 суток они снова попадают в очередь и снова дают череду отказов, то есть разгон происходит каждый прогон, а не изредка.

При этом сам бэкфилл исправен: 19.08 — saved=419 из checked=422, то есть на разрешимых адресах промахов почти нет.

Отдельно: цена уже принятого решения (не предлагаю пересматривать)

Ключ Яндекс-геокодера недействителен с 31.07 (#2585), владелец решил не перевыпускать (#2593). Решение зафиксировано; здесь только измеренная цена, которой на момент решения не было в цифрах.

Провайдеры в geocode_cache по неделям:

06-15  yandex 559   nominatim 63
06-22  yandex 683   nominatim 65
07-06  yandex 2578  nominatim 9
07-13  yandex 82    nominatim 2
07-27  yandex 2     nominatim 398   ← перелом
08-10  yandex 0     nominatim 900

Доля объявлений без координат по неделям сбора:

22.06–20.07   0.0–0.3 %
27.07        12.1 %
03.08         9.1 %
10.08         7.9 %
17.08         5.2 %

Яндекс давал основную массу; Nominatim по российской адресации покрывает хуже. 1 424 активных объявления он не резолвит вовсе — они останутся без координат при любой починке темпа.

Что чинить в этом issue

Только наш дефект — темп. Он самостоятелен и полезен независимо от вопроса о провайдере:

  1. Один общий ограничитель темпа на все обращения к Nominatim (перед КАЖДЫМ запросом, а не после успеха) — вместо трёх разрозненных sleep.
  2. 429 не должен запускать повтор веерной попытки: это просьба сбавить темп, а не сбой запроса.

Критерий приёмки

Через 7 суток после выкатки: событий 429 Too many requests от Nominatim в GlitchTip — ноль, при неизменившемся числе прогонов бэкфилла. Показатель saved/checked не должен упасть.

## Что видно на проде `HTTPStatusError: 429 Too many requests` от `nominatim.openstreetmap.org` — **194 события за последние 7 суток** (GlitchTip, Trade-In). Это не внешняя случайность: мы сами превышаем политику 1 req/sec, причём именно там, где превышать не следует. ## Сдерживатель темпа стоит не там `geocoder.py` — три места, и ни одно не даёт глобального ограничения: | место | что делает | |---|---| | `geocode()` :1894 | `await asyncio.sleep(1.0)` — **внутри `if result is not None`**, то есть только после УСПЕХА | | `_nominatim_lookup()` :793 | `sleep(1.0)` **между** typo-вариантами, но НЕ перед tier-1 | | `@retry(stop_after_attempt(3))` :761 | при исключении перезапускает всю функцию — tier-1 плюс до 4 вариантов заново | Итог для неудачного адреса: tier-1 в t=0, четыре варианта к t=4 — **5 запросов за 4 секунды**, дальше сразу следующий адрес без паузы. При исключении (а 429 приходит именно исключением, `HTTPStatusError`) вся веерная попытка повторяется ещё дважды. То есть темп сбрасывается ровно тогда, когда сервер нас и просит притормозить, и разгоняется ровно на тех адресах, которые не резолвятся. Цикл бэкфилла (`tasks/geocode_missing.py:171`) зовёт `geocode()` последовательно и **своей паузы не имеет** — единственный сдерживатель тот, что на успешной ветке. ## Почему череда неудач — не редкость, а норма прогона Разбор 16 947 объявлений без координат: | | | |---|---| | без координат всего | 16 947 | | из них активных | 1 948 | | активных с пригодным адресом | 1 471 | | доступно бэкфиллу прямо сейчас | 47 | | **в семидневном backoff (пробовали и не смогли)** | **1 424** | 97 % активных «без координат» — повторные неудачи. Каждые 7 суток они снова попадают в очередь и снова дают череду отказов, то есть разгон происходит **каждый прогон**, а не изредка. При этом сам бэкфилл исправен: 19.08 — `saved=419` из `checked=422`, то есть на разрешимых адресах промахов почти нет. ## Отдельно: цена уже принятого решения (не предлагаю пересматривать) Ключ Яндекс-геокодера недействителен с 31.07 (#2585), владелец решил не перевыпускать (#2593). Решение зафиксировано; здесь только измеренная цена, которой на момент решения не было в цифрах. Провайдеры в `geocode_cache` по неделям: ``` 06-15 yandex 559 nominatim 63 06-22 yandex 683 nominatim 65 07-06 yandex 2578 nominatim 9 07-13 yandex 82 nominatim 2 07-27 yandex 2 nominatim 398 ← перелом 08-10 yandex 0 nominatim 900 ``` Доля объявлений без координат по неделям сбора: ``` 22.06–20.07 0.0–0.3 % 27.07 12.1 % 03.08 9.1 % 10.08 7.9 % 17.08 5.2 % ``` Яндекс давал основную массу; Nominatim по российской адресации покрывает хуже. 1 424 активных объявления он не резолвит вовсе — они останутся без координат при любой починке темпа. ## Что чинить в этом issue Только наш дефект — темп. Он самостоятелен и полезен независимо от вопроса о провайдере: 1. Один общий ограничитель темпа на все обращения к Nominatim (перед КАЖДЫМ запросом, а не после успеха) — вместо трёх разрозненных `sleep`. 2. `429` не должен запускать повтор веерной попытки: это просьба сбавить темп, а не сбой запроса. ## Критерий приёмки Через 7 суток после выкатки: событий `429 Too many requests` от Nominatim в GlitchTip — ноль, при неизменившемся числе прогонов бэкфилла. Показатель `saved/checked` не должен упасть.
Author
Collaborator

База ДО правки и критерий — записаны до факта

Правка смержена 20.08 (#2954), выкатка следом.

Замер на момент мержа (GlitchTip, тот же запрос понадобится при проверке):

SELECT count(*) AS issue_строк, sum(count) AS событий, max(last_seen)::date
  FROM issue_events_issue
 WHERE title ILIKE '%429 Too many requests%' AND title ILIKE '%nominatim%';
issue_строк | событий | последнее
1           | 194     | 2026-08-19

Проверить 27.08.2026. Считать НОВЫЕ события: строк issue с first_seen после 20.08 — ноль; у существующей строки last_seen не сдвинулся за 20.08.

Условия, при которых замер не годится и его надо повторить, а не толковать:

  • прогонов geocode_missing_listings за неделю меньше 5 (то есть бэкфилл почти не ходил — тогда ноль означает «не спрашивали», а не «перестали получать отказ»); текущий темп — 13 прогонов за 14 суток;
  • отношение saved/checked в этих прогонах упало заметно ниже нынешних ~99% — значит ограничитель темпа съел пропускную способность, и ноль 429 куплен не тем;
  • Trade-In перестал слать события в GlitchTip вообще (проверять по любым другим issue проекта за те же сутки) — тогда ноль про инструмент, а не про темп.

Если 429 останутся — следующий шаг известен и намеренно отложен: повторы на 429 сейчас перезапускают всю веерную попытку (tier-1 + до 4 typo-вариантов). Не тронул одной правкой специально, чтобы падение счётчика можно было приписать именно ограничителю.

## База ДО правки и критерий — записаны до факта Правка смержена 20.08 (#2954), выкатка следом. Замер на момент мержа (GlitchTip, тот же запрос понадобится при проверке): ```sql SELECT count(*) AS issue_строк, sum(count) AS событий, max(last_seen)::date FROM issue_events_issue WHERE title ILIKE '%429 Too many requests%' AND title ILIKE '%nominatim%'; ``` ``` issue_строк | событий | последнее 1 | 194 | 2026-08-19 ``` **Проверить 27.08.2026.** Считать НОВЫЕ события: строк issue с `first_seen` после 20.08 — ноль; у существующей строки `last_seen` не сдвинулся за 20.08. Условия, при которых замер не годится и его надо повторить, а не толковать: - прогонов `geocode_missing_listings` за неделю меньше 5 (то есть бэкфилл почти не ходил — тогда ноль означает «не спрашивали», а не «перестали получать отказ»); текущий темп — 13 прогонов за 14 суток; - отношение `saved/checked` в этих прогонах упало заметно ниже нынешних ~99% — значит ограничитель темпа съел пропускную способность, и ноль 429 куплен не тем; - Trade-In перестал слать события в GlitchTip вообще (проверять по любым другим issue проекта за те же сутки) — тогда ноль про инструмент, а не про темп. Если 429 останутся — следующий шаг известен и намеренно отложен: повторы на `429` сейчас перезапускают всю веерную попытку (tier-1 + до 4 typo-вариантов). Не тронул одной правкой специально, чтобы падение счётчика можно было приписать именно ограничителю.
Author
Collaborator

Помеха, размеченная ДО окна проверки

Проверил собственную оговорку из PR («ограничитель внутрипроцессный») — она предметная, а не теоретическая:

tradein-backend   uvicorn app.main:app         ← пользовательские запросы
tradein-scraper   python -m app.scheduler_main ← бэкфилл geocode_missing_listings

Образ у них один (sha256:29177613…), ограничитель в обоих на месте (4 упоминания, старых sleep(1.0) — 0). Но это два процесса, значит два независимых ограничителя, и в моменты наложения суммарный темп может дойти до 2 req/s.

Записываю это сейчас, до 27.08, чтобы результат нельзя было подогнать под удобное толкование:

  • 429 стало ноль → ограничитель закрыл вопрос;
  • 429 заметно упало, но не до нуля → остаток, скорее всего, от наложения двух процессов, а не от неработающего ограничителя. Проверять по времени событий: если они кучкуются в окна прогонов бэкфилла — да, наложение. Лечится общим счётчиком (Redis);
  • 429 не изменилось → ограничитель не работает, гипотеза про темп неверна, и правку надо разбирать заново, а не объяснять.

Доминирующий источник запросов — бэкфилл (сотни за прогон против редких пользовательских), и именно в нём была череда неудач, разгонявшая темп. Так что основного эффекта жду от него.

Прод-проверка кода (20.08)

tradein-backend  _nominatim_throttle=4  старых sleep(1.0)=0   Up 19 minutes
tradein-scraper  _nominatim_throttle=4  старых sleep(1.0)=0
## Помеха, размеченная ДО окна проверки Проверил собственную оговорку из PR («ограничитель внутрипроцессный») — она предметная, а не теоретическая: ``` tradein-backend uvicorn app.main:app ← пользовательские запросы tradein-scraper python -m app.scheduler_main ← бэкфилл geocode_missing_listings ``` Образ у них один (`sha256:29177613…`), ограничитель в обоих на месте (4 упоминания, старых `sleep(1.0)` — 0). Но это **два процесса**, значит два независимых ограничителя, и в моменты наложения суммарный темп может дойти до 2 req/s. Записываю это сейчас, до 27.08, чтобы результат нельзя было подогнать под удобное толкование: - **429 стало ноль** → ограничитель закрыл вопрос; - **429 заметно упало, но не до нуля** → остаток, скорее всего, от наложения двух процессов, а не от неработающего ограничителя. Проверять по времени событий: если они кучкуются в окна прогонов бэкфилла — да, наложение. Лечится общим счётчиком (Redis); - **429 не изменилось** → ограничитель не работает, гипотеза про темп неверна, и правку надо разбирать заново, а не объяснять. Доминирующий источник запросов — бэкфилл (сотни за прогон против редких пользовательских), и именно в нём была череда неудач, разгонявшая темп. Так что основного эффекта жду от него. ## Прод-проверка кода (20.08) ``` tradein-backend _nominatim_throttle=4 старых sleep(1.0)=0 Up 19 minutes tradein-scraper _nominatim_throttle=4 старых sleep(1.0)=0 ```
Author
Collaborator

Проверка 27.08 — по критерию, записанному 20.08 до факта

1. Новых 429 нет

title                                     count | first_seen | last_seen
429 Too many requests … nominatim …        194  | 18.08 03:24 | 19.08 21:22 UTC

Единственная строка issue, count не сдвинулся с базы (194), last_seen19.08 21:22 UTC, то есть до мержа. Строк с first_seen после 20.08 — ноль. Других ошибок геокодера за период тоже нет (проверял по title/culprit ILIKE '%nominatim%|%geocod%'): единственная соседняя строка — Avito HTTP 403, последняя 10.08, к Nominatim отношения не имеет.

2. Бэкфилл ходил — 8 прогонов за неделю (порог был 5)

geocode_missing_listings, 20.08–27.08: run_id 4450, 4476, 4573, 4687, 4794, 4898, 4994, 5017. Последний — сегодня 03:21 UTC.

3. Инструмент жив

Trade-In шлёт события в GlitchTip каждый день, включая сегодня: 27.08 — 20 issue-строк / 146 событий, 26.08 — 25 / 300. Ноль по 429 не от того, что проект замолчал.

4. Пропускная способность — оговорка, и она не в пользу «просто зелено»

Здесь честный ответ сложнее, чем «не упало»:

до правки (13–19.08, 7 прогонов) после (20–27.08, 8 прогонов)
адресов проверено 1893 944
не разрешилось (skipped) 499 (26.4 %) 428 (45.3 %)
секунд на адрес 1.9 4.5

Доля неразрешённых выросла почти вдвое. Приписывать это ограничителю нельзя: ограничитель не может сделать адрес неразрешимым. Причина — состав очереди. Отбор берёт пары с geocode_tried_at IS NULL OR tried_at < 7 дней, то есть через неделю после выкатки в него вернулись ровно те, кто уже не разрешился. Замер очереди сейчас:

никогда не пробовали:      13
пробовали и не смогли:    368   ← 96.6 %
всего пар (address, city): 381

Отдельно проверил, что дело не в новом хосте после переезда: прямая проба Nominatim из tradein-scraper на Poincare — HTTP 200, координаты приходят. Худший прогон (5017, сегодня: checked 95, skipped 95, saved 0) — это батч целиком из повторных неудач, а не отказ площадки.

Рост секунд на адрес (1.9 → 4.5) — прямая и ожидаемая цена ограничителя. Сейчас она ничего не стоит: очередь (381 пара) меньше одного батча (200×2 итерации), прогоны заканчиваются дренажем, а не исчерпанием бюджета.

Отложенный пункт 2 не понадобился

В базовом замере было записано: «если 429 останутся — следующий шаг известен, повторы на 429 перезапускают всю веерную попытку». 429 не осталось, поэтому эту правку не делаю — она была условной.

Побочная находка, вынесена отдельно

Прогон 5017 шёл 3150 с при budget_sec=1800. Бюджет проверяется только МЕЖДУ батчами (tasks/geocode_missing.py:402), внутри батча из 200 адресов проверки нет. До ограничителя это не было заметно (1.9 с/адрес → батч ~6 мин), после — стало (4.5 с/адрес, на трудном хвосте до 33 с). Завёл отдельным issue.

Критерий приёмки выполнен: 429 — ноль. Закрываю.

## Проверка 27.08 — по критерию, записанному 20.08 до факта ### 1. Новых 429 нет ``` title count | first_seen | last_seen 429 Too many requests … nominatim … 194 | 18.08 03:24 | 19.08 21:22 UTC ``` Единственная строка issue, `count` не сдвинулся с базы (194), `last_seen` — **19.08 21:22 UTC**, то есть до мержа. Строк с `first_seen` после 20.08 — ноль. Других ошибок геокодера за период тоже нет (проверял по `title/culprit ILIKE '%nominatim%|%geocod%'`): единственная соседняя строка — Avito HTTP 403, последняя 10.08, к Nominatim отношения не имеет. ### 2. Бэкфилл ходил — 8 прогонов за неделю (порог был 5) `geocode_missing_listings`, 20.08–27.08: run_id 4450, 4476, 4573, 4687, 4794, 4898, 4994, 5017. Последний — сегодня 03:21 UTC. ### 3. Инструмент жив Trade-In шлёт события в GlitchTip каждый день, включая сегодня: 27.08 — 20 issue-строк / 146 событий, 26.08 — 25 / 300. Ноль по 429 не от того, что проект замолчал. ### 4. Пропускная способность — оговорка, и она не в пользу «просто зелено» Здесь честный ответ сложнее, чем «не упало»: | | до правки (13–19.08, 7 прогонов) | после (20–27.08, 8 прогонов) | |---|---|---| | адресов проверено | 1893 | 944 | | не разрешилось (`skipped`) | 499 (**26.4 %**) | 428 (**45.3 %**) | | секунд на адрес | 1.9 | **4.5** | Доля неразрешённых выросла почти вдвое. Приписывать это ограничителю нельзя: **ограничитель не может сделать адрес неразрешимым**. Причина — состав очереди. Отбор берёт пары с `geocode_tried_at IS NULL OR tried_at < 7 дней`, то есть через неделю после выкатки в него вернулись ровно те, кто уже не разрешился. Замер очереди сейчас: ``` никогда не пробовали: 13 пробовали и не смогли: 368 ← 96.6 % всего пар (address, city): 381 ``` Отдельно проверил, что дело не в новом хосте после переезда: прямая проба Nominatim из `tradein-scraper` на Poincare — `HTTP 200`, координаты приходят. Худший прогон (5017, сегодня: `checked 95, skipped 95, saved 0`) — это батч целиком из повторных неудач, а не отказ площадки. Рост секунд на адрес (1.9 → 4.5) — прямая и ожидаемая цена ограничителя. Сейчас она ничего не стоит: очередь (381 пара) меньше одного батча (200×2 итерации), прогоны заканчиваются дренажем, а не исчерпанием бюджета. ### Отложенный пункт 2 не понадобился В базовом замере было записано: «если 429 останутся — следующий шаг известен, повторы на 429 перезапускают всю веерную попытку». 429 не осталось, поэтому эту правку не делаю — она была условной. ### Побочная находка, вынесена отдельно Прогон 5017 шёл **3150 с при `budget_sec=1800`**. Бюджет проверяется только МЕЖДУ батчами (`tasks/geocode_missing.py:402`), внутри батча из 200 адресов проверки нет. До ограничителя это не было заметно (1.9 с/адрес → батч ~6 мин), после — стало (4.5 с/адрес, на трудном хвосте до 33 с). Завёл отдельным issue. **Критерий приёмки выполнен: 429 — ноль. Закрываю.**
Author
Collaborator

Побочная находка вынесена в #3151.

Побочная находка вынесена в #3151.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2953
No description provided.