From 7e32281fbb8c62909a4731bba4a7475d295602f1 Mon Sep 17 00:00:00 2001 From: Mayur Das Date: Sat, 17 Oct 2026 11:07:44 +0530 Subject: [PATCH] fix(preview): count the app's own 403/404 as routed get_url/get_port_url treated every 403 or 404 as "not routed yet", so an app that answers 404 or 403 at its root (an API-only server, a sign-in page) made the wait run out its whole timeout and raise, though the URL was live. The edge marks every response the app produced with an upstream header and never marks its own refusals, so a marked 403/404 now counts as routed. The async client matches. --- .changeset/preview-wait-app-404.md | 5 +++++ src/neevai/resources/sandboxes.py | 16 +++++++++++++--- tests/test_ports.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 .changeset/preview-wait-app-404.md diff --git a/.changeset/preview-wait-app-404.md b/.changeset/preview-wait-app-404.md new file mode 100644 index 0000000..e3061d0 --- /dev/null +++ b/.changeset/preview-wait-app-404.md @@ -0,0 +1,5 @@ +--- +"neevai": patch +--- + +`get_url` / `get_port_url` no longer wait out the whole timeout when the app itself answers 403 or 404 at its root, as an API-only or sign-in-gated server does. Those answers now count as routed; only a 403/404 from before the URL is routed keeps the wait going. diff --git a/src/neevai/resources/sandboxes.py b/src/neevai/resources/sandboxes.py index dc31afd..8f7d4b0 100644 --- a/src/neevai/resources/sandboxes.py +++ b/src/neevai/resources/sandboxes.py @@ -34,15 +34,25 @@ DEFAULT_PORT_WAIT_TIMEOUT_MS = 60_000 DEFAULT_PORT_POLL_INTERVAL_MS = 2_000 +# Set by the edge proxy on a response that came from the sandbox, never on one it answered itself. +_UPSTREAM_HEADER = "x-envoy-upstream-service-time" + + +def _routed(resp: httpx.Response) -> bool: + """True for any status but the edge's own 403/404; an app's own 403/404 carries the mark.""" + if _UPSTREAM_HEADER in resp.headers: + return True + return resp.status_code not in (403, 404) + def _preview_url_reachable(url: str, timeout_ms: float) -> bool: - """Probes a preview URL; True once it is reachable (stops returning a 403/404).""" + """Probes a preview URL; True once it is reachable (see :func:`_routed`).""" try: resp = httpx.get(url, follow_redirects=False, timeout=timeout_ms / 1000.0) except (httpx.HTTPError, httpx.InvalidURL): # Connection error, DNS failure, timeout, or a malformed URL — not routable yet. return False - return resp.status_code not in (403, 404) + return _routed(resp) async def _apreview_url_reachable(url: str, timeout_ms: float) -> bool: @@ -52,7 +62,7 @@ async def _apreview_url_reachable(url: str, timeout_ms: float) -> bool: resp = await client.get(url, follow_redirects=False, timeout=timeout_ms / 1000.0) except (httpx.HTTPError, httpx.InvalidURL): return False - return resp.status_code not in (403, 404) + return _routed(resp) def _validate_wait_args(timeout_ms: int, poll_interval_ms: int) -> None: diff --git a/tests/test_ports.py b/tests/test_ports.py index 2354add..2de0952 100644 --- a/tests/test_ports.py +++ b/tests/test_ports.py @@ -1,9 +1,13 @@ """Tests for sandbox preview ports: expose / list / revoke / get_url.""" +import httpx import pytest from neevai.client import AsyncNeevAI, NeevAI from neevai.errors import NeevAIError +from neevai.resources.sandboxes import _apreview_url_reachable, _preview_url_reachable + +APP = {"x-envoy-upstream-service-time": "3"} # the edge's mark on a response the app produced def _client(mock_transport) -> NeevAI: @@ -87,6 +91,31 @@ def test_get_url_raises_when_not_routable_before_timeout(mock_transport, monkeyp client.close() +@pytest.mark.parametrize( + ("status", "headers", "routed"), + [ + (404, {}, False), # the edge, before the URL is routed + (403, {}, False), + (404, APP, True), # an API-only app with nothing at its root + (403, APP, True), # a sign-in-gated root + (200, {}, True), + (503, {}, True), # the edge, routed but nothing listening yet + ], +) +def test_preview_url_reachable_tells_the_app_from_the_edge(monkeypatch, status, headers, routed): + monkeypatch.setattr(httpx, "get", lambda *a, **k: httpx.Response(status, headers=headers)) + assert _preview_url_reachable("https://p.example", 1000) is routed + + +@pytest.mark.asyncio +async def test_async_preview_url_reachable_counts_the_apps_own_404(monkeypatch): + async def get(self, *a, **k): + return httpx.Response(404, headers=APP) + + monkeypatch.setattr(httpx.AsyncClient, "get", get) + assert await _apreview_url_reachable("https://p.example", 1000) is True + + def test_get_url_rejects_non_positive_timeout(mock_transport): client = _client(mock_transport) sb = _new_sandbox(client)