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 * 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]);
|
||||||
|
|
||||||
|
@ -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));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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 (shouldn’t hit)
|
||||||
|
return fetch(url, options);
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default authFetch;
|
export default authFetch;
|
||||||
|
Loading…
Reference in New Issue
Block a user