From c70aa42076a38202ee7cda6fb4177d4f6fb4beda Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 26 Sep 2025 14:15:23 +0000 Subject: [PATCH] connectivity fixes --- .build.hash | 2 +- src/App.js | 6 +++ src/auth/apiClient.js | 32 +++++++++++++- src/auth/apiFetch.js | 74 +++++++++++++++++++++++++------- src/utils/authFetch.js | 95 ++++++++++++++++++++++++++++++++++++------ 5 files changed, 178 insertions(+), 31 deletions(-) diff --git a/.build.hash b/.build.hash index 2a63884..a6e3ca4 100644 --- a/.build.hash +++ b/.build.hash @@ -1 +1 @@ -3834d1308cdf6ddffce440b08f3aceb39e2cd6e9-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b +620419ccaec3e3b8f78ba81554844bd12f8a110d-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b diff --git a/src/App.js b/src/App.js index 5fd1678..8932074 100644 --- a/src/App.js +++ b/src/App.js @@ -47,6 +47,7 @@ import api from './auth/apiClient.js'; import * as safeLocal from './utils/safeLocal.js'; import VerificationGate from './components/VerificationGate.js'; import Verify from './components/Verify.js'; +import { initNetObserver } from './utils/net.js'; @@ -80,6 +81,11 @@ function App() { const AUTH_HOME = '/signin-landing'; + useEffect(() => { + const cleanup = initNetObserver(); // track effectiveType/downlink + return cleanup; + }, []); + const prevPathRef = React.useRef(location.pathname); useEffect(() => { prevPathRef.current = location.pathname; }, [location.pathname]); diff --git a/src/auth/apiClient.js b/src/auth/apiClient.js index e438731..23cca0f 100644 --- a/src/auth/apiClient.js +++ b/src/auth/apiClient.js @@ -1,6 +1,7 @@ -// src/apiClient.js +// src/auth/apiClient.js import axios from 'axios'; import { getToken, clearToken } from './authMemory.js'; +import { getNetState } from '../utils/net.js'; // sane defaults axios.defaults.withCredentials = true; // send cookies to same-origin /api when needed @@ -8,6 +9,11 @@ axios.defaults.timeout = 20000; // attach Authorization from in-memory token axios.interceptors.request.use((config) => { + // Adapt timeout on mobile slow networks unless caller already set one + if (config.timeout == null) { + const { slow } = getNetState(); + config.timeout = slow ? 15000 : 8000; + } const t = getToken(); if (t) { config.headers = config.headers || {}; @@ -34,7 +40,29 @@ axios.interceptors.response.use( // ping your SessionExpiredHandler (you already mount it) window.dispatchEvent(new CustomEvent('aptiva:session-expired')); } - return Promise.reject(err); + // Bounded retry for idempotent requests on 408/429/5xx and network timeouts + const cfg = err?.config || {}; + const method = (cfg.method || 'get').toUpperCase(); + const isIdempotent = /^(GET|HEAD|OPTIONS)$/i.test(method); + const code = err?.code; // 'ECONNABORTED' for timeout + const shouldRetry = + isIdempotent && ( + status === 408 || status === 429 || (status >= 500 && status <= 599) || code === 'ECONNABORTED' + ); + if (!shouldRetry) return Promise.reject(err); + + cfg.__retryCount = (cfg.__retryCount || 0) + 1; + const { slow } = getNetState(); + const max = slow ? 2 : 1; // keep axios retries conservative + if (cfg.__retryCount > max) return Promise.reject(err); + + // Honor Retry-After if sent + const raHeader = err?.response?.headers?.['retry-after']; + const raMs = (raHeader && !Number.isNaN(Number(raHeader))) ? Number(raHeader) * 1000 : null; + const base = slow ? 600 : 350; + const backoff = raMs ?? Math.round(Math.min(2000, base * Math.pow(2, cfg.__retryCount)) * (0.75 + Math.random()*0.5)); + + return new Promise((resolve) => setTimeout(resolve, backoff)).then(() => axios(cfg)); } ); diff --git a/src/auth/apiFetch.js b/src/auth/apiFetch.js index fb6b513..2112f64 100644 --- a/src/auth/apiFetch.js +++ b/src/auth/apiFetch.js @@ -1,23 +1,67 @@ // src/auth/apiFetch.js import { getToken, clearToken } from './authMemory.js'; +import { getNetState } from '../utils/net.js'; + -export default function apiFetch(input, init = {}) { +function defaultTimeoutMs() { + const { slow } = getNetState(); + return slow ? 15000 : 8000; +} +function isIdempotent(m) { return /^(GET|HEAD|OPTIONS)$/i.test(m || 'GET'); } +function maxAttempts(m, force) { const { slow } = getNetState(); return isIdempotent(m) ? (slow ? 4 : 2) : (force ? (slow ? 3 : 2) : 1); } +function backoff(a){ const { slow }=getNetState(); const b=slow?600:350, c=slow?4000:2000; const r=Math.min(c,b*Math.pow(2,a)); return Math.round(r*(0.75+Math.random()*0.5)); } +function parseRetryAfter(h){ const s=Number(h); return Number.isFinite(s)&&s>=0?s*1000:null; } + +export default async function apiFetch(input, init = {}) { const headers = new Headers(init.headers || {}); // optional: add Bearer if you *happen* to have one in memory const t = window.__auth?.get?.(); if (t) headers.set('Authorization', `Bearer ${t}`); - return fetch(input, { - ...init, - headers, - credentials: 'include' // ← send cookie - }).then(async (res) => { - if (res.ok) return res; - const rid = res.headers.get('x-request-id'); - const text = await res.text().catch(() => ''); - const err = new Error(text || res.statusText || 'Request failed'); - err.status = res.status; - if (rid) err.requestId = rid; - throw err; - }); -} + const method = (init.method || 'GET').toUpperCase(); + const attempts = maxAttempts(method, !!init.retryNonIdempotent); + const timeoutMs = init.timeoutMs ?? defaultTimeoutMs(); + let attempt = 0, lastErr; + + while (attempt < attempts) { + const controller = new AbortController(); + const tid = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(input, { + ...init, + signal: controller.signal, + headers, + credentials: 'include' // ← send cookie + }); + clearTimeout(tid); + if (res.ok) return res; + const rid = res.headers.get('x-request-id'); + if ((res.status === 408 || res.status === 429 || (res.status >= 500 && res.status <= 599)) && + (isIdempotent(method) || init.retryNonIdempotent)) { + attempt++; + if (attempt >= attempts) { + const text = await res.text().catch(()=>''); + const err = new Error(text || res.statusText || 'Request failed'); err.status=res.status; if (rid) err.requestId=rid; throw err; + } + const ra = parseRetryAfter(res.headers.get('Retry-After')); + await new Promise(r => setTimeout(r, ra ?? backoff(attempt))); + continue; + } + const text = await res.text().catch(()=>''); + const err = new Error(text || res.statusText || 'Request failed'); + err.status = res.status; + if (rid) err.requestId = rid; + throw err; + } catch (e) { + clearTimeout(tid); + lastErr = e; + const aborted = e?.name === 'AbortError'; + const networkish = aborted || e instanceof TypeError; + if (!(networkish && (isIdempotent(method) || init.retryNonIdempotent))) throw e; + attempt++; + if (attempt >= attempts) throw e; + await new Promise(r => setTimeout(r, backoff(attempt))); + } + } + if (lastErr) throw lastErr; + } diff --git a/src/utils/authFetch.js b/src/utils/authFetch.js index ea997c3..1dc3d81 100644 --- a/src/utils/authFetch.js +++ b/src/utils/authFetch.js @@ -3,33 +3,102 @@ // - Sends cookies automatically (credentials: 'include'). // - Keeps the same behavior: return Response, or null on 401/403. +import { getNetState } from './net.js'; + let onSessionExpiredCallback = null; export const setSessionExpiredCallback = (callback) => { onSessionExpiredCallback = callback; }; +function defaultTimeoutMs() { + const { slow } = getNetState(); + return slow ? 15000 : 8000; +} + +function isIdempotent(method) { + return /^(GET|HEAD|OPTIONS)$/i.test(method || 'GET'); +} + +function maxAttempts(method, forceRetryNonIdempotent) { + const { slow } = getNetState(); + if (isIdempotent(method)) return slow ? 4 : 2; + return forceRetryNonIdempotent ? (slow ? 3 : 2) : 1; +} + +function nextBackoff(attempt) { + const { slow } = getNetState(); + const base = slow ? 600 : 350; + const cap = slow ? 4000 : 2000; + const raw = Math.min(cap, base * Math.pow(2, attempt)); + const jitter = raw * (0.75 + Math.random() * 0.5); + return Math.round(jitter); +} + +function parseRetryAfter(h) { + if (!h) return null; + const sec = Number(h); + return Number.isFinite(sec) && sec >= 0 ? sec * 1000 : null; +} + const authFetch = async (url, options = {}) => { const method = (options.method || 'GET').toUpperCase(); const hasCTHeader = options.headers && Object.prototype.hasOwnProperty.call(options.headers, 'Content-Type'); const shouldIncludeContentType = ['POST','PUT','PATCH'].includes(method) && !hasCTHeader; - const res = await fetch(url, { - credentials: 'include', // <-- send httpOnly session cookie - ...options, - headers: { - ...(shouldIncludeContentType ? { 'Content-Type': 'application/json' } : {}), - Accept: 'application/json', - ...(options.headers || {}), - }, - }); + const attempts = maxAttempts(method, !!options.retryNonIdempotent); + const timeoutMs = options.timeoutMs ?? defaultTimeoutMs(); + let attempt = 0; + let lastErr; - if (res.status === 401 || res.status === 403) { - onSessionExpiredCallback?.(); - return null; + while (attempt < attempts) { + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + credentials: 'include', // <-- send httpOnly session cookie + ...options, + signal: controller.signal, + headers: { + ...(shouldIncludeContentType ? { 'Content-Type': 'application/json' } : {}), + Accept: 'application/json', + ...(options.headers || {}), + }, + }); + clearTimeout(t); + + if (res.status === 401 || res.status === 403) { + onSessionExpiredCallback?.(); + return null; + } + + if (res.ok) return res; + + // Retry on 408/429/5xx + if ((res.status === 408 || res.status === 429 || (res.status >= 500 && res.status <= 599)) && + (isIdempotent(method) || options.retryNonIdempotent)) { + attempt++; + if (attempt >= attempts) return res; // bubble final response to caller + const ra = parseRetryAfter(res.headers.get('Retry-After')); + await new Promise(r => setTimeout(r, ra ?? nextBackoff(attempt))); + continue; + } + return res; // non-retriable status + } catch (e) { + clearTimeout(t); + lastErr = e; + const aborted = e?.name === 'AbortError'; + const networkish = aborted || e instanceof TypeError; + if (!(networkish && (isIdempotent(method) || options.retryNonIdempotent))) throw e; + attempt++; + if (attempt >= attempts) throw e; + await new Promise(r => setTimeout(r, nextBackoff(attempt))); + } } + if (lastErr) throw lastErr; + // Fallback (shouldn’t hit) + return fetch(url, options); - return res; }; export default authFetch;