From eeb639061dde3bf52dbb09e2d78393683e13be8c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 19:38:40 +1100 Subject: [PATCH] fix(dev): clear "can't reach the API" message; start-api refuses a busy port (#118) - callApi() in session.ts: a network failure becomes "Can't reach the API at . Is it running?"; aborts pass through. Used by sign-in, sign-out, change password and every apiFetch call. - start-api.ps1: if port 8000 is already listening, print the PID, its start time and the taskkill line, and exit 1 instead of starting a second API that never serves requests. - Both start scripts title their windows (API / Frontend). - QUICKSTART troubleshooting: two new rows; drop the stale "no Alembic in this repo" row (alembic.ini exists since #84). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019YPoXzBSj89ZHgw7nfTmbB --- .../src/__tests__/session.test.ts | 23 +++++++++++++++++++ csv-table-hub-main/src/lib/csv.functions.ts | 4 ++-- csv-table-hub-main/src/lib/session.ts | 19 ++++++++++++--- docs/QUICKSTART.md | 3 ++- scripts/start-api.ps1 | 14 +++++++++++ scripts/start-frontend.ps1 | 1 + 6 files changed, 58 insertions(+), 6 deletions(-) diff --git a/csv-table-hub-main/src/__tests__/session.test.ts b/csv-table-hub-main/src/__tests__/session.test.ts index 20bc5f5..d5fbecd 100644 --- a/csv-table-hub-main/src/__tests__/session.test.ts +++ b/csv-table-hub-main/src/__tests__/session.test.ts @@ -138,3 +138,26 @@ describe("requireSession (route guard)", () => { expect(() => requireSession()).not.toThrow(); }); }); + +describe("API unreachable (#118)", () => { + const down = () => vi.fn(async () => { throw new TypeError("Failed to fetch"); }); + + it("sign-in says the API can't be reached instead of 'Failed to fetch'", async () => { + vi.stubGlobal("fetch", down()); + await expect(signIn("acme", "alice", "pw")).rejects.toThrow(/Can't reach the API at .*Is it running\?/); + expect(getSession()).toBeNull(); + }); + + it("other API calls say the same and keep the session", async () => { + store(session()); + vi.stubGlobal("fetch", down()); + await expect(listCsvFiles()).rejects.toThrow(/Can't reach the API/); + expect(getSession()?.token).toBe("tok-123"); + }); + + it("a cancelled request is not reported as the API being down", async () => { + const abort = new DOMException("The operation was aborted.", "AbortError"); + vi.stubGlobal("fetch", vi.fn(async () => { throw abort; })); + await expect(signIn("acme", "alice", "pw")).rejects.toBe(abort); + }); +}); diff --git a/csv-table-hub-main/src/lib/csv.functions.ts b/csv-table-hub-main/src/lib/csv.functions.ts index 1287ae5..8c6d0b2 100644 --- a/csv-table-hub-main/src/lib/csv.functions.ts +++ b/csv-table-hub-main/src/lib/csv.functions.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import type { QueryClient } from "@tanstack/react-query"; -import { apiBase, authHeaders, clearSession } from "@/lib/session"; +import { apiBase, authHeaders, callApi, clearSession } from "@/lib/session"; const columnTypeSchema = z.enum(["int8", "numeric", "date", "timestamptz", "boolean", "text"]); export type ColumnType = z.infer; @@ -102,7 +102,7 @@ const previewRowsSchema = z.object({ }); async function apiFetch(path: string, schema: z.ZodType, options?: RequestInit): Promise { - const response = await fetch(`${apiBase}${path}`, { + const response = await callApi(`${apiBase}${path}`, { ...options, headers: { "content-type": "application/json", diff --git a/csv-table-hub-main/src/lib/session.ts b/csv-table-hub-main/src/lib/session.ts index 0dfa0e2..3fd75a9 100644 --- a/csv-table-hub-main/src/lib/session.ts +++ b/csv-table-hub-main/src/lib/session.ts @@ -18,6 +18,19 @@ const KEY = "te.session"; export const apiBase = (import.meta.env.VITE_API_BASE ?? "").replace(/\/$/, ""); +/** fetch() that says when the API can't be reached (#118), instead of the + * browser's bare "Failed to fetch". Aborts (cancelled uploads) pass through. */ +export async function callApi(url: string, init?: RequestInit): Promise { + try { + return await fetch(url, init); + } catch (err) { + if (err instanceof TypeError) { + throw new Error(`Can't reach the API at ${apiBase || "this site"}. Is it running?`); + } + throw err; + } +} + export function getSession(now: Date = new Date()): Session | null { try { const raw = sessionStorage.getItem(KEY); @@ -43,7 +56,7 @@ export function clearSession(): void { /** Sign in with a local account. Throws with the server's message on failure. */ export async function signIn(tenant: string, username: string, password: string): Promise { - const res = await fetch(`${apiBase}/api/auth/login`, { + const res = await callApi(`${apiBase}/api/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ tenant, username, password }), @@ -72,7 +85,7 @@ export async function signOut(): Promise { const s = getSession(); clearSession(); if (s) { - await fetch(`${apiBase}/api/auth/logout`, { + await callApi(`${apiBase}/api/auth/logout`, { method: "POST", headers: { Authorization: `Bearer ${s.token}` }, }).catch(() => undefined); @@ -92,7 +105,7 @@ export function requireSession(): void { /** Change your own password (#117). This session stays; the server ends all others. */ export async function changePassword(currentPassword: string, newPassword: string): Promise { - const res = await fetch(`${apiBase}/api/auth/password`, { + const res = await callApi(`${apiBase}/api/auth/password`, { method: "POST", headers: { "content-type": "application/json", ...authHeaders() }, body: JSON.stringify({ currentPassword, newPassword }), diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 72e7ff4..1a67c41 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -230,9 +230,10 @@ Restart-Service postgresql-x64-18 |---|---|---| | `password authentication failed` | Wrong `PGPASSWORD` | Reset password using admin steps above | | `Connection refused` on port 5433 | PG 18 not running | Open Services → start `postgresql-x64-18` | -| `alembic.ini not found` on API start | Expected — no Alembic in this repo | Safe to ignore | | Frontend shows "This page didn't load" | API not running | Start the API first (Terminal 1) | | `ERR_CONNECTION_REFUSED` in browser console | API stopped | Restart `uvicorn` | +| "Can't reach the API at http://localhost:8000. Is it running?" | API not running | Start it with `.\scripts\start-api.ps1` | +| `start-api.ps1` says "Port 8000 is already in use by PID …" | An older API is still running, maybe in a window you've forgotten (#118) | Close that window, or run the `taskkill` line it prints, then start again | | `npm run dev` fails with "Missing script" | Wrong directory | Must run from `csv-table-hub-main\`, not repo root | | Deployment shows `[✗] failed for dev` | DB already exists | False positive — run `bash build/deploy_all.sh dev` to confirm | diff --git a/scripts/start-api.ps1 b/scripts/start-api.ps1 index 8af0bd4..c281502 100644 --- a/scripts/start-api.ps1 +++ b/scripts/start-api.ps1 @@ -24,6 +24,20 @@ if (-not $env:API_KEY) { Write-Host "API_KEY not set - every endpoint is unauthenticated (fine for local dev)." -ForegroundColor Yellow } +# #118: refuse to start a second API. Uvicorn can print "running" even when an +# older API already holds the port, and then every request goes to the old one. +$busy = Get-NetTCPConnection -LocalPort 8000 -State Listen -ErrorAction SilentlyContinue +if ($busy) { + foreach ($b in $busy) { + $p = Get-Process -Id $b.OwningProcess -ErrorAction SilentlyContinue + Write-Host ("Port 8000 is already in use by PID {0} ({1}, started {2})." -f $b.OwningProcess, $p.ProcessName, $p.StartTime) -ForegroundColor Red + } + Write-Host "Another API is probably still running. Close its window, or stop it with:" -ForegroundColor Red + Write-Host (" taskkill /PID {0} /T /F" -f $busy[0].OwningProcess) -ForegroundColor Red + exit 1 +} + Set-Location (Join-Path $PSScriptRoot "..") +$host.UI.RawUI.WindowTitle = "API (port 8000)" Write-Host "API starting on http://localhost:8000 (docs: /docs)" -ForegroundColor Green python -m uvicorn api.main:app --reload --port 8000 diff --git a/scripts/start-frontend.ps1 b/scripts/start-frontend.ps1 index c89e6ce..8b4213f 100644 --- a/scripts/start-frontend.ps1 +++ b/scripts/start-frontend.ps1 @@ -10,5 +10,6 @@ if (-not (Test-Path "node_modules")) { npm install } +$host.UI.RawUI.WindowTitle = "Frontend (port 8080)" Write-Host "Frontend starting on http://localhost:8080" -ForegroundColor Green npm run dev