713 lines
19 KiB
TypeScript
713 lines
19 KiB
TypeScript
import type { AuthRefreshResponse } from '../../packages/shared/src/contracts/auth';
|
||
import {
|
||
API_RESPONSE_ENVELOPE_HEADER,
|
||
API_RESPONSE_ENVELOPE_VERSION,
|
||
API_VERSION,
|
||
type ApiErrorPayload,
|
||
type ApiMeta,
|
||
parseApiErrorMessage,
|
||
unwrapApiResponse,
|
||
} from '../../packages/shared/src/http';
|
||
|
||
const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1';
|
||
export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed';
|
||
const REQUEST_ID_HEADER = 'x-request-id';
|
||
const API_VERSION_HEADER = 'x-api-version';
|
||
const ROUTE_VERSION_HEADER = 'x-route-version';
|
||
const DEFAULT_RETRYABLE_STATUS_CODES = [408, 425, 429, 502, 503, 504];
|
||
const DEFAULT_SAFE_RETRY_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||
|
||
export type ApiRetryOptions = {
|
||
maxRetries?: number;
|
||
baseDelayMs?: number;
|
||
maxDelayMs?: number;
|
||
retryableStatusCodes?: number[];
|
||
retryUnsafeMethods?: boolean;
|
||
allowRetryMethods?: string[];
|
||
};
|
||
|
||
export type ApiAuthImpact = 'global' | 'local';
|
||
|
||
export type ApiRequestOptions = {
|
||
retry?: ApiRetryOptions;
|
||
timeoutMs?: number;
|
||
skipAuth?: boolean;
|
||
omitEnvelopeHeader?: boolean;
|
||
skipRefresh?: boolean;
|
||
// global:请求失败可影响整站登录态;local:失败只属于当前卡片、图片或运行态。
|
||
authImpact?: ApiAuthImpact;
|
||
// 会话探测类请求需要静默处理 401,避免 AuthGate 因自发广播再次触发 hydrate。
|
||
notifyAuthStateChange?: boolean;
|
||
// 推荐页自动加载作品这类局部后台请求失败时,只应让当前卡片报错,不应清空全局登录态。
|
||
clearAuthOnUnauthorized?: boolean;
|
||
};
|
||
|
||
export const BACKGROUND_AUTH_REQUEST_OPTIONS = {
|
||
authImpact: 'local',
|
||
skipRefresh: true,
|
||
notifyAuthStateChange: false,
|
||
clearAuthOnUnauthorized: false,
|
||
} satisfies ApiRequestOptions;
|
||
|
||
type ResolvedRetryOptions = {
|
||
maxRetries: number;
|
||
baseDelayMs: number;
|
||
maxDelayMs: number;
|
||
retryableStatusCodes: Set<number>;
|
||
retryUnsafeMethods: boolean;
|
||
allowRetryMethods: Set<string>;
|
||
method: string;
|
||
};
|
||
|
||
type ParsedApiErrorShape = {
|
||
code: string;
|
||
details: Record<string, unknown> | null;
|
||
meta: Partial<ApiMeta>;
|
||
};
|
||
|
||
type ResolvedAuthFailurePolicy = {
|
||
skipRefresh: boolean;
|
||
notifyAuthStateChange: boolean;
|
||
clearAuthOnUnauthorized: boolean;
|
||
};
|
||
|
||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||
return typeof value === 'object' && value !== null;
|
||
}
|
||
|
||
function normalizeHeaders(headers?: HeadersInit) {
|
||
const nextHeaders: Record<string, string> = {};
|
||
|
||
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
|
||
headers.forEach((value, key) => {
|
||
nextHeaders[key] = value;
|
||
});
|
||
return nextHeaders;
|
||
}
|
||
|
||
if (Array.isArray(headers)) {
|
||
for (const [key, value] of headers) {
|
||
nextHeaders[key] = value;
|
||
}
|
||
return nextHeaders;
|
||
}
|
||
|
||
if (headers) {
|
||
Object.assign(nextHeaders, headers);
|
||
}
|
||
|
||
return nextHeaders;
|
||
}
|
||
|
||
function coerceMeta(value: unknown): Partial<ApiMeta> {
|
||
if (!isRecord(value)) {
|
||
return {};
|
||
}
|
||
|
||
return {
|
||
apiVersion:
|
||
typeof value.apiVersion === 'string' && value.apiVersion.trim()
|
||
? value.apiVersion.trim()
|
||
: undefined,
|
||
requestId:
|
||
typeof value.requestId === 'string' && value.requestId.trim()
|
||
? value.requestId.trim()
|
||
: undefined,
|
||
routeVersion:
|
||
typeof value.routeVersion === 'string' && value.routeVersion.trim()
|
||
? value.routeVersion.trim()
|
||
: undefined,
|
||
operation:
|
||
typeof value.operation === 'string' && value.operation.trim()
|
||
? value.operation.trim()
|
||
: value.operation === null
|
||
? null
|
||
: undefined,
|
||
latencyMs:
|
||
typeof value.latencyMs === 'number' && Number.isFinite(value.latencyMs)
|
||
? value.latencyMs
|
||
: undefined,
|
||
timestamp:
|
||
typeof value.timestamp === 'string' && value.timestamp.trim()
|
||
? value.timestamp.trim()
|
||
: undefined,
|
||
};
|
||
}
|
||
|
||
function parseApiErrorShape(rawText: string): ParsedApiErrorShape | null {
|
||
if (!rawText.trim()) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(rawText) as
|
||
| {
|
||
error?: ApiErrorPayload;
|
||
meta?: Partial<ApiMeta>;
|
||
code?: string;
|
||
details?: Record<string, unknown> | null;
|
||
}
|
||
| Record<string, unknown>;
|
||
|
||
if (isRecord(parsed.error)) {
|
||
return {
|
||
code:
|
||
typeof parsed.error.code === 'string' && parsed.error.code.trim()
|
||
? parsed.error.code.trim()
|
||
: 'HTTP_ERROR',
|
||
details:
|
||
isRecord(parsed.error.details) || parsed.error.details === null
|
||
? (parsed.error.details as Record<string, unknown> | null)
|
||
: null,
|
||
meta: coerceMeta(parsed.meta),
|
||
};
|
||
}
|
||
|
||
if (typeof parsed.code === 'string' && parsed.code.trim()) {
|
||
return {
|
||
code: parsed.code.trim(),
|
||
details:
|
||
isRecord(parsed.details) || parsed.details === null
|
||
? (parsed.details as Record<string, unknown> | null)
|
||
: null,
|
||
meta: coerceMeta(parsed.meta),
|
||
};
|
||
}
|
||
} catch {
|
||
// Ignore malformed json responses.
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function createAbortError() {
|
||
if (typeof DOMException !== 'undefined') {
|
||
return new DOMException('The operation was aborted.', 'AbortError');
|
||
}
|
||
|
||
const error = new Error('The operation was aborted.');
|
||
error.name = 'AbortError';
|
||
return error;
|
||
}
|
||
|
||
function createTimeoutError(timeoutMs: number) {
|
||
const error = new Error(`请求超时:${timeoutMs}ms`);
|
||
error.name = 'TimeoutError';
|
||
return error;
|
||
}
|
||
|
||
function composeAbortSignal(
|
||
signal: AbortSignal | undefined,
|
||
timeoutMs: number | undefined,
|
||
) {
|
||
const shouldUseTimeout =
|
||
typeof timeoutMs === 'number' &&
|
||
Number.isFinite(timeoutMs) &&
|
||
timeoutMs > 0;
|
||
|
||
if (!shouldUseTimeout) {
|
||
return {
|
||
signal,
|
||
cleanup: () => {},
|
||
};
|
||
}
|
||
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => {
|
||
controller.abort(createTimeoutError(timeoutMs));
|
||
}, timeoutMs);
|
||
|
||
const cleanup = () => {
|
||
clearTimeout(timeoutId);
|
||
signal?.removeEventListener('abort', onAbort);
|
||
};
|
||
|
||
const onAbort = () => {
|
||
controller.abort(signal?.reason ?? createAbortError());
|
||
};
|
||
|
||
if (signal?.aborted) {
|
||
cleanup();
|
||
controller.abort(signal.reason ?? createAbortError());
|
||
return {
|
||
signal: controller.signal,
|
||
cleanup,
|
||
};
|
||
}
|
||
|
||
signal?.addEventListener('abort', onAbort, { once: true });
|
||
|
||
return {
|
||
signal: controller.signal,
|
||
cleanup,
|
||
};
|
||
}
|
||
|
||
async function waitForRetry(ms: number, signal?: AbortSignal) {
|
||
if (ms <= 0) {
|
||
return;
|
||
}
|
||
|
||
await new Promise<void>((resolve, reject) => {
|
||
const timeoutId = setTimeout(() => {
|
||
cleanup();
|
||
resolve();
|
||
}, ms);
|
||
|
||
const onAbort = () => {
|
||
cleanup();
|
||
reject(signal?.reason ?? createAbortError());
|
||
};
|
||
|
||
const cleanup = () => {
|
||
clearTimeout(timeoutId);
|
||
signal?.removeEventListener('abort', onAbort);
|
||
};
|
||
|
||
if (signal?.aborted) {
|
||
cleanup();
|
||
reject(signal.reason ?? createAbortError());
|
||
return;
|
||
}
|
||
|
||
signal?.addEventListener('abort', onAbort, { once: true });
|
||
});
|
||
}
|
||
|
||
function resolveRetryOptions(
|
||
method: string,
|
||
retry?: ApiRetryOptions,
|
||
): ResolvedRetryOptions {
|
||
const normalizedMethod = method.toUpperCase();
|
||
const defaultMaxRetries = DEFAULT_SAFE_RETRY_METHODS.has(normalizedMethod)
|
||
? 1
|
||
: 0;
|
||
|
||
return {
|
||
maxRetries:
|
||
typeof retry?.maxRetries === 'number' && retry.maxRetries >= 0
|
||
? Math.floor(retry.maxRetries)
|
||
: defaultMaxRetries,
|
||
baseDelayMs:
|
||
typeof retry?.baseDelayMs === 'number' && retry.baseDelayMs > 0
|
||
? retry.baseDelayMs
|
||
: 250,
|
||
maxDelayMs:
|
||
typeof retry?.maxDelayMs === 'number' && retry.maxDelayMs > 0
|
||
? retry.maxDelayMs
|
||
: 1500,
|
||
retryableStatusCodes: new Set(
|
||
retry?.retryableStatusCodes?.length
|
||
? retry.retryableStatusCodes
|
||
: DEFAULT_RETRYABLE_STATUS_CODES,
|
||
),
|
||
retryUnsafeMethods: retry?.retryUnsafeMethods === true,
|
||
allowRetryMethods: new Set(
|
||
(retry?.allowRetryMethods ?? []).map((value) => value.toUpperCase()),
|
||
),
|
||
method: normalizedMethod,
|
||
};
|
||
}
|
||
|
||
function shouldRetryResponse(
|
||
status: number,
|
||
attempt: number,
|
||
retry: ResolvedRetryOptions,
|
||
) {
|
||
if (attempt >= retry.maxRetries) {
|
||
return false;
|
||
}
|
||
|
||
if (!retry.retryableStatusCodes.has(status)) {
|
||
return false;
|
||
}
|
||
|
||
return (
|
||
retry.retryUnsafeMethods ||
|
||
DEFAULT_SAFE_RETRY_METHODS.has(retry.method) ||
|
||
retry.allowRetryMethods.has(retry.method)
|
||
);
|
||
}
|
||
|
||
export function isAbortError(error: unknown) {
|
||
return (
|
||
error instanceof Error &&
|
||
(error.name === 'AbortError' ||
|
||
(typeof DOMException !== 'undefined' &&
|
||
error instanceof DOMException &&
|
||
error.name === 'AbortError'))
|
||
);
|
||
}
|
||
|
||
export function isTimeoutError(error: unknown) {
|
||
return error instanceof Error && error.name === 'TimeoutError';
|
||
}
|
||
|
||
function shouldRetryError(
|
||
error: unknown,
|
||
attempt: number,
|
||
retry: ResolvedRetryOptions,
|
||
) {
|
||
if (attempt >= retry.maxRetries || isAbortError(error)) {
|
||
return false;
|
||
}
|
||
|
||
return error instanceof TypeError;
|
||
}
|
||
|
||
function buildRetryDelayMs(attempt: number, retry: ResolvedRetryOptions) {
|
||
return Math.min(retry.maxDelayMs, retry.baseDelayMs * Math.max(1, attempt));
|
||
}
|
||
|
||
function resolveAuthFailurePolicy(
|
||
options: ApiRequestOptions,
|
||
): ResolvedAuthFailurePolicy {
|
||
const isLocalAuthImpact = options.authImpact === 'local';
|
||
|
||
return {
|
||
// 局部后台请求可以携带已有 token,但不能主动 refresh;
|
||
// 否则 refresh 失败会把一次卡片/图片/运行态失败放大成全局掉线。
|
||
skipRefresh: isLocalAuthImpact || options.skipRefresh === true,
|
||
notifyAuthStateChange: isLocalAuthImpact
|
||
? false
|
||
: options.notifyAuthStateChange !== false,
|
||
clearAuthOnUnauthorized: isLocalAuthImpact
|
||
? false
|
||
: options.clearAuthOnUnauthorized !== false,
|
||
};
|
||
}
|
||
|
||
export class ApiClientError extends Error {
|
||
status: number;
|
||
code: string;
|
||
details: Record<string, unknown> | null;
|
||
meta: ApiMeta;
|
||
responseText: string;
|
||
|
||
constructor(params: {
|
||
message: string;
|
||
status: number;
|
||
code: string;
|
||
details?: Record<string, unknown> | null;
|
||
meta?: Partial<ApiMeta>;
|
||
responseText?: string;
|
||
}) {
|
||
super(params.message);
|
||
this.name = 'ApiClientError';
|
||
this.status = params.status;
|
||
this.code = params.code;
|
||
this.details = params.details ?? null;
|
||
this.meta = {
|
||
apiVersion: params.meta?.apiVersion ?? API_VERSION,
|
||
requestId: params.meta?.requestId,
|
||
routeVersion: params.meta?.routeVersion,
|
||
operation: params.meta?.operation,
|
||
latencyMs: params.meta?.latencyMs,
|
||
timestamp: params.meta?.timestamp,
|
||
};
|
||
this.responseText = params.responseText ?? '';
|
||
}
|
||
}
|
||
|
||
function canUseLocalStorage() {
|
||
return (
|
||
typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
|
||
);
|
||
}
|
||
|
||
export function emitAuthStateChange() {
|
||
if (typeof window === 'undefined') {
|
||
return;
|
||
}
|
||
|
||
if (typeof CustomEvent === 'function') {
|
||
window.dispatchEvent(new CustomEvent(AUTH_STATE_EVENT));
|
||
return;
|
||
}
|
||
|
||
if (typeof Event === 'function') {
|
||
window.dispatchEvent(new Event(AUTH_STATE_EVENT));
|
||
}
|
||
}
|
||
|
||
export function getStoredAccessToken() {
|
||
if (!canUseLocalStorage()) {
|
||
return '';
|
||
}
|
||
|
||
return window.localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() || '';
|
||
}
|
||
|
||
export function setStoredAccessToken(
|
||
token: string,
|
||
options: {
|
||
emit?: boolean;
|
||
} = {},
|
||
) {
|
||
if (!canUseLocalStorage()) {
|
||
return;
|
||
}
|
||
|
||
const nextToken = token.trim();
|
||
const previousToken = getStoredAccessToken();
|
||
if (nextToken) {
|
||
window.localStorage.setItem(ACCESS_TOKEN_KEY, nextToken);
|
||
} else {
|
||
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||
}
|
||
|
||
// 只有登录态令牌真的发生变化时才广播事件,避免无意义的鉴权重算。
|
||
if (options.emit !== false && previousToken !== nextToken) {
|
||
emitAuthStateChange();
|
||
}
|
||
}
|
||
|
||
export function clearStoredAccessToken(
|
||
options: {
|
||
emit?: boolean;
|
||
} = {},
|
||
) {
|
||
if (!canUseLocalStorage()) {
|
||
return;
|
||
}
|
||
|
||
const previousToken = getStoredAccessToken();
|
||
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||
|
||
// 未登录态下重复清空 token 不应触发状态刷新,否则会放大 401 循环。
|
||
if (options.emit !== false && previousToken) {
|
||
emitAuthStateChange();
|
||
}
|
||
}
|
||
|
||
function withAuthorizationHeaders(
|
||
headers?: HeadersInit,
|
||
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader' | 'skipAuth'> = {},
|
||
) {
|
||
const nextHeaders = normalizeHeaders(headers);
|
||
const token = getStoredAccessToken();
|
||
if (token && !options.skipAuth) {
|
||
nextHeaders.Authorization = `Bearer ${token}`;
|
||
}
|
||
if (!options.omitEnvelopeHeader) {
|
||
nextHeaders[API_RESPONSE_ENVELOPE_HEADER] = API_RESPONSE_ENVELOPE_VERSION;
|
||
}
|
||
return nextHeaders;
|
||
}
|
||
|
||
let refreshAccessTokenPromise: Promise<string> | null = null;
|
||
|
||
async function refreshAccessToken() {
|
||
if (refreshAccessTokenPromise) {
|
||
return refreshAccessTokenPromise;
|
||
}
|
||
|
||
refreshAccessTokenPromise = (async () => {
|
||
const response = await fetch('/api/auth/refresh', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: {
|
||
[API_RESPONSE_ENVELOPE_HEADER]: API_RESPONSE_ENVELOPE_VERSION,
|
||
},
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw await buildApiClientError(response, '刷新登录状态失败');
|
||
}
|
||
|
||
const responseText = await response.text();
|
||
const payload = responseText
|
||
? unwrapApiResponse<AuthRefreshResponse>(
|
||
JSON.parse(responseText) as AuthRefreshResponse,
|
||
)
|
||
: null;
|
||
|
||
if (payload?.ok !== true || !payload.token?.trim()) {
|
||
throw new Error('刷新登录状态失败');
|
||
}
|
||
|
||
const nextToken = payload.token.trim();
|
||
setStoredAccessToken(nextToken, { emit: false });
|
||
return nextToken;
|
||
})();
|
||
|
||
try {
|
||
return await refreshAccessTokenPromise;
|
||
} finally {
|
||
refreshAccessTokenPromise = null;
|
||
}
|
||
}
|
||
|
||
export async function ensureStoredAccessToken() {
|
||
const currentToken = getStoredAccessToken();
|
||
if (currentToken) {
|
||
return currentToken;
|
||
}
|
||
|
||
// AuthGate 恢复会话时可能只有 HttpOnly refresh cookie,本地尚无 access token。
|
||
return refreshAccessToken();
|
||
}
|
||
|
||
export async function refreshStoredAccessToken() {
|
||
try {
|
||
return await refreshAccessToken();
|
||
} catch (error) {
|
||
clearStoredAccessToken({ emit: false });
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
export async function fetchWithApiAuth(
|
||
input: string,
|
||
init: RequestInit = {},
|
||
options: ApiRequestOptions = {},
|
||
) {
|
||
const method = (init.method ?? 'GET').toUpperCase();
|
||
const retry = resolveRetryOptions(method, options.retry);
|
||
const authFailurePolicy = resolveAuthFailurePolicy(options);
|
||
const requestSignal = init.signal ?? undefined;
|
||
let attempt = 0;
|
||
let refreshAttempted = false;
|
||
|
||
for (;;) {
|
||
try {
|
||
let requestHeaders = withAuthorizationHeaders(init.headers, options);
|
||
let hasAuthHeader = Boolean(
|
||
requestHeaders.Authorization?.trim() ||
|
||
requestHeaders.authorization?.trim(),
|
||
);
|
||
|
||
if (
|
||
!hasAuthHeader &&
|
||
!options.skipAuth &&
|
||
!authFailurePolicy.skipRefresh
|
||
) {
|
||
try {
|
||
// 受保护请求在本地 access token 缺失时,先尝试用 refresh cookie 静默补票,
|
||
// 避免把后端原始 “缺少 Bearer Token” 直接暴露给业务 UI。
|
||
await ensureStoredAccessToken();
|
||
requestHeaders = withAuthorizationHeaders(init.headers, options);
|
||
hasAuthHeader = Boolean(
|
||
requestHeaders.Authorization?.trim() ||
|
||
requestHeaders.authorization?.trim(),
|
||
);
|
||
} catch {
|
||
// 补票失败时继续走原始请求,让调用方按真实 401 分支处理。
|
||
}
|
||
}
|
||
|
||
const timedRequest = composeAbortSignal(requestSignal, options.timeoutMs);
|
||
let response: Response;
|
||
try {
|
||
response = await fetch(input, {
|
||
credentials: 'same-origin',
|
||
...init,
|
||
signal: timedRequest.signal,
|
||
headers: requestHeaders,
|
||
});
|
||
} finally {
|
||
timedRequest.cleanup();
|
||
}
|
||
|
||
if (
|
||
response.status === 401 &&
|
||
hasAuthHeader &&
|
||
!options.skipAuth &&
|
||
!authFailurePolicy.skipRefresh &&
|
||
!refreshAttempted
|
||
) {
|
||
try {
|
||
await refreshAccessToken();
|
||
refreshAttempted = true;
|
||
// refresh 成功只代表 access token 已补票成功,
|
||
// 不能把当前业务请求的首次 401 直接放大成全局鉴权变更,
|
||
// 否则像 Puzzle works 这类受保护列表会把单接口失败放大成整个平台重复 hydrate。
|
||
continue;
|
||
} catch {
|
||
if (hasAuthHeader && authFailurePolicy.clearAuthOnUnauthorized) {
|
||
clearStoredAccessToken({ emit: false });
|
||
}
|
||
if (authFailurePolicy.notifyAuthStateChange) {
|
||
emitAuthStateChange();
|
||
}
|
||
}
|
||
} else if (
|
||
response.status === 401 &&
|
||
hasAuthHeader &&
|
||
!options.skipAuth &&
|
||
!refreshAttempted
|
||
) {
|
||
if (authFailurePolicy.clearAuthOnUnauthorized) {
|
||
clearStoredAccessToken({ emit: false });
|
||
}
|
||
if (authFailurePolicy.notifyAuthStateChange) {
|
||
emitAuthStateChange();
|
||
}
|
||
}
|
||
|
||
if (!shouldRetryResponse(response.status, attempt, retry)) {
|
||
return response;
|
||
}
|
||
} catch (error) {
|
||
if (!shouldRetryError(error, attempt, retry)) {
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
attempt += 1;
|
||
await waitForRetry(buildRetryDelayMs(attempt, retry), requestSignal);
|
||
}
|
||
}
|
||
|
||
async function buildApiClientError(
|
||
response: Response,
|
||
fallbackMessage: string,
|
||
) {
|
||
const responseText = await response.text();
|
||
const parsedError = parseApiErrorShape(responseText);
|
||
|
||
return new ApiClientError({
|
||
message: parseApiErrorMessage(responseText, fallbackMessage),
|
||
status: response.status,
|
||
code: parsedError?.code ?? `HTTP_${response.status || 0}`,
|
||
details: parsedError?.details ?? null,
|
||
meta: {
|
||
apiVersion:
|
||
parsedError?.meta.apiVersion ??
|
||
response.headers.get(API_VERSION_HEADER) ??
|
||
API_VERSION,
|
||
requestId:
|
||
parsedError?.meta.requestId ??
|
||
response.headers.get(REQUEST_ID_HEADER) ??
|
||
undefined,
|
||
routeVersion:
|
||
parsedError?.meta.routeVersion ??
|
||
response.headers.get(ROUTE_VERSION_HEADER) ??
|
||
undefined,
|
||
operation: parsedError?.meta.operation,
|
||
latencyMs: parsedError?.meta.latencyMs,
|
||
timestamp: parsedError?.meta.timestamp,
|
||
},
|
||
responseText,
|
||
});
|
||
}
|
||
|
||
export async function requestJson<T>(
|
||
url: string,
|
||
init: RequestInit,
|
||
fallbackMessage: string,
|
||
options: ApiRequestOptions = {},
|
||
): Promise<T> {
|
||
const response = await fetchWithApiAuth(url, init, options);
|
||
|
||
if (!response.ok) {
|
||
throw await buildApiClientError(response, fallbackMessage);
|
||
}
|
||
|
||
const responseText = await response.text();
|
||
|
||
return responseText
|
||
? unwrapApiResponse<T>(JSON.parse(responseText) as T)
|
||
: (null as T);
|
||
}
|