diff --git a/apps/web/app/api/bounties/[id]/route.ts b/apps/web/app/api/bounties/[id]/route.ts index 96aee2f..77410fc 100644 --- a/apps/web/app/api/bounties/[id]/route.ts +++ b/apps/web/app/api/bounties/[id]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { getDb } from '@/lib/db'; +import { dbErrorResponse } from '@/lib/api-error'; export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; @@ -17,6 +18,6 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: return NextResponse.json(rows[0]); } catch (e) { console.error(e); - return NextResponse.json({ error: 'Failed to fetch bounty' }, { status: 500 }); + return dbErrorResponse(e, 'Failed to fetch bounty'); } } diff --git a/apps/web/app/api/bounties/route.ts b/apps/web/app/api/bounties/route.ts index 11a6d8f..dfa01f2 100644 --- a/apps/web/app/api/bounties/route.ts +++ b/apps/web/app/api/bounties/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { createCoinPayClient } from '@profullstack/stack/coinpay'; import { getDb } from '@/lib/db'; +import { dbErrorResponse } from '@/lib/api-error'; import { getSessionDid } from '@/lib/auth'; import { generatePublicId } from '@/lib/id'; @@ -23,7 +24,7 @@ export async function GET() { return NextResponse.json(bounties); } catch (e) { console.error(e); - return NextResponse.json({ error: 'Failed to fetch bounties' }, { status: 500 }); + return dbErrorResponse(e, 'Failed to fetch bounties'); } } @@ -91,6 +92,6 @@ export async function POST(req: NextRequest) { }, { status: 201 }); } catch (e) { console.error('Failed to create bounty:', e); - return NextResponse.json({ error: 'Failed to create bounty. Please try again.' }, { status: 500 }); + return dbErrorResponse(e, 'Failed to create bounty. Please try again.'); } } diff --git a/apps/web/app/api/coupons/[id]/route.ts b/apps/web/app/api/coupons/[id]/route.ts index d234db7..987d002 100644 --- a/apps/web/app/api/coupons/[id]/route.ts +++ b/apps/web/app/api/coupons/[id]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { getDb } from '@/lib/db'; +import { dbErrorResponse } from '@/lib/api-error'; export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; @@ -15,6 +16,6 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: return NextResponse.json(rows[0]); } catch (err) { console.error(err); - return NextResponse.json({ error: 'Failed to fetch coupon' }, { status: 500 }); + return dbErrorResponse(err, 'Failed to fetch coupon'); } } diff --git a/apps/web/app/api/coupons/route.ts b/apps/web/app/api/coupons/route.ts index 814bdf3..458193e 100644 --- a/apps/web/app/api/coupons/route.ts +++ b/apps/web/app/api/coupons/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { getDb } from '@/lib/db'; +import { dbErrorResponse } from '@/lib/api-error'; import { getSessionDid } from '@/lib/auth'; import type { DiscountType } from '@/lib/types'; @@ -20,7 +21,7 @@ export async function GET(req: NextRequest) { return NextResponse.json(coupons); } catch (err) { console.error(err); - return NextResponse.json({ error: 'Failed to fetch coupons' }, { status: 500 }); + return dbErrorResponse(err, 'Failed to fetch coupons'); } } @@ -128,6 +129,6 @@ export async function POST(req: NextRequest) { return NextResponse.json({ success: true }, { status: 201 }); } catch (err) { console.error(err); - return NextResponse.json({ error: 'Failed to create coupon' }, { status: 500 }); + return dbErrorResponse(err, 'Failed to create coupon'); } } diff --git a/apps/web/app/api/coupons/vote/route.ts b/apps/web/app/api/coupons/vote/route.ts index e0da6f4..cb3b8d0 100644 --- a/apps/web/app/api/coupons/vote/route.ts +++ b/apps/web/app/api/coupons/vote/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getSessionDid } from '@/lib/auth'; import { getDb } from '@/lib/db'; +import { dbErrorResponse } from '@/lib/api-error'; export async function POST(req: NextRequest) { const did = await getSessionDid(); @@ -33,6 +34,6 @@ export async function POST(req: NextRequest) { return NextResponse.json({ success: true, votes: rows[0]?.votes ?? 0 }); } catch (err) { console.error(err); - return NextResponse.json({ error: 'Failed to vote' }, { status: 500 }); + return dbErrorResponse(err, 'Failed to vote'); } } diff --git a/apps/web/app/api/search/route.ts b/apps/web/app/api/search/route.ts index 5e1aa01..4337763 100644 --- a/apps/web/app/api/search/route.ts +++ b/apps/web/app/api/search/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { getDb } from '@/lib/db'; +import { dbErrorResponse } from '@/lib/api-error'; export async function GET(req: NextRequest) { const { searchParams } = new URL(req.url); @@ -24,6 +25,6 @@ export async function GET(req: NextRequest) { return NextResponse.json(results); } catch (err) { console.error(err); - return NextResponse.json({ error: 'Search failed' }, { status: 500 }); + return dbErrorResponse(err, 'Search failed'); } } diff --git a/apps/web/app/api/stores/[slug]/route.ts b/apps/web/app/api/stores/[slug]/route.ts index a24e0db..94b1f3b 100644 --- a/apps/web/app/api/stores/[slug]/route.ts +++ b/apps/web/app/api/stores/[slug]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { getDb } from '@/lib/db'; +import { dbErrorResponse } from '@/lib/api-error'; export async function GET(_req: NextRequest, { params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; @@ -16,6 +17,6 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ slu return NextResponse.json({ store, coupons }); } catch (err) { console.error(err); - return NextResponse.json({ error: 'Failed to fetch store' }, { status: 500 }); + return dbErrorResponse(err, 'Failed to fetch store'); } } diff --git a/apps/web/app/api/stores/route.ts b/apps/web/app/api/stores/route.ts index 63fa1a3..3a1e838 100644 --- a/apps/web/app/api/stores/route.ts +++ b/apps/web/app/api/stores/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { getDb } from '@/lib/db'; +import { dbErrorResponse } from '@/lib/api-error'; import { getSessionDid } from '@/lib/auth'; export async function GET() { @@ -15,7 +16,7 @@ export async function GET() { return NextResponse.json(stores); } catch (err) { console.error(err); - return NextResponse.json({ error: 'Failed to fetch stores' }, { status: 500 }); + return dbErrorResponse(err, 'Failed to fetch stores'); } } @@ -37,6 +38,6 @@ export async function POST(req: NextRequest) { return NextResponse.json({ success: true }, { status: 201 }); } catch (err) { console.error(err); - return NextResponse.json({ error: 'Failed to create store' }, { status: 500 }); + return dbErrorResponse(err, 'Failed to create store'); } } diff --git a/apps/web/lib/api-error.ts b/apps/web/lib/api-error.ts new file mode 100644 index 0000000..2800e0b --- /dev/null +++ b/apps/web/lib/api-error.ts @@ -0,0 +1,30 @@ +import 'server-only'; +import { NextResponse } from 'next/server'; +import { isDbPaused } from './db'; + +/** + * What a caller sees when the database node is parked. It names the cause + * rather than the fix: the dashboard restart is an operator action, and the + * person hitting the API can only wait. + */ +export const DB_PAUSED_MESSAGE = + 'The coupon database is temporarily unavailable and should be back shortly.'; + +/** + * Map an error caught in a route handler onto a response. + * + * A paused database is a transient infrastructure state, not a bad request and + * not a bug, so it answers 503 + Retry-After instead of a blanket 500. Anything + * else keeps the route's own 500 and message, so a genuine defect still reads + * as a defect. `code` is stable for clients to branch on — the CLI prints its + * own wording for `database_paused` rather than echoing a raw HTTP status. + */ +export function dbErrorResponse(err: unknown, fallback: string): NextResponse { + if (isDbPaused(err)) { + return NextResponse.json( + { error: DB_PAUSED_MESSAGE, code: 'database_paused' }, + { status: 503, headers: { 'Retry-After': '60' } } + ); + } + return NextResponse.json({ error: fallback }, { status: 500 }); +} diff --git a/apps/web/lib/db.ts b/apps/web/lib/db.ts index 14b301e..fa89cec 100644 --- a/apps/web/lib/db.ts +++ b/apps/web/lib/db.ts @@ -24,6 +24,20 @@ function isDisconnect(err: unknown): boolean { ); } +// SQLite Cloud parks a free-tier node after a stretch of inactivity. Every +// query then fails with error 10010 until someone restarts it from the +// dashboard, and the node refuses new sockets too — so reconnecting cannot fix +// it. That is why this is deliberately NOT folded into isDisconnect() above: +// retrying a paused node just pays the connection cost twice before failing +// identically. Callers use it to tell "c0upons is down" (transient, 503) apart +// from "c0upons is broken" (a real 500). +export function isDbPaused(err: unknown): boolean { + const code = (err as { errorCode?: string | number } | null)?.errorCode; + if (code != null && String(code) === '10010') return true; + const msg = err instanceof Error ? err.message : String(err); + return /node has been paused|paused due to inactivity/i.test(msg); +} + async function runSql(args: unknown[]): Promise { if (!client) client = createClient(); try { diff --git a/apps/web/public/cli/c0upons b/apps/web/public/cli/c0upons index 896cb25..d180170 100644 --- a/apps/web/public/cli/c0upons +++ b/apps/web/public/cli/c0upons @@ -6,7 +6,7 @@ set -euo pipefail -VERSION="1.1.0" +VERSION="1.2.0" BASE_URL="${C0UPONS_API:-https://c0upons.com/api}" # Website root (for login + share links). Defaults to BASE_URL without /api. WEB_URL="${C0UPONS_WEB:-${BASE_URL%/api}}" @@ -90,6 +90,35 @@ api_post() { exit 1 } +# api_get — fetches a public endpoint. +# On success, leaves the response body in $API_BODY. On failure, prints the +# server's own message and exits. The read commands used to pipe `curl -fsSL` +# straight into jq, so an outage surfaced as `curl: (22) The requested URL +# returned error: 500` — which says nothing about whose fault it is or whether +# retrying helps. The API distinguishes those cases now; this reads them out. +api_get() { + local path="$1" resp code + # -L keeps the redirect-following the previous `curl -fsSL` had, so a + # canonical-host redirect still resolves instead of surfacing as a bare 301. + resp=$(curl -sSL -w $'\n%{http_code}' "${BASE_URL}${path}") \ + || { echo -e "${RED}Network error.${RESET} Could not reach ${BASE_URL}."; exit 1; } + code=$(printf '%s' "$resp" | tail -n1) + API_BODY=$(printf '%s' "$resp" | sed '$d') + if [ "$code" -ge 200 ] && [ "$code" -lt 300 ]; then + return 0 + fi + local msg + msg=$(printf '%s' "$API_BODY" | jq -r '.error // empty' 2>/dev/null || true) + # 503 is the API saying "down, not broken" — worth its own wording so people + # retry rather than reinstall the CLI or file a bug. + if [ "$code" = "503" ]; then + echo -e "${ORANGE}c0upons is temporarily unavailable.${RESET} ${msg:-Please try again shortly.}" + else + echo -e "${RED}Error (${code}):${RESET} ${msg:-request failed}" + fi + exit 1 +} + urlencode() { local raw="$1" if command -v python3 &>/dev/null; then @@ -130,7 +159,8 @@ cmd_search() { local encoded encoded=$(urlencode "$q") local results - results=$(curl -fsSL "${BASE_URL}/search?q=${encoded}") + api_get "/search?q=${encoded}" + results="$API_BODY" local count count=$(echo "$results" | jq 'length') if [ "$count" -eq 0 ]; then @@ -142,14 +172,18 @@ cmd_search() { while IFS= read -r row; do print_coupon "$row" i=$((i + 1)) - [ "$i" -ge 20 ] && break + # `[ … ] && break` would leave its own status 1 behind on the last pass + # through the loop, and that becomes the script's exit code — so a search + # that printed results perfectly still reported failure to `&&`/CI. + if [ "$i" -ge 20 ]; then break; fi done < <(echo "$results" | jq -c '.[]') } cmd_latest() { require_jq local results - results=$(curl -fsSL "${BASE_URL}/coupons") + api_get "/coupons" + results="$API_BODY" local count count=$(echo "$results" | jq 'length') echo -e "${BOLD}Latest ${count} coupon(s)${RESET}\n" @@ -157,14 +191,16 @@ cmd_latest() { while IFS= read -r row; do print_coupon "$row" i=$((i + 1)) - [ "$i" -ge 10 ] && break + # Same trailing-status trap as cmd_search above. + if [ "$i" -ge 10 ]; then break; fi done < <(echo "$results" | jq -c '.[]') } cmd_stores() { require_jq local results - results=$(curl -fsSL "${BASE_URL}/stores") + api_get "/stores" + results="$API_BODY" local count count=$(echo "$results" | jq 'length') echo -e "${BOLD}${count} stores${RESET}\n" @@ -179,7 +215,8 @@ cmd_store() { exit 1 fi local result - result=$(curl -fsSL "${BASE_URL}/stores/${slug}") + api_get "/stores/${slug}" + result="$API_BODY" local name name=$(echo "$result" | jq -r '.name // "Unknown"') local coupons