SvelteKit中如何处理并发Token刷新请求?
解决SvelteKit中服务端并发刷新Token的问题
问题背景
在SvelteKit应用的+page.server.js的load函数中,我用自定义的customFetch调用第三方后端接口,后端采用refresh token机制:当收到401响应时,会刷新Token并重试原请求;如果刷新失败(refresh token过期)就登出用户。
但用户操作过快时会出现并发刷新请求,第一个请求之外的所有刷新都会使用已过期的refresh token,导致后续请求失败并触发登出。通用方案是用一个Promise让后续请求等待刷新完成,但在SvelteKit服务端场景下,这个Promise该存在哪里?如果改用客户端的+page.js加载函数,是不是可以用setContext/getContext实现?
现有伪代码
+page.server.js
import { customFetch } from "$./customFetch.js"; export async function load(event) { const fetchSomeData = async () => { const res = await customFetch( "https://someURL.com", { method: "GET", }, event ); const data = await res.json(); return data.data; }; return { news: await fetchSomeData(), }; }
customFetch.js
export async function customFetch(url, config = {}, event) { const { cookies } = event; // 处理默认配置 const headers = config.headers || {}; const method = config.method || "GET"; const body = config.body || null; const session = cookies.get("session"); // 原代码存在逻辑问题,需从session中拆分token const { accessToken, refreshToken } = session ? JSON.parse(session) : {}; // 添加Authorization头 if (accessToken) { headers["Authorization"] = `Bearer ${accessToken}`; } // 发起初始请求 const response = await event.fetch(url, { method, headers, body }); if (response.status === 401) { // 原代码用全局变量存储refreshPromise,会导致跨请求污染 if (!refreshPromise) { refreshPromise = refreshAccessToken(event); } await refreshPromise; return customFetch(url, { method, body, headers }, event); } refreshPromise = null; return response; } async function refreshAccessToken(event) { try { // 刷新Token逻辑:调用后端刷新接口 const { cookies } = event; const session = cookies.get("session"); const { refreshToken } = session ? JSON.parse(session) : {}; const refreshRes = await event.fetch("https://backend.com/refresh", { method: "POST", body: JSON.stringify({ refreshToken }) }); if (!refreshRes.ok) throw new Error("刷新Token失败"); const newSession = await refreshRes.json(); // 服务端可通过cookies.set更新http-only cookie cookies.set("session", JSON.stringify(newSession), { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "strict", path: "/" }); } catch (error) { // 刷新失败,登出用户 event.cookies.delete("session", { path: "/" }); throw error; } finally { refreshPromise = null; } }
解决方案
1. 服务端场景(+page.server.js):将refreshPromise存在event.locals中
SvelteKit的event.locals是每个请求独立的上下文对象,不会在不同用户/请求间共享,完美解决全局变量的污染问题。修改后的customFetch.js如下:
export async function customFetch(url, config = {}, event) { const { cookies, locals } = event; // 处理默认配置 const headers = config.headers || {}; const method = config.method || "GET"; const body = config.body || null; const session = cookies.get("session"); const { accessToken, refreshToken } = session ? JSON.parse(session) : {}; // 添加Authorization头 if (accessToken) { headers["Authorization"] = `Bearer ${accessToken}`; } // 发起初始请求 const response = await event.fetch(url, { method, headers, body }); if (response.status === 401) { // 用event.locals存储刷新Promise,每个请求独立 if (!locals.refreshPromise) { locals.refreshPromise = refreshAccessToken(event); } await locals.refreshPromise; // 刷新后重新调用customFetch,获取新token发起请求 return customFetch(url, { method, body, headers }, event); } // 请求成功,清除刷新Promise locals.refreshPromise = null; return response; } async function refreshAccessToken(event) { try { const { cookies, locals } = event; const session = cookies.get("session"); const { refreshToken } = session ? JSON.parse(session) : {}; const refreshRes = await event.fetch("https://backend.com/refresh", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refreshToken }) }); if (!refreshRes.ok) throw new Error("刷新Token失败"); const newSession = await refreshRes.json(); cookies.set("session", JSON.stringify(newSession), { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "strict", path: "/" }); } catch (error) { cookies.delete("session", { path: "/" }); throw error; } finally { // 清除locals里的Promise event.locals.refreshPromise = null; } }
2. 客户端场景(+page.js):用setContext/getContext管理刷新Promise
如果是在客户端的+page.js或组件中调用customFetch,可以用Svelte的上下文API存储刷新Promise,确保每个用户会话独立:
首先在根布局+layout.svelte初始化上下文:
<script> import { setContext } from 'svelte'; // 初始化刷新Promise上下文,初始为null setContext('refreshPromise', null); </script> <slot />
然后修改客户端版本的customFetch.js:
import { getContext, setContext } from 'svelte'; export async function customFetch(url, config = {}) { const headers = config.headers || {}; const method = config.method || "GET"; const body = config.body || null; // 客户端从localStorage获取session(需后端配合允许客户端存储token) const session = localStorage.getItem("session"); const { accessToken, refreshToken } = session ? JSON.parse(session) : {}; if (accessToken) { headers["Authorization"] = `Bearer ${accessToken}`; } const response = await fetch(url, { method, headers, body }); if (response.status === 401) { let refreshPromise = getContext('refreshPromise'); if (!refreshPromise) { refreshPromise = refreshAccessToken(); setContext('refreshPromise', refreshPromise); } await refreshPromise; return customFetch(url, { method, body, headers }); } setContext('refreshPromise', null); return response; } async function refreshAccessToken() { try { const session = localStorage.getItem("session"); const { refreshToken } = session ? JSON.parse(session) : {}; const refreshRes = await fetch("https://backend.com/refresh", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refreshToken }) }); if (!refreshRes.ok) throw new Error("刷新Token失败"); const newSession = await refreshRes.json(); localStorage.setItem("session", JSON.stringify(newSession)); } catch (error) { localStorage.removeItem("session"); // 客户端跳转登录页 window.location.href = "/login"; throw error; } finally { setContext('refreshPromise', null); } }
关键注意点
- 服务端必须用
event.locals存储刷新Promise,绝对不能用全局变量,否则会导致不同用户的请求互相干扰。 - 客户端用
setContext/getContext时,要确保上下文作用域覆盖所有需要调用customFetch的组件,根布局是最合适的选择。 - 刷新Token成功后,一定要更新存储token的介质(服务端是http-only cookie,客户端根据实际情况选择localStorage或sessionStorage),确保后续请求能拿到新的token。
内容的提问来源于stack exchange,提问作者Fusscreme
相关产品推荐
相关产品推荐

