diff --git a/packages/core/src/__tests__/connections-timeout.test.ts b/packages/core/src/__tests__/connections-timeout.test.ts new file mode 100644 index 0000000..fe769fe --- /dev/null +++ b/packages/core/src/__tests__/connections-timeout.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { CodeSpar } from "../index.js"; + +const realFetch = globalThis.fetch; +afterEach(() => { globalThis.fetch = realFetch; }); + +function sessionCreate(): Response { + return { ok: true, status: 201, text: async () => "", json: async () => ({ + id: "ses_c", org_id: "o", user_id: "u", servers: [], + status: "active", created_at: new Date().toISOString(), closed_at: null, + }) } as unknown as Response; +} + +describe("session.connections timeout", () => { + it("connections() validates the timeout (invalid value is NOT swallowed into the cache fallback)", async () => { + let connectionsFetches = 0; + globalThis.fetch = ((url: string) => { + if (String(url).endsWith("/v1/sessions")) return Promise.resolve(sessionCreate()); + connectionsFetches++; + return Promise.resolve({ ok: true, status: 200, text: async () => "", json: async () => ({ servers: [], tools: [] }) } as unknown as Response); + }) as unknown as typeof fetch; + + const cs = new CodeSpar({ apiKey: "csk_live_t", baseUrl: "https://x" }); + const session = await cs.create("u"); + await expect(session.connections({ timeout: 0 })).rejects.toThrow(/timeout/i); + await expect(session.connections({ timeout: Number.NaN })).rejects.toThrow(/timeout/i); + // Fail-fast: the request never left the client. + expect(connectionsFetches).toBe(0); + }, 5000); + + it("connections() stays best-effort: a transport failure resolves to the cached list", async () => { + let fail = false; + globalThis.fetch = ((url: string) => { + if (String(url).endsWith("/v1/sessions")) return Promise.resolve(sessionCreate()); + if (fail) return Promise.reject(new TypeError("network down")); + return Promise.resolve({ ok: true, status: 200, text: async () => "", json: async () => ({ + servers: [{ id: "srv_1", name: "zoop", category: "payments", country: "BR", auth_type: "none", connected: true }], + tools: [], + }) } as unknown as Response); + }) as unknown as typeof fetch; + + const cs = new CodeSpar({ apiKey: "csk_live_t", baseUrl: "https://x" }); + const session = await cs.create("u"); + + // Warm the cache, then fail the transport — the cached list comes back. + const first = await session.connections(); + expect(first).toHaveLength(1); + fail = true; + await expect(session.connections()).resolves.toEqual(first); + }, 5000); + + it("connections() with an empty cache resolves to [] on transport failure", async () => { + globalThis.fetch = ((url: string) => { + if (String(url).endsWith("/v1/sessions")) return Promise.resolve(sessionCreate()); + return Promise.reject(new TypeError("network down")); + }) as unknown as typeof fetch; + + const cs = new CodeSpar({ apiKey: "csk_live_t", baseUrl: "https://x" }); + const session = await cs.create("u"); + await expect(session.connections()).resolves.toEqual([]); + }, 5000); +}); diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index fc7b266..71b5bf4 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -580,6 +580,12 @@ export async function createSession( }, async connections(opts?: CallOptions): Promise { + // Caller misconfiguration is NOT best-effort — validate the + // effective timeout before the swallow (parity with close()), so + // an invalid value surfaces instead of silently returning the + // cached list. + const resolved = callOpts(opts); + validateTimeout(resolved.timeout); // Best-effort — both transport failures (CodesparApiError from // safeFetch) and non-2xx responses fall back to the cached // payload so a transient blip doesn't crater the session. @@ -588,7 +594,7 @@ export async function createSession( `${baseUrl}/v1/sessions/${data.id}/connections`, { headers }, "connections", - callOpts(opts), + resolved, async (r) => { if (!r.ok) return cachedConnections ?? []; const payload = (await r.json()) as BackendConnectionsResponse; diff --git a/packages/python/src/codespar/_async_session.py b/packages/python/src/codespar/_async_session.py index c5d7b1c..968fbc2 100644 --- a/packages/python/src/codespar/_async_session.py +++ b/packages/python/src/codespar/_async_session.py @@ -1182,7 +1182,7 @@ async def connections( project_id=self._project_id, timeout=timeout, ) - except ApiError: + except (ApiError, TimeoutError): return list(self._cached_connections or []) if not isinstance(data, dict): return list(self._cached_connections or []) diff --git a/packages/python/tests/test_session.py b/packages/python/tests/test_session.py index b44983c..d26188f 100644 --- a/packages/python/tests/test_session.py +++ b/packages/python/tests/test_session.py @@ -11,6 +11,7 @@ import json from typing import Any +import httpx import pytest from pytest_httpx import HTTPXMock @@ -520,3 +521,86 @@ async def test_session_discover_raises_on_error_envelope(httpx_mock: HTTPXMock) with pytest.raises(ApiError, match="no_eligible_providers"): await session.discover("anything") await session.close() + + +# ── connections: best-effort cache fallback ─────────────────────── + + +def _connections_json() -> dict[str, Any]: + return { + "servers": [ + { + "id": "srv_1", + "name": "zoop", + "category": "payments", + "country": "BR", + "auth_type": "none", + "connected": True, + } + ], + "tools": [], + } + + +async def test_connections_timeout_returns_cached_list(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + url="https://api.codespar.dev/v1/sessions", + method="POST", + json=_session_json(), + ) + httpx_mock.add_response( + url="https://api.codespar.dev/v1/sessions/ses_abc123/connections", + method="GET", + json=_connections_json(), + ) + httpx_mock.add_exception( + httpx.ReadTimeout("read timed out"), + url="https://api.codespar.dev/v1/sessions/ses_abc123/connections", + method="GET", + ) + + async with AsyncCodeSpar(api_key="csk_test_x") as cs: + session = await cs.create("user_123", preset="brazilian") + warm = await session.connections() + assert [s.name for s in warm] == ["zoop"] + # Timeout on the refresh — best-effort, the cached list comes back. + assert await session.connections() == warm + + +async def test_connections_timeout_with_empty_cache_returns_empty( + httpx_mock: HTTPXMock, +) -> None: + httpx_mock.add_response( + url="https://api.codespar.dev/v1/sessions", + method="POST", + json=_session_json(), + ) + httpx_mock.add_exception( + httpx.ReadTimeout("read timed out"), + url="https://api.codespar.dev/v1/sessions/ses_abc123/connections", + method="GET", + ) + + async with AsyncCodeSpar(api_key="csk_test_x") as cs: + session = await cs.create("user_123", preset="brazilian") + assert await session.connections() == [] + + +async def test_connections_unrelated_error_still_propagates( + httpx_mock: HTTPXMock, +) -> None: + httpx_mock.add_response( + url="https://api.codespar.dev/v1/sessions", + method="POST", + json=_session_json(), + ) + httpx_mock.add_exception( + ValueError("boom"), + url="https://api.codespar.dev/v1/sessions/ses_abc123/connections", + method="GET", + ) + + async with AsyncCodeSpar(api_key="csk_test_x") as cs: + session = await cs.create("user_123", preset="brazilian") + with pytest.raises(ValueError, match="boom"): + await session.connections()