fix: improve error handling across various components; utilize getAuthErrorMessage for consistent user-facing error messages

This commit is contained in:
Usman Baig
2026-02-03 19:31:26 +01:00
parent af5d9631e5
commit eaf02c853f
12 changed files with 199 additions and 92 deletions

View File

@@ -2,6 +2,11 @@
* HTTP client wrapper for API calls
*/
import { authMessageFromStatus, AUTH_ERROR_MESSAGES } from '@/lib/utils/authErrors'
/** Request timeout in ms; network errors surface as user-facing "Network error, please try again." */
const FETCH_TIMEOUT_MS = 30_000
export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8082'
export const AUTH_URL = process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:3000'
export const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3003'
@@ -73,11 +78,26 @@ async function apiRequest<T>(
// * We rely on HttpOnly cookies, so no manual Authorization header injection.
// * We MUST set credentials: 'include' for the browser to send cookies cross-origin (or same-site).
const response = await fetch(url, {
...options,
headers,
credentials: 'include', // * IMPORTANT: Send cookies
})
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
const signal = options.signal ?? controller.signal
let response: Response
try {
response = await fetch(url, {
...options,
headers,
credentials: 'include', // * IMPORTANT: Send cookies
signal,
})
clearTimeout(timeoutId)
} catch (e) {
clearTimeout(timeoutId)
if (e instanceof Error && (e.name === 'AbortError' || e.name === 'TypeError')) {
throw new ApiError(AUTH_ERROR_MESSAGES.NETWORK, 0)
}
throw e
}
if (!response.ok) {
if (response.status === 401) {
@@ -99,7 +119,7 @@ async function apiRequest<T>(
if (retryResponse.ok) {
resolve(await retryResponse.json())
} else {
reject(new ApiError('Retry failed', retryResponse.status))
reject(new ApiError(authMessageFromStatus(retryResponse.status), retryResponse.status))
}
} catch (e) {
reject(e)
@@ -135,12 +155,16 @@ async function apiRequest<T>(
return retryResponse.json()
}
} else {
onRefreshFailed(new ApiError('Refresh failed', 401))
const sessionExpiredMsg = authMessageFromStatus(401)
onRefreshFailed(new ApiError(sessionExpiredMsg, 401))
localStorage.removeItem('user')
}
} catch (e) {
onRefreshFailed(e)
throw e
const err = e instanceof Error && (e.name === 'AbortError' || e.name === 'TypeError')
? new ApiError(AUTH_ERROR_MESSAGES.NETWORK, 0)
: e
onRefreshFailed(err)
throw err
} finally {
isRefreshing = false
}
@@ -148,11 +172,9 @@ async function apiRequest<T>(
}
}
const errorBody = await response.json().catch(() => ({
error: 'Unknown error',
message: `HTTP ${response.status}: ${response.statusText}`,
}))
throw new ApiError(errorBody.message || errorBody.error || 'Request failed', response.status, errorBody)
const errorBody = await response.json().catch(() => ({}))
const message = authMessageFromStatus(response.status)
throw new ApiError(message, response.status, errorBody)
}
return response.json()

63
lib/utils/authErrors.ts Normal file
View File

@@ -0,0 +1,63 @@
/**
* Auth error message mapping for user-facing copy.
* Maps status codes and error types to safe, actionable messages (no sensitive details).
*/
export const AUTH_ERROR_MESSAGES = {
/** Shown when session/token is expired; prompts re-login. */
SESSION_EXPIRED: 'Session expired, please sign in again.',
/** Shown when credentials are invalid (e.g. wrong password, invalid token). */
INVALID_CREDENTIALS: 'Invalid credentials',
/** Shown on network failure or timeout; prompts retry. */
NETWORK: 'Network error, please try again.',
/** Generic fallback for server/unknown errors. */
GENERIC: 'Something went wrong, please try again.',
} as const
/**
* Returns the user-facing message for a given HTTP status from an API/auth response.
* Used when building ApiError messages and when mapping server-returned error types.
*/
export function authMessageFromStatus(status: number): string {
if (status === 401) return AUTH_ERROR_MESSAGES.SESSION_EXPIRED
if (status === 403) return AUTH_ERROR_MESSAGES.INVALID_CREDENTIALS
if (status >= 500) return AUTH_ERROR_MESSAGES.GENERIC
return AUTH_ERROR_MESSAGES.GENERIC
}
/** Error type returned by auth server actions for mapping to user-facing copy. */
export type AuthErrorType = 'network' | 'expired' | 'invalid' | 'server'
/**
* Maps server-action error type (e.g. from exchangeAuthCode) to user-facing message.
* Used in auth callback so no sensitive details are shown.
*/
export function authMessageFromErrorType(type: AuthErrorType): string {
switch (type) {
case 'expired':
return AUTH_ERROR_MESSAGES.SESSION_EXPIRED
case 'invalid':
return AUTH_ERROR_MESSAGES.INVALID_CREDENTIALS
case 'network':
return AUTH_ERROR_MESSAGES.NETWORK
case 'server':
default:
return AUTH_ERROR_MESSAGES.GENERIC
}
}
/**
* Maps an error (e.g. ApiError, network/abort) to a safe user-facing message.
* Use this when displaying API/auth errors in the UI so expired, invalid, and network
* cases show the correct copy without exposing sensitive details.
*/
export function getAuthErrorMessage(error: unknown): string {
if (!error) return AUTH_ERROR_MESSAGES.GENERIC
const err = error as { status?: number; name?: string; message?: string }
if (typeof err.status === 'number') return authMessageFromStatus(err.status)
if (err.name === 'AbortError') return AUTH_ERROR_MESSAGES.NETWORK
if (err instanceof Error && (err.name === 'TypeError' || /fetch|network|failed to fetch/i.test(err.message || ''))) {
return AUTH_ERROR_MESSAGES.NETWORK
}
return AUTH_ERROR_MESSAGES.GENERIC
}