This commit is contained in:
662
src/services/apiClient.ts
Normal file
662
src/services/apiClient.ts
Normal file
@@ -0,0 +1,662 @@
|
||||
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 ApiRequestOptions = {
|
||||
retry?: ApiRetryOptions;
|
||||
timeoutMs?: number;
|
||||
skipAuth?: boolean;
|
||||
omitEnvelopeHeader?: boolean;
|
||||
skipRefresh?: boolean;
|
||||
// 会话探测类请求需要静默处理 401,避免 AuthGate 因自发广播再次触发 hydrate。
|
||||
notifyAuthStateChange?: boolean;
|
||||
};
|
||||
|
||||
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>;
|
||||
};
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
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()) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
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 fetchWithApiAuth(
|
||||
input: string,
|
||||
init: RequestInit = {},
|
||||
options: ApiRequestOptions = {},
|
||||
) {
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const retry = resolveRetryOptions(method, options.retry);
|
||||
const shouldNotifyAuthStateChange = options.notifyAuthStateChange !== false;
|
||||
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 && !options.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 &&
|
||||
!options.skipRefresh &&
|
||||
!refreshAttempted
|
||||
) {
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
refreshAttempted = true;
|
||||
// refresh 成功只代表 access token 已补票成功,
|
||||
// 不能把当前业务请求的首次 401 直接放大成全局鉴权变更,
|
||||
// 否则像 Puzzle works 这类受保护列表会把单接口失败放大成整个平台重复 hydrate。
|
||||
continue;
|
||||
} catch {
|
||||
if (hasAuthHeader) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
}
|
||||
if (shouldNotifyAuthStateChange) {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
response.status === 401 &&
|
||||
hasAuthHeader &&
|
||||
!options.skipAuth &&
|
||||
!refreshAttempted
|
||||
) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
if (shouldNotifyAuthStateChange) {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user