修复认证刷新账号代际隔离
按认证代际与起始令牌隔离共享 refresh 并使用 CAS 发布新令牌 防止旧 refresh 失败清理新账号令牌 补充晚到回包与新账号独立 refresh 回归测试 同步项目基线与共享项目记忆
This commit is contained in:
@@ -6483,6 +6483,7 @@
|
||||
- 2026-08-05 账号隔离补充,2026-08-07 完善 legacy 总额入口:账单读取、奖励码和邀请码兑换使用各控制器自己的账号生命周期 / 请求 revision,不依赖共享 Store owner effect 的提交时序;旧账号回调不得更新新账号 UI、结束新请求或刷新新账号钱包。充值下单、邀请码兑换和奖励码兑换三条写请求还必须共享账号生命周期 `AbortController`,在切号 effect cleanup 与卸载时中止旧 signal,使 `fetchWithApiAuth` 的 refresh 等待和写请求退避立即结束,禁止旧 POST 重试重新读取新账号 Token。AI Game Creator 的账单与充值使用独立 lifecycle,账号切换 render 必须同步屏蔽旧账单、充值和支付状态。充值中心兼容响应暂缺共享明细时,弹窗保留响应自带的 `walletBalance / mudPointBalance`,不把有效总额改写为 `0`;owner 匹配的 legacy `walletBalance` 同时可供个人中心统计卡和图片画板顶部等纯总额入口兜底,但 `mudPointBalance`、钱包展开明细和账单分桶继续保持空,不从总额反推或伪造分桶。
|
||||
- 2026-08-07 审查收口补充:共享 Store 显式保存 owner 隔离的 `legacyWalletBalance`,确保首次生命周期读取旧响应时无需先打开充值弹窗即可展示纯总额;较新的 legacy-only 响应必须原子清除旧 `mudPointBalance`,该字段不能生成分桶。直接余额快照附带单调 operation sequence;充值中心、支付确认和 watch 等异步响应都在请求发起前捕获快照,后发操作先落地后拒绝更早快照回滚。刷新错误只向 UI 暴露稳定中文提示,不透传 transport / 后端实现文案。
|
||||
- 2026-08-07 lifecycle 所有权补充:主站钱包坚持全应用唯一 lifecycle,由 `AuthGate` 根认证边界绑定 ready user;现役平台壳、图片编辑器和 profile controller 只消费 Store,不重复声明 owner。根边界在账号失效、依赖切换、StrictMode effect replay 和最终卸载 cleanup 时统一 `resetWalletBalance`,中止活动请求并清除 owner、明细和 legacy 总额;禁止在子页面按相同 user ID 各自清理 module-level Store。
|
||||
- 2026-08-07 refresh 发布隔离补充:`apiClient` 把公开 token 设置与清理视为认证代际变更,共享 `/api/auth/refresh` 只在“代际 + 发起时 token”快照相同时复用。refresh 成功只能以同一快照 CAS 发布新 token,旧账号晚到成功必须拒绝;旧 refresh 的 401/403 也只能在原快照仍当前时清 token。新账号进入新代际后立即发起独立 refresh,不等待也不加入旧账号 Promise。
|
||||
- 2026-08-05 生成扣退费时序补充:external generation 入队时尚未扣费,worker 领取为 `running` 后的资产操作才预扣,业务失败则先退款再写任务失败态。主站钱包因此以账号下全局 active external task 为轮询生命周期,每轮成功状态读取都通知共享 Store 合并刷新,终态轮同时覆盖成功结算与失败退款;画布内容刷新仍只限当前项目的 `completed`,不因其它项目或 `failed` 刷新画布。
|
||||
- 验证:共享 Store 覆盖首次读取、尾随补读、旧响应、账号切换、错误保留和错误 owner;主站覆盖 dashboard 与充值中心不一致时三处 UI 仍一致、切换账号清空及 focus 刷新;AI Game Creator 覆盖 adapter、账号 owner 和 focus 刷新。运行定向 Vitest、两端类型检查、编码检查与 `git diff --check`。
|
||||
- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。
|
||||
|
||||
@@ -4243,6 +4243,13 @@
|
||||
- 处理:并发 bootstrap 每次 attempt 使用独立 `AbortController`,任一分支失败时先中止同轮 controller 再安排有界重试,卸载时中止当前 attempt。充值订单从创建成功起持有同一个 owner、账号 revision 和 `AbortController`;每次 delay、confirm 和 SSE watch 前后都校验生命周期,并把同一 signal 传到请求层;账号切换和卸载先 abort,再清理 ref、state 与旧支付回调 hash。
|
||||
- 验证:bootstrap 用例覆盖“一侧 reject、另一侧 pending、重试后卸载”,并断言每轮 signal 都已中止;充值用 fake timer 证明首次确认 pending 后切换账号会中止 signal,推进全部退避时间也不会产生第二个确认请求或清理新账号 Token。
|
||||
|
||||
## 中止 refresh 等待不等于隔离 token 发布(2026-08-07)
|
||||
|
||||
- 现象:A 账号的写请求 401 后开始共享 refresh,随后切换到 B。A 的 `AbortSignal` 虽然让业务请求立即结束且不再重放 POST,但底层 refresh 为了其它共享等待者不会被中止;A 的成功回包晚到时仍可能覆盖 B 的 token。
|
||||
- 原因:只对 `await` 叠加 abort 保护了调用链,没有给共享 Promise 的归属和最终 token 写入加账号栅栏;单一全局 Promise 还会让 B 加入 A 已在途的 refresh。
|
||||
- 处理:公开 token setter / clearer 每次都推进 auth generation;refresh 按 `generation + 发起时 access token` 共享、并以该快照 CAS 发布成功 token。快照已过期时成功回包转为失效结果,401/403 也不得清理新代际 token;新代际建立自己的 refresh Promise,旧 Promise 收尾时不得清掉新尝试。
|
||||
- 验证:`src/services/apiClient.test.ts` 要等旧 refresh 完整收束后断言 B token 不变,并用两个独立 deferred response 证明 B 会发起第二个 `/api/auth/refresh`;另覆盖旧 refresh 401 晚到不清 B token。
|
||||
|
||||
## 下游 manifest 回调测试不能冒充实时数据源(2026-08-05)
|
||||
|
||||
- 现象:工作台的资源、任务与版本重投影单测保持绿色,但后台 Agent 已更新 `.agent/manifest.json` 后,打开中的工作台仍长期显示旧快照,只有重开项目才更新。
|
||||
|
||||
@@ -66,7 +66,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当
|
||||
5. 前端不得用 `hasPointsRecharged` 统一隐藏所有泥点档位首充权益;该字段只表示账号是否发生过任一泥点充值。
|
||||
6. 充值支付渠道只允许由设备平台隔离层解析为 `wechat_mp`、`wechat_mp_virtual`、`wechat_jsapi`、`wechat_h5` 或 `wechat_native`;生产真实支付不得默认落到 `mock`,缺失或未知 `paymentChannel` 必须拒绝。
|
||||
7. 小程序 WebView 充值使用 `wechat_mp_virtual` 调起小程序虚拟支付;微信内浏览器使用 `wechat_jsapi` 调起微信支付 JSAPI;普通 Web 使用 `wechat_native` 二维码支付,避免因移动 UA、触控能力或窄屏误入 `wechat_h5`。只有微信通知或查单确认 `SUCCESS` 后才刷新余额或会员状态。一次充值从下单、宿主 / JSAPI / H5 / Native 调起到查单确认与 watch 必须始终携带同一个 `ownerUserId + account lifecycle revision`;pending / confirming order、二维码、提交状态、错误结果、成功回调以及清 token、重新登录等认证副作用在每次写入前都必须校验该令牌,账号切换或卸载后旧链路不得再影响新账号。
|
||||
8. 后端必须按 access JWT 中的最小设备快照拦截真实微信充值路径,不能只依赖前端隐藏入口或请求体传入的 `paymentChannel`。前端下单入口还必须使用同步 operation token 防止同一 React 提交周期内重复创建订单;充值下单、邀请码兑换和奖励码兑换必须绑定同一个账号生命周期 `AbortSignal`,切号或卸载时先中止旧 signal,禁止 POST 的 401、503 或网络重试重新读取新账号 Token。账号切换时,充值、账单、邀请码中心、邀请码输入、弹窗和在途读取结果都必须按账号生命周期整体失效。
|
||||
8. 后端必须按 access JWT 中的最小设备快照拦截真实微信充值路径,不能只依赖前端隐藏入口或请求体传入的 `paymentChannel`。前端下单入口还必须使用同步 operation token 防止同一 React 提交周期内重复创建订单;充值下单、邀请码兑换和奖励码兑换必须绑定同一个账号生命周期 `AbortSignal`,切号或卸载时先中止旧 signal,禁止 POST 的 401、503 或网络重试重新读取新账号 Token。共享 access token refresh 还必须按账号认证代际与发起时 token 快照隔离:旧代际成功回包不得发布 token,旧代际失败不得清理新账号 token,新账号不得复用旧账号的在途 refresh Promise。账号切换时,充值、账单、邀请码中心、邀请码输入、弹窗和在途读取结果都必须按账号生命周期整体失效。
|
||||
9. 后台“充值商品”页继续维护泥点和会员商品配置,保存后影响新的充值中心快照、下单和支付确认;历史订单保留下单时快照。会员商品配置保留不表示当前版本开放公开购买或升级入口。
|
||||
|
||||
## 唯一后端路线
|
||||
|
||||
@@ -722,6 +722,14 @@ describe('apiClient', () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
const accountARefresh = refreshStoredAccessToken({
|
||||
clearOnFailure: false,
|
||||
});
|
||||
const accountARefreshResult = expect(accountARefresh).rejects.toMatchObject(
|
||||
{
|
||||
name: 'AuthStateChangedDuringRefreshError',
|
||||
},
|
||||
);
|
||||
setStoredAccessToken('account-b-token', { emit: false });
|
||||
abortController.abort();
|
||||
|
||||
@@ -737,16 +745,85 @@ describe('apiClient', () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await refreshResponse.promise;
|
||||
await Promise.resolve();
|
||||
await accountARefreshResult;
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(getStoredAccessToken()).toBe('account-b-token');
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'/api/profile/redeem-codes/redeem',
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/auth/refresh');
|
||||
});
|
||||
|
||||
it('starts a separate refresh for a new auth generation', async () => {
|
||||
setStoredAccessToken('account-a-token', { emit: false });
|
||||
const accountAResponse =
|
||||
createDeferred<ReturnType<typeof createResponseMock>>();
|
||||
const accountBResponse =
|
||||
createDeferred<ReturnType<typeof createResponseMock>>();
|
||||
fetchMock
|
||||
.mockImplementationOnce(() => accountAResponse.promise)
|
||||
.mockImplementationOnce(() => accountBResponse.promise);
|
||||
|
||||
const accountARefresh = refreshStoredAccessToken({ clearOnFailure: false });
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
setStoredAccessToken('account-b-token', { emit: false });
|
||||
const accountBRefresh = refreshStoredAccessToken({ clearOnFailure: false });
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
accountAResponse.resolve(
|
||||
createResponseMock({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: { token: 'late-account-a-token' },
|
||||
error: null,
|
||||
meta: { apiVersion: '2026-06-16' },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await expect(accountARefresh).rejects.toMatchObject({
|
||||
name: 'AuthStateChangedDuringRefreshError',
|
||||
});
|
||||
expect(getStoredAccessToken()).toBe('account-b-token');
|
||||
|
||||
accountBResponse.resolve(
|
||||
createResponseMock({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: { token: 'fresh-account-b-token' },
|
||||
error: null,
|
||||
meta: { apiVersion: '2026-06-16' },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await expect(accountBRefresh).resolves.toBe('fresh-account-b-token');
|
||||
expect(getStoredAccessToken()).toBe('fresh-account-b-token');
|
||||
});
|
||||
|
||||
it('does not clear a new account token when an old refresh fails late', async () => {
|
||||
setStoredAccessToken('account-a-token', { emit: false });
|
||||
const accountAResponse =
|
||||
createDeferred<ReturnType<typeof createResponseMock>>();
|
||||
fetchMock.mockImplementationOnce(() => accountAResponse.promise);
|
||||
|
||||
const accountARefresh = refreshStoredAccessToken();
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
setStoredAccessToken('account-b-token', { emit: false });
|
||||
accountAResponse.resolve(createResponseMock({ status: 401 }));
|
||||
|
||||
await expect(accountARefresh).rejects.toMatchObject({ status: 401 });
|
||||
expect(getStoredAccessToken()).toBe('account-b-token');
|
||||
});
|
||||
|
||||
it('aborts requests when timeoutMs is reached', async () => {
|
||||
setStoredAccessToken('timeout-token', { emit: false });
|
||||
fetchMock.mockImplementation(
|
||||
|
||||
+107
-17
@@ -114,7 +114,9 @@ function normalizeHeaders(headers?: HeadersInit) {
|
||||
|
||||
function hasHeader(headers: Record<string, string>, name: string) {
|
||||
const normalizedName = name.toLowerCase();
|
||||
return Object.keys(headers).some((key) => key.toLowerCase() === normalizedName);
|
||||
return Object.keys(headers).some(
|
||||
(key) => key.toLowerCase() === normalizedName,
|
||||
);
|
||||
}
|
||||
|
||||
function setHeaderIfMissing(
|
||||
@@ -146,7 +148,11 @@ function attachHostRuntimeHeaders(headers: Record<string, string>) {
|
||||
runtime.clientRuntime || 'wechat_mini_program',
|
||||
);
|
||||
setHeaderIfMissing(headers, CLIENT_PLATFORM_HEADER, runtime.hostPlatform);
|
||||
setHeaderIfMissing(headers, MINI_PROGRAM_ENV_HEADER, runtime.miniProgramEnv);
|
||||
setHeaderIfMissing(
|
||||
headers,
|
||||
MINI_PROGRAM_ENV_HEADER,
|
||||
runtime.miniProgramEnv,
|
||||
);
|
||||
return headers;
|
||||
}
|
||||
|
||||
@@ -171,7 +177,10 @@ function buildClientRequestId() {
|
||||
return `web-${randomId}`;
|
||||
}
|
||||
|
||||
function resolveRequestIdHeader(headers: Record<string, string>, options: ApiRequestOptions) {
|
||||
function resolveRequestIdHeader(
|
||||
headers: Record<string, string>,
|
||||
options: ApiRequestOptions,
|
||||
) {
|
||||
const explicitRequestId = options.requestId?.trim();
|
||||
const existingRequestId = Object.entries(headers).find(
|
||||
([key, value]) => key.toLowerCase() === REQUEST_ID_HEADER && value.trim(),
|
||||
@@ -630,7 +639,9 @@ export function getStoredAccessToken() {
|
||||
return window.localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() || '';
|
||||
}
|
||||
|
||||
export function setStoredAccessToken(
|
||||
let authStateGeneration = 0;
|
||||
|
||||
function writeStoredAccessToken(
|
||||
token: string,
|
||||
options: {
|
||||
emit?: boolean;
|
||||
@@ -654,6 +665,20 @@ export function setStoredAccessToken(
|
||||
}
|
||||
}
|
||||
|
||||
export function setStoredAccessToken(
|
||||
token: string,
|
||||
options: {
|
||||
emit?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
authStateGeneration += 1;
|
||||
writeStoredAccessToken(token, options);
|
||||
}
|
||||
|
||||
export function clearStoredAccessToken(
|
||||
options: {
|
||||
emit?: boolean;
|
||||
@@ -663,6 +688,7 @@ export function clearStoredAccessToken(
|
||||
return;
|
||||
}
|
||||
|
||||
authStateGeneration += 1;
|
||||
const previousToken = getStoredAccessToken();
|
||||
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
|
||||
@@ -687,7 +713,50 @@ function withAuthorizationHeaders(
|
||||
return nextHeaders;
|
||||
}
|
||||
|
||||
let refreshAccessTokenPromise: Promise<string> | null = null;
|
||||
type AuthStateSnapshot = {
|
||||
generation: number;
|
||||
accessToken: string;
|
||||
};
|
||||
|
||||
type RefreshAccessTokenAttempt = AuthStateSnapshot & {
|
||||
promise: Promise<string>;
|
||||
};
|
||||
|
||||
class AuthStateChangedDuringRefreshError extends Error {
|
||||
constructor() {
|
||||
super('刷新期间登录状态已变化');
|
||||
this.name = 'AuthStateChangedDuringRefreshError';
|
||||
}
|
||||
}
|
||||
|
||||
let refreshAccessTokenAttempt: RefreshAccessTokenAttempt | null = null;
|
||||
|
||||
function captureAuthStateSnapshot(): AuthStateSnapshot {
|
||||
return {
|
||||
generation: authStateGeneration,
|
||||
accessToken: getStoredAccessToken(),
|
||||
};
|
||||
}
|
||||
|
||||
function isCurrentAuthState(snapshot: AuthStateSnapshot) {
|
||||
return (
|
||||
authStateGeneration === snapshot.generation &&
|
||||
getStoredAccessToken() === snapshot.accessToken
|
||||
);
|
||||
}
|
||||
|
||||
function publishRefreshedAccessToken(
|
||||
nextToken: string,
|
||||
snapshot: AuthStateSnapshot,
|
||||
) {
|
||||
if (!isCurrentAuthState(snapshot)) {
|
||||
throw new AuthStateChangedDuringRefreshError();
|
||||
}
|
||||
|
||||
// refresh 只轮换同一账号的 access token,不推进账号代际。
|
||||
// 外部登录、切号或退出通过公开 setter 推进代际,使旧 refresh 发布失效。
|
||||
writeStoredAccessToken(nextToken, { emit: false });
|
||||
}
|
||||
|
||||
function shouldClearAuthAfterRefreshFailure(error: unknown) {
|
||||
return (
|
||||
@@ -697,11 +766,16 @@ function shouldClearAuthAfterRefreshFailure(error: unknown) {
|
||||
}
|
||||
|
||||
async function refreshAccessToken() {
|
||||
if (refreshAccessTokenPromise) {
|
||||
return refreshAccessTokenPromise;
|
||||
const authStateSnapshot = captureAuthStateSnapshot();
|
||||
if (
|
||||
refreshAccessTokenAttempt &&
|
||||
refreshAccessTokenAttempt.generation === authStateSnapshot.generation &&
|
||||
refreshAccessTokenAttempt.accessToken === authStateSnapshot.accessToken
|
||||
) {
|
||||
return refreshAccessTokenAttempt.promise;
|
||||
}
|
||||
|
||||
refreshAccessTokenPromise = (async () => {
|
||||
const promise = (async () => {
|
||||
const response = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
@@ -726,14 +800,21 @@ async function refreshAccessToken() {
|
||||
throw new Error('刷新登录状态失败');
|
||||
}
|
||||
|
||||
setStoredAccessToken(nextToken, { emit: false });
|
||||
publishRefreshedAccessToken(nextToken, authStateSnapshot);
|
||||
return nextToken;
|
||||
})();
|
||||
const attempt: RefreshAccessTokenAttempt = {
|
||||
...authStateSnapshot,
|
||||
promise,
|
||||
};
|
||||
refreshAccessTokenAttempt = attempt;
|
||||
|
||||
try {
|
||||
return await refreshAccessTokenPromise;
|
||||
return await promise;
|
||||
} finally {
|
||||
refreshAccessTokenPromise = null;
|
||||
if (refreshAccessTokenAttempt === attempt) {
|
||||
refreshAccessTokenAttempt = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -752,12 +833,14 @@ export async function refreshStoredAccessToken(
|
||||
clearOnFailure?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const authStateSnapshot = captureAuthStateSnapshot();
|
||||
try {
|
||||
return await refreshAccessToken();
|
||||
} catch (error) {
|
||||
if (
|
||||
options.clearOnFailure !== false &&
|
||||
shouldClearAuthAfterRefreshFailure(error)
|
||||
shouldClearAuthAfterRefreshFailure(error) &&
|
||||
isCurrentAuthState(authStateSnapshot)
|
||||
) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
}
|
||||
@@ -774,7 +857,10 @@ export async function fetchWithApiAuth(
|
||||
const retry = resolveRetryOptions(method, options.retry);
|
||||
const authFailurePolicy = resolveAuthFailurePolicy(options);
|
||||
const requestSignal = init.signal ?? undefined;
|
||||
const requestId = resolveRequestIdHeader(normalizeHeaders(init.headers), options);
|
||||
const requestId = resolveRequestIdHeader(
|
||||
normalizeHeaders(init.headers),
|
||||
options,
|
||||
);
|
||||
let attempt = 0;
|
||||
let refreshAttempted = false;
|
||||
|
||||
@@ -784,7 +870,7 @@ export async function fetchWithApiAuth(
|
||||
requestHeaders[REQUEST_ID_HEADER] = requestId;
|
||||
let hasAuthHeader = Boolean(
|
||||
requestHeaders.Authorization?.trim() ||
|
||||
requestHeaders.authorization?.trim(),
|
||||
requestHeaders.authorization?.trim(),
|
||||
);
|
||||
|
||||
if (
|
||||
@@ -800,7 +886,7 @@ export async function fetchWithApiAuth(
|
||||
requestHeaders[REQUEST_ID_HEADER] = requestId;
|
||||
hasAuthHeader = Boolean(
|
||||
requestHeaders.Authorization?.trim() ||
|
||||
requestHeaders.authorization?.trim(),
|
||||
requestHeaders.authorization?.trim(),
|
||||
);
|
||||
} catch (error) {
|
||||
if (requestSignal?.aborted) {
|
||||
@@ -830,6 +916,7 @@ export async function fetchWithApiAuth(
|
||||
!authFailurePolicy.skipRefresh &&
|
||||
!refreshAttempted
|
||||
) {
|
||||
const refreshAuthStateSnapshot = captureAuthStateSnapshot();
|
||||
try {
|
||||
await awaitWithAbortSignal(refreshAccessToken(), requestSignal);
|
||||
refreshAttempted = true;
|
||||
@@ -844,7 +931,8 @@ export async function fetchWithApiAuth(
|
||||
const shouldClearAuth =
|
||||
hasAuthHeader &&
|
||||
authFailurePolicy.clearAuthOnUnauthorized &&
|
||||
shouldClearAuthAfterRefreshFailure(refreshError);
|
||||
shouldClearAuthAfterRefreshFailure(refreshError) &&
|
||||
isCurrentAuthState(refreshAuthStateSnapshot);
|
||||
if (shouldClearAuth) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
}
|
||||
@@ -897,7 +985,9 @@ async function buildApiClientError(
|
||||
const baseMessage = parseApiErrorMessage(responseText, fallbackMessage);
|
||||
|
||||
return new ApiClientError({
|
||||
message: requestId ? `${baseMessage}(requestId: ${requestId})` : baseMessage,
|
||||
message: requestId
|
||||
? `${baseMessage}(requestId: ${requestId})`
|
||||
: baseMessage,
|
||||
status: response.status,
|
||||
code: parsedError?.code ?? `HTTP_${response.status || 0}`,
|
||||
details: parsedError?.details ?? null,
|
||||
|
||||
Reference in New Issue
Block a user