From 6f676309635509adbe6b25c14405879fc292a544 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:03:42 +0000 Subject: [PATCH] perf: Use asyncio.gather for captive portal requests Replaced sequential HTTP requests inside the captive portal probe with concurrent execution using asyncio.gather. This addresses an N+1 query pattern where the time taken was proportional to the number of URLs checked. Measurements indicate that execution time now scales with the slowest single request, drastically improving performance when multiple portal check URLs are configured. Co-authored-by: Horizontal42 <75383519+Horizontal42@users.noreply.github.com> --- src/netsleuth/probes/captive_portal.py | 35 +++++++++++++++++--------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/netsleuth/probes/captive_portal.py b/src/netsleuth/probes/captive_portal.py index ce51948..34af087 100644 --- a/src/netsleuth/probes/captive_portal.py +++ b/src/netsleuth/probes/captive_portal.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from urllib.parse import urlsplit import httpx @@ -32,6 +33,23 @@ def classify_portal_response( return "suspect", f"unexpected status {status}" +async def _fetch_and_classify( + client: httpx.AsyncClient, + url: str, + expected_status: int, + timeout: float, +) -> PortalCheck: + try: + response = await client.get(url, follow_redirects=False, timeout=timeout) + verdict, evidence = classify_portal_response( + url, response.status_code, response.text, response.headers.get("location"), expected_status + ) + return PortalCheck(url=url, status=response.status_code, verdict=verdict, evidence=evidence) + except httpx.HTTPError as exc: + verdict, evidence = "error", str(exc) + return PortalCheck(url=url, status=None, verdict=verdict, evidence=evidence) + + async def check_captive_portal( client: httpx.AsyncClient, urls: list[str], @@ -39,18 +57,11 @@ async def check_captive_portal( expected_status: int = 204, timeout: float, ) -> CaptivePortal: - checks: list[PortalCheck] = [] - for url in urls: - try: - response = await client.get(url, follow_redirects=False, timeout=timeout) - verdict, evidence = classify_portal_response( - url, response.status_code, response.text, response.headers.get("location"), expected_status - ) - except httpx.HTTPError as exc: - verdict, evidence = "error", str(exc) - checks.append(PortalCheck(url=url, status=None, verdict=verdict, evidence=evidence)) - continue - checks.append(PortalCheck(url=url, status=response.status_code, verdict=verdict, evidence=evidence)) + coros = [ + _fetch_and_classify(client, url, expected_status, timeout) + for url in urls + ] + checks = list(await asyncio.gather(*coros)) portal_checks = [c for c in checks if c.verdict == "portal"] if portal_checks: