diff --git a/backend/app/services/scrapers/nspd_client.py b/backend/app/services/scrapers/nspd_client.py index 40d1cbf8..670b66e2 100644 --- a/backend/app/services/scrapers/nspd_client.py +++ b/backend/app/services/scrapers/nspd_client.py @@ -480,7 +480,7 @@ class NSPDClient: asyncio.run(). Предназначен для вызова из синхронного кода (Celery task, FastAPI sync handler). - Concurrency: NSPDBulkClient._SEMAPHORE(3) ограничивает параллельные + Concurrency: NSPDBulkClient._sem (per-instance, capacity=3) ограничивает параллельные запросы. При grid_n=7 (49 ячеек) — все 49 ячеек запускаются одним gather; семафор пропускает не более 3 одновременно. Thread-safety: каждый вызов get_features_in_bbox_grid создаёт новый event loop @@ -503,7 +503,7 @@ class NSPDClient: Note: Не делает live HTTP вызовы если вызван с mock NSPDBulkClient. - Rate-limit управляется семафором NSPDBulkClient._SEMAPHORE(3) + + Rate-limit управляется семафором NSPDBulkClient._sem (per-instance, capacity=3) + asyncio.sleep(0.05) jitter — не через self.rate_ms. """ # Импортируем здесь чтобы избежать circular import: diff --git a/backend/tests/scrapers/test_nspd_bulk_client.py b/backend/tests/scrapers/test_nspd_bulk_client.py index 48e482fb..7add40f5 100644 --- a/backend/tests/scrapers/test_nspd_bulk_client.py +++ b/backend/tests/scrapers/test_nspd_bulk_client.py @@ -316,7 +316,7 @@ async def test_list_objects_in_building_parses( @pytest.mark.asyncio async def test_rate_limit_semaphore_max_3_concurrent() -> None: - """Не более 3 одновременных запросов через _SEMAPHORE. + """Не более 3 одновременных запросов через per-instance self._sem (PR #260). Мокируем httpx.AsyncClient.get (нижний уровень) с задержкой, чтобы реальный семафор работал. Считаем max in-flight внутри семафора. @@ -332,7 +332,7 @@ async def test_rate_limit_semaphore_max_3_concurrent() -> None: async def slow_get(*args: Any, **kwargs: Any) -> httpx.Response: nonlocal max_concurrent, current, call_count - # Фиксируем вход — уже внутри семафора (httpx.get вызывается после async with _SEMAPHORE) + # Фиксируем вход — уже внутри семафора (httpx.get вызывается после async with self._sem) async with lock: current += 1 call_count += 1 @@ -353,11 +353,12 @@ async def test_rate_limit_semaphore_max_3_concurrent() -> None: tasks = [client.search_by_quarter(f"66:41:{i:07d}") for i in range(6)] await asyncio.gather(*tasks) - # _SEMAPHORE(3) → не более 3 одновременно внутри slow_get + # self._sem(3) → не более 3 одновременно внутри slow_get assert max_concurrent <= 3, f"Expected ≤3 concurrent, got {max_concurrent}" assert call_count == 6 # все 6 вызовов прошли - # Убедимся что _SEMAPHORE в модуле имеет нужное значение capacity - assert bulk_mod._SEMAPHORE._value >= 0 # семафор сброшен после всех задач + # NB: per-instance self._sem cleanup проверяется через max_concurrent <= 3 выше. + # Module-level _SEMAPHORE удалён в PR #260 (cross-loop binding fix); smoke на capacity: + assert bulk_mod._SEMAPHORE_LIMIT == 3 @pytest.mark.asyncio