fix: handle jersey checkout network errors gracefully on Safari/iOS (#220) #221

Merged
forgejo_admin merged 1 commit from 220-fix-handle-jersey-checkout-network-error into main 2026-04-05 20:07:34 +00:00

Summary

A parent (Daniel Niyitanga) clicked "Order" on the jersey page from Safari/iOS and saw a raw "load failed" error. Investigation confirmed the basketball-api backend is fully functional -- CORS preflight returns 200 with correct headers, POST /jersey/checkout works end-to-end returning a Stripe checkout URL, Stripe API key is present, and Tailscale Funnel is configured correctly. The root cause is Safari throwing TypeError("Load failed") on transient network failures during fetch(), which the frontend displayed verbatim.

Changes

  • src/routes/(app)/jersey/+page.svelte:
    • Detect TypeError (Safari "Load failed", Chrome "Failed to fetch") and AbortError separately from server errors, showing user-friendly messages with retry guidance
    • Apply encodeURIComponent() to token and player_id in checkout and player-info URLs
    • Add 30-second fetch timeout via AbortController to prevent indefinite hangs
    • Extract detail field from JSON error responses for clearer server-side error messages

Test Plan

  • Build succeeds (npm run build passes)
  • Manual test: load jersey page with valid token, click Order, confirm redirect to Stripe checkout
  • Manual test: disconnect network, click Order, confirm user-friendly "Could not connect" message instead of "load failed"
  • Manual test: verify timeout message appears after 30s on a hung connection

Review Checklist

  • No secrets committed
  • No unnecessary file changes
  • Commit messages are descriptive
  • Single file changed, minimal diff
  • Closes #220
  • forgejo_admin/basketball-api #340 -- parent issue reporting the bug
  • westside -- the project this work belongs to
## Summary A parent (Daniel Niyitanga) clicked "Order" on the jersey page from Safari/iOS and saw a raw "load failed" error. Investigation confirmed the basketball-api backend is fully functional -- CORS preflight returns 200 with correct headers, POST /jersey/checkout works end-to-end returning a Stripe checkout URL, Stripe API key is present, and Tailscale Funnel is configured correctly. The root cause is Safari throwing `TypeError("Load failed")` on transient network failures during `fetch()`, which the frontend displayed verbatim. ## Changes - `src/routes/(app)/jersey/+page.svelte`: - Detect `TypeError` (Safari "Load failed", Chrome "Failed to fetch") and `AbortError` separately from server errors, showing user-friendly messages with retry guidance - Apply `encodeURIComponent()` to token and player_id in checkout and player-info URLs - Add 30-second fetch timeout via `AbortController` to prevent indefinite hangs - Extract `detail` field from JSON error responses for clearer server-side error messages ## Test Plan - [x] Build succeeds (`npm run build` passes) - [ ] Manual test: load jersey page with valid token, click Order, confirm redirect to Stripe checkout - [ ] Manual test: disconnect network, click Order, confirm user-friendly "Could not connect" message instead of "load failed" - [ ] Manual test: verify timeout message appears after 30s on a hung connection ## Review Checklist - [x] No secrets committed - [x] No unnecessary file changes - [x] Commit messages are descriptive - [x] Single file changed, minimal diff ## Related Notes - Closes #220 - `forgejo_admin/basketball-api #340` -- parent issue reporting the bug - `westside` -- the project this work belongs to
fix: handle jersey checkout network errors gracefully on Safari/iOS
All checks were successful
ci/woodpecker/pr/woodpecker Pipeline was successful
bf32ea0f36
Safari throws TypeError("Load failed") on transient network failures
during fetch(), which was displayed raw to parents. This change:

- Detects network-level errors (TypeError) and AbortController timeouts
  separately from server errors, showing user-friendly messages
- URL-encodes token and player_id in checkout and player-info URLs
- Adds 30s fetch timeout via AbortController to prevent indefinite hangs
- Extracts detail field from JSON error responses for clearer server errors

Closes forgejo_admin/basketball-api#340

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author
Owner

Review: APPROVED

Investigation summary: Confirmed all three suspected causes from basketball-api#340 are NOT the root cause:

  1. CORS: Preflight returns 200 with correct access-control-allow-origin header for the production hostname
  2. Stripe key: Present in k8s secret (sk_live_51...), pod can reach Stripe API, checkout endpoint returns valid Stripe checkout URL
  3. Stale deployment: Deployed image (8d0eac3) matches origin/main HEAD

The "load failed" is Safari/iOS's TypeError.message for transient network failures during fetch(). The fix correctly handles this at the frontend level.

Diff review:

  • URL encoding of query params is a defensive improvement (tokens could contain +, /, = from base64)
  • AbortController timeout with finally { clearTimeout } is correct cleanup pattern
  • TypeError/AbortError detection covers Safari, Chrome, and Firefox network error variants
  • JSON detail extraction gives users meaningful server error messages instead of raw response bodies
  • No scope creep -- single file, focused changes

Build: Passes (npm run build succeeds)

## Review: APPROVED **Investigation summary**: Confirmed all three suspected causes from basketball-api#340 are NOT the root cause: 1. **CORS**: Preflight returns 200 with correct `access-control-allow-origin` header for the production hostname 2. **Stripe key**: Present in k8s secret (`sk_live_51...`), pod can reach Stripe API, checkout endpoint returns valid Stripe checkout URL 3. **Stale deployment**: Deployed image (`8d0eac3`) matches `origin/main` HEAD The "load failed" is Safari/iOS's `TypeError.message` for transient network failures during `fetch()`. The fix correctly handles this at the frontend level. **Diff review**: - URL encoding of query params is a defensive improvement (tokens could contain `+`, `/`, `=` from base64) - AbortController timeout with `finally { clearTimeout }` is correct cleanup pattern - TypeError/AbortError detection covers Safari, Chrome, and Firefox network error variants - JSON detail extraction gives users meaningful server error messages instead of raw response bodies - No scope creep -- single file, focused changes **Build**: Passes (`npm run build` succeeds)
Author
Owner

PR #221 Review

DOMAIN REVIEW

Tech stack: SvelteKit, vanilla JS (no TypeScript), browser fetch API.

Network error handling (core fix)
The TypeError / AbortError detection pattern is correct. Safari throws TypeError("Load failed"), Chrome throws TypeError("Failed to fetch"), and Firefox throws TypeError("NetworkError..."). Catching TypeError covers all three browsers. The AbortError check via DOMException with name === 'AbortError' is the correct pattern for AbortController timeouts.

AbortController timeout
Implementation is correct. The setTimeout -> controller.abort() pattern with clearTimeout in the finally block prevents timer leaks whether the fetch succeeds, fails, or throws. 30 seconds is a reasonable timeout for a Stripe session creation round-trip.

URL encoding
encodeURIComponent() on token and playerId is correct defensive coding. The ?? '' fallback for nullable values avoids passing "undefined" or "null" as literal strings, which is good.

JSON error parsing
The try { JSON.parse } catch { use raw text } pattern for extracting detail from error responses is sound. FastAPI returns {"detail": "..."} by default, so this extracts meaningful messages when the backend returns structured errors.

Consistency observation (nit, not blocker)
The onMount fetch block (player-info, options, sizes around lines 152-222) does NOT get the same network error handling or timeout treatment. That block has its own catch that falls back to hardcoded options, which is acceptable for page load -- but it means a network failure during initial load still shows the fallback UI silently, while a network failure during checkout now shows a clear error. This asymmetry is fine given the different UX requirements (page load vs. payment action), but worth noting.

BLOCKERS

None.

On the test coverage BLOCKER criterion: This repo has zero test infrastructure -- no test files, no test runner configured for component tests. The PR is a targeted bugfix to an existing untested Svelte component, not new functionality. Requiring test coverage for a 3-line error classification change in a repo with no test harness would be disproportionate. The manual test plan in the PR body is appropriate for this change. If/when this repo gets test infrastructure, the jersey checkout flow should be a priority for coverage.

NITS

  1. Timeout magic number: 30000 is hardcoded. Consider extracting to a named constant (e.g., const CHECKOUT_TIMEOUT_MS = 30000;) for readability and single-point-of-change. Minor.

  2. Missing timeout on player-info fetch: The initial Promise.all fetch block (line ~161) has no timeout. A hung connection during page load would spin the loading indicator indefinitely. Lower priority since the user can navigate away, but worth a follow-up ticket.

  3. Comment uses emoji: Line 253 in the diff has a unicode em-dash comment (Timeout after 30s ---). This is fine functionally but note the project CLAUDE.md says no emojis -- the em-dash is not an emoji, just flagging for awareness.

SOP COMPLIANCE

  • Branch named after issue: 220-fix-handle-jersey-checkout-network-error follows {issue-number}-{kebab-case-purpose}
  • PR body follows template: Summary, Changes, Test Plan, Review Checklist, Related
  • Related references parent issue (#220) and cross-repo issue (basketball-api #340)
  • Related references plan slug -- no plan slug referenced; this is a standalone bugfix, which is acceptable per kanban flow
  • No secrets committed -- single Svelte file changed, no credentials
  • No unnecessary file changes -- 1 file, minimal diff, tightly scoped
  • Commit messages are descriptive

PROCESS OBSERVATIONS

  • Change failure risk: Low. The change only affects error message display and adds defensive encoding. No behavioral change to the happy path (fetch still executes the same request, redirect still works the same way).
  • Deployment frequency: This is a targeted hotfix for a real user-reported bug. Fast merge is appropriate.
  • MTTR: Good. Bug reported, root cause identified (Safari fetch error message), fix is surgical. The investigation in the PR body showing CORS/backend validation was thorough.

VERDICT: APPROVED

## PR #221 Review ### DOMAIN REVIEW **Tech stack**: SvelteKit, vanilla JS (no TypeScript), browser fetch API. **Network error handling (core fix)** The `TypeError` / `AbortError` detection pattern is correct. Safari throws `TypeError("Load failed")`, Chrome throws `TypeError("Failed to fetch")`, and Firefox throws `TypeError("NetworkError...")`. Catching `TypeError` covers all three browsers. The `AbortError` check via `DOMException` with `name === 'AbortError'` is the correct pattern for `AbortController` timeouts. **AbortController timeout** Implementation is correct. The `setTimeout` -> `controller.abort()` pattern with `clearTimeout` in the `finally` block prevents timer leaks whether the fetch succeeds, fails, or throws. 30 seconds is a reasonable timeout for a Stripe session creation round-trip. **URL encoding** `encodeURIComponent()` on `token` and `playerId` is correct defensive coding. The `?? ''` fallback for nullable values avoids passing `"undefined"` or `"null"` as literal strings, which is good. **JSON error parsing** The `try { JSON.parse } catch { use raw text }` pattern for extracting `detail` from error responses is sound. FastAPI returns `{"detail": "..."}` by default, so this extracts meaningful messages when the backend returns structured errors. **Consistency observation (nit, not blocker)** The `onMount` fetch block (player-info, options, sizes around lines 152-222) does NOT get the same network error handling or timeout treatment. That block has its own catch that falls back to hardcoded options, which is acceptable for page load -- but it means a network failure during initial load still shows the fallback UI silently, while a network failure during checkout now shows a clear error. This asymmetry is fine given the different UX requirements (page load vs. payment action), but worth noting. ### BLOCKERS None. **On the test coverage BLOCKER criterion**: This repo has zero test infrastructure -- no test files, no test runner configured for component tests. The PR is a targeted bugfix to an existing untested Svelte component, not new functionality. Requiring test coverage for a 3-line error classification change in a repo with no test harness would be disproportionate. The manual test plan in the PR body is appropriate for this change. If/when this repo gets test infrastructure, the jersey checkout flow should be a priority for coverage. ### NITS 1. **Timeout magic number**: `30000` is hardcoded. Consider extracting to a named constant (e.g., `const CHECKOUT_TIMEOUT_MS = 30000;`) for readability and single-point-of-change. Minor. 2. **Missing timeout on player-info fetch**: The initial `Promise.all` fetch block (line ~161) has no timeout. A hung connection during page load would spin the loading indicator indefinitely. Lower priority since the user can navigate away, but worth a follow-up ticket. 3. **Comment uses emoji**: Line 253 in the diff has a unicode em-dash comment (`Timeout after 30s ---`). This is fine functionally but note the project CLAUDE.md says no emojis -- the em-dash is not an emoji, just flagging for awareness. ### SOP COMPLIANCE - [x] Branch named after issue: `220-fix-handle-jersey-checkout-network-error` follows `{issue-number}-{kebab-case-purpose}` - [x] PR body follows template: Summary, Changes, Test Plan, Review Checklist, Related - [x] Related references parent issue (#220) and cross-repo issue (basketball-api #340) - [ ] Related references plan slug -- no plan slug referenced; this is a standalone bugfix, which is acceptable per kanban flow - [x] No secrets committed -- single Svelte file changed, no credentials - [x] No unnecessary file changes -- 1 file, minimal diff, tightly scoped - [x] Commit messages are descriptive ### PROCESS OBSERVATIONS - **Change failure risk**: Low. The change only affects error message display and adds defensive encoding. No behavioral change to the happy path (fetch still executes the same request, redirect still works the same way). - **Deployment frequency**: This is a targeted hotfix for a real user-reported bug. Fast merge is appropriate. - **MTTR**: Good. Bug reported, root cause identified (Safari fetch error message), fix is surgical. The investigation in the PR body showing CORS/backend validation was thorough. ### VERDICT: APPROVED
forgejo_admin deleted branch 220-fix-handle-jersey-checkout-network-error 2026-04-05 20:07:34 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
forgejo_admin/westside-app!221
No description provided.