connectivity fixes
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful

This commit is contained in:
Josh 2025-09-26 14:15:23 +00:00
parent 3d06c0c09d
commit c70aa42076
5 changed files with 178 additions and 31 deletions

View File

@ -1 +1 @@
3834d1308cdf6ddffce440b08f3aceb39e2cd6e9-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b 620419ccaec3e3b8f78ba81554844bd12f8a110d-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b

View File

@ -47,6 +47,7 @@ import api from './auth/apiClient.js';
import * as safeLocal from './utils/safeLocal.js'; import * as safeLocal from './utils/safeLocal.js';
import VerificationGate from './components/VerificationGate.js'; import VerificationGate from './components/VerificationGate.js';
import Verify from './components/Verify.js'; import Verify from './components/Verify.js';
import { initNetObserver } from './utils/net.js';
@ -80,6 +81,11 @@ function App() {
const AUTH_HOME = '/signin-landing'; const AUTH_HOME = '/signin-landing';
useEffect(() => {
const cleanup = initNetObserver(); // track effectiveType/downlink
return cleanup;
}, []);
const prevPathRef = React.useRef(location.pathname); const prevPathRef = React.useRef(location.pathname);
useEffect(() => { prevPathRef.current = location.pathname; }, [location.pathname]); useEffect(() => { prevPathRef.current = location.pathname; }, [location.pathname]);

View File

@ -1,6 +1,7 @@
// src/apiClient.js // src/auth/apiClient.js
import axios from 'axios'; import axios from 'axios';
import { getToken, clearToken } from './authMemory.js'; import { getToken, clearToken } from './authMemory.js';
import { getNetState } from '../utils/net.js';
// sane defaults // sane defaults
axios.defaults.withCredentials = true; // send cookies to same-origin /api when needed 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 // attach Authorization from in-memory token
axios.interceptors.request.use((config) => { 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(); const t = getToken();
if (t) { if (t) {
config.headers = config.headers || {}; config.headers = config.headers || {};
@ -34,7 +40,29 @@ axios.interceptors.response.use(
// ping your SessionExpiredHandler (you already mount it) // ping your SessionExpiredHandler (you already mount it)
window.dispatchEvent(new CustomEvent('aptiva:session-expired')); 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));
} }
); );

View File

@ -1,23 +1,67 @@
// src/auth/apiFetch.js // src/auth/apiFetch.js
import { getToken, clearToken } from './authMemory.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 || {}); const headers = new Headers(init.headers || {});
// optional: add Bearer if you *happen* to have one in memory // optional: add Bearer if you *happen* to have one in memory
const t = window.__auth?.get?.(); const t = window.__auth?.get?.();
if (t) headers.set('Authorization', `Bearer ${t}`); if (t) headers.set('Authorization', `Bearer ${t}`);
return fetch(input, { 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, ...init,
signal: controller.signal,
headers, headers,
credentials: 'include' // ← send cookie credentials: 'include' // ← send cookie
}).then(async (res) => { });
clearTimeout(tid);
if (res.ok) return res; if (res.ok) return res;
const rid = res.headers.get('x-request-id'); 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 text = await res.text().catch(()=>'');
const err = new Error(text || res.statusText || 'Request failed'); const err = new Error(text || res.statusText || 'Request failed');
err.status = res.status; err.status = res.status;
if (rid) err.requestId = rid; if (rid) err.requestId = rid;
throw err; 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;
} }

View File

@ -3,33 +3,102 @@
// - Sends cookies automatically (credentials: 'include'). // - Sends cookies automatically (credentials: 'include').
// - Keeps the same behavior: return Response, or null on 401/403. // - Keeps the same behavior: return Response, or null on 401/403.
import { getNetState } from './net.js';
let onSessionExpiredCallback = null; let onSessionExpiredCallback = null;
export const setSessionExpiredCallback = (callback) => { export const setSessionExpiredCallback = (callback) => {
onSessionExpiredCallback = 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 authFetch = async (url, options = {}) => {
const method = (options.method || 'GET').toUpperCase(); const method = (options.method || 'GET').toUpperCase();
const hasCTHeader = options.headers && Object.prototype.hasOwnProperty.call(options.headers, 'Content-Type'); const hasCTHeader = options.headers && Object.prototype.hasOwnProperty.call(options.headers, 'Content-Type');
const shouldIncludeContentType = ['POST','PUT','PATCH'].includes(method) && !hasCTHeader; const shouldIncludeContentType = ['POST','PUT','PATCH'].includes(method) && !hasCTHeader;
const attempts = maxAttempts(method, !!options.retryNonIdempotent);
const timeoutMs = options.timeoutMs ?? defaultTimeoutMs();
let attempt = 0;
let lastErr;
while (attempt < attempts) {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { const res = await fetch(url, {
credentials: 'include', // <-- send httpOnly session cookie credentials: 'include', // <-- send httpOnly session cookie
...options, ...options,
signal: controller.signal,
headers: { headers: {
...(shouldIncludeContentType ? { 'Content-Type': 'application/json' } : {}), ...(shouldIncludeContentType ? { 'Content-Type': 'application/json' } : {}),
Accept: 'application/json', Accept: 'application/json',
...(options.headers || {}), ...(options.headers || {}),
}, },
}); });
clearTimeout(t);
if (res.status === 401 || res.status === 403) { if (res.status === 401 || res.status === 403) {
onSessionExpiredCallback?.(); onSessionExpiredCallback?.();
return null; return null;
} }
return res; 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 (shouldnt hit)
return fetch(url, options);
}; };
export default authFetch; export default authFetch;