connectivity fixes
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful
This commit is contained in:
parent
3d06c0c09d
commit
c70aa42076
@ -1 +1 @@
|
||||
3834d1308cdf6ddffce440b08f3aceb39e2cd6e9-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b
|
||||
620419ccaec3e3b8f78ba81554844bd12f8a110d-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b
|
||||
|
@ -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]);
|
||||
|
||||
|
@ -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));
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user