@better-auth-ui/core
Version:
Authentication components and data utilities for [Better Auth](https://better-auth.com), available for React and Solid.
166 lines (134 loc) • 4.2 kB
text/typescript
import type {
BetterFetchError,
BetterFetchOption,
BetterFetchPlugin
} from "better-auth/client"
const DEFAULT_AUTH_QUERY_RETRY_COUNT = 3
const MAX_AUTH_QUERY_RETRY_DELAY = 30_000
const RETRYABLE_AUTH_QUERY_STATUS_CODES = new Set([
408, 429, 500, 502, 503, 504
])
export type AuthQueryError = BetterFetchError & {
retryAfterMs?: number
}
export const REAUTHENTICATION_REQUIRED_ERROR_CODES = new Set([
"SESSION_NOT_FRESH",
"SESSION_EXPIRED"
])
/** Return whether Better Auth requires the user to authenticate again. */
export function isReauthenticationRequiredError(error: unknown) {
if (typeof error !== "object" || error === null) return false
const code = (error as { code?: unknown }).code
if (
typeof code === "string" &&
REAUTHENTICATION_REQUIRED_ERROR_CODES.has(code)
) {
return true
}
const errorBody = (error as { error?: unknown }).error
if (typeof errorBody !== "object" || errorBody === null) return false
const nestedCode = (errorBody as { code?: unknown }).code
return (
typeof nestedCode === "string" &&
REAUTHENTICATION_REQUIRED_ERROR_CODES.has(nestedCode)
)
}
class AuthQueryResponseError extends Error implements AuthQueryError {
constructor(
readonly status: number,
readonly statusText: string,
readonly error: unknown,
readonly retryAfterMs: number
) {
super(statusText || String(status), { cause: error })
}
}
function getNumericProperty(
value: unknown,
property: "retryAfterMs" | "status"
) {
if (typeof value !== "object" || value === null) {
return undefined
}
const propertyValue = (value as Record<PropertyKey, unknown>)[property]
return typeof propertyValue === "number" ? propertyValue : undefined
}
function parseRetryAfter(value: string) {
const seconds = Number(value)
if (Number.isFinite(seconds) && seconds >= 0) {
return seconds * 1000
}
const date = Date.parse(value)
if (Number.isNaN(date)) {
return undefined
}
return Math.max(0, date - Date.now())
}
function getRetryAfter(response: Response) {
const retryAfter = response.headers.get("Retry-After")
const parsedRetryAfter =
retryAfter === null ? undefined : parseRetryAfter(retryAfter)
if (parsedRetryAfter !== undefined) {
return parsedRetryAfter
}
const legacyRetryAfter = response.headers.get("X-Retry-After")
return legacyRetryAfter === null
? undefined
: parseRetryAfter(legacyRetryAfter)
}
const authQueryRetryPlugin = {
id: "better-auth-ui-query-retry",
name: "Better Auth UI query retry",
hooks: {
onError: async (context) => {
const retryAfterMs = getRetryAfter(context.response)
if (retryAfterMs !== undefined) {
throw new AuthQueryResponseError(
context.response.status,
context.response.statusText,
context.error,
retryAfterMs
)
}
}
}
} satisfies BetterFetchPlugin
export function createAuthQueryFetchOptions(
fetchOptions: BetterFetchOption | undefined,
signal: AbortSignal
): BetterFetchOption & { throw: true } {
return {
...fetchOptions,
signal,
throw: true,
plugins: [...(fetchOptions?.plugins ?? []), authQueryRetryPlugin]
}
}
export function isRetryableAuthQueryError(error: unknown) {
const status = getNumericProperty(error, "status")
return status === undefined || RETRYABLE_AUTH_QUERY_STATUS_CODES.has(status)
}
export function shouldRetryAuthQuery(failureCount: number, error: unknown) {
return (
failureCount < DEFAULT_AUTH_QUERY_RETRY_COUNT &&
isRetryableAuthQueryError(error)
)
}
export function authQueryRetryDelay(failureCount: number, error: unknown) {
const retryAfterMs = getNumericProperty(error, "retryAfterMs")
if (
retryAfterMs !== undefined &&
Number.isFinite(retryAfterMs) &&
retryAfterMs >= 0
) {
return retryAfterMs
}
return Math.min(1000 * 2 ** failureCount, MAX_AUTH_QUERY_RETRY_DELAY)
}
export function createAuthQueryRetryOptions(isServer: () => boolean) {
return {
retry: (failureCount: number, error: unknown) =>
!isServer() && shouldRetryAuthQuery(failureCount, error),
retryDelay: authQueryRetryDelay
} as const
}