Skip to content

Commit ea4924c

Browse files
authored
Merge pull request #425 from ProBuidler/No-request-timeout-is-configured-on-the-API-client-#358
2 parents 2cb82e8 + 97a1658 commit ea4924c

2 files changed

Lines changed: 37 additions & 10 deletions

File tree

lib/api/axios.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useRateLimitStore } from '../store/rateLimitStore';
44
import { getCsrfTokenFromCookie, CSRF_HEADER_NAME } from '../utils/csrf';
55
import { toast } from 'sonner';
66
import { announce } from '@/lib/utils/announce';
7-
import { parseApiError } from '../utils/apiError';
7+
import { parseApiError, isTimeoutError } from '../utils/apiError';
88
import { getAppRouter } from '../navigation/appRouter';
99

1010
// Deduplication: avoid showing multiple toasts for simultaneous errors
@@ -64,6 +64,14 @@ function notifyError(message: string, key?: string) {
6464
// Use cookie-based auth (HttpOnly cookie set by the server). Do not read tokens from localStorage.
6565
const apiBaseURL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
6666

67+
// Default timeout for all requests unless a longer one is needed (e.g. payment submission).
68+
export const DEFAULT_TIMEOUT_MS = 15000;
69+
// Payment submission endpoints (charge creation, settlement processing) can take longer
70+
// than standard reads because they may wait for provider confirmation.
71+
export const PAYMENT_TIMEOUT_MS = 30000;
72+
// URLs matching these paths get the extended payment timeout.
73+
const PAYMENT_TIMEOUT_PATHS: ReadonlyArray<string> = ['/payments', '/settlements'];
74+
6775
if (!process.env.NEXT_PUBLIC_API_URL && typeof window !== 'undefined') {
6876
console.warn(
6977
'[API Client] NEXT_PUBLIC_API_URL is not set. Defaulting to http://localhost:3001. ' +
@@ -73,7 +81,7 @@ if (!process.env.NEXT_PUBLIC_API_URL && typeof window !== 'undefined') {
7381

7482
export const apiClient = axios.create({
7583
baseURL: apiBaseURL,
76-
timeout: 15000, // 15 seconds for normal requests
84+
timeout: DEFAULT_TIMEOUT_MS,
7785
headers: {
7886
'Content-Type': 'application/json',
7987
},
@@ -92,6 +100,15 @@ const refreshClient = axios.create({
92100
// Attach CSRF token to all state-changing requests (double-submit cookie pattern)
93101
const STATE_METHODS = ['post', 'put', 'patch', 'delete'] as const;
94102

103+
// Resolve the timeout for a request. Payment submission endpoints get the extended
104+
// timeout; everything else uses the default. Note that Axios merges the instance
105+
// default into every request config, so the URL match is the authoritative signal
106+
// here and cannot be distinguished from a caller-provided value at this point.
107+
function resolveRequestTimeout(url: string | undefined): number {
108+
const safeUrl = url || '';
109+
return PAYMENT_TIMEOUT_PATHS.some((path) => safeUrl.includes(path)) ? PAYMENT_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
110+
}
111+
95112
apiClient.interceptors.request.use((config) => {
96113
const method = (config.method || '').toLowerCase();
97114
if (STATE_METHODS.includes(method as (typeof STATE_METHODS)[number])) {
@@ -101,13 +118,7 @@ apiClient.interceptors.request.use((config) => {
101118
}
102119
}
103120

104-
// Use 30 second timeout for payment submission endpoints, 15 seconds for others
105-
const url = config.url || '';
106-
if (url.includes('/payments') || url.includes('/settlements')) {
107-
config.timeout = 30000;
108-
} else {
109-
config.timeout = 15000;
110-
}
121+
config.timeout = resolveRequestTimeout(config.url);
111122

112123
return config;
113124
});
@@ -196,6 +207,11 @@ apiClient.interceptors.response.use(
196207
const limit = rawLimit ? parseInt(String(rawLimit), 10) : undefined;
197208
useRateLimitStore.getState().setRateLimited(seconds, endpoint, limit);
198209
notifyError(`Too many attempts. Please try again in ${seconds} seconds.`, `429_${endpoint}`);
210+
} else if (isTimeoutError(error)) {
211+
// Axios aborts timed-out requests and surfaces an ECONNABORTED error.
212+
// Avoid the generic network message so the user knows the request was
213+
// given ample time and can retry.
214+
notifyError('The request timed out. Please try again.', `timeout_${originalRequest?.url || 'unknown'}`);
199215
} else if (!error.response) {
200216
notifyError('Network error. Please check your connection.', 'network_error');
201217
} else if (error.response?.status >= 500) {

lib/utils/apiError.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
import { AxiosError } from 'axios';
22

3+
export const REQUEST_TIMED_OUT_CODE = 'ECONNABORTED';
4+
export const REQUEST_TIMED_OUT_MESSAGE = 'The request timed out. Please try again.';
5+
6+
export function isTimeoutError(error: unknown): boolean {
7+
if (error && typeof error === 'object' && 'code' in error) {
8+
return (error as { code?: string }).code === REQUEST_TIMED_OUT_CODE;
9+
}
10+
return false;
11+
}
12+
313
export interface ApiErrorResponse {
414
message: string;
515
code?: string;
@@ -60,7 +70,8 @@ export function parseApiError(error: unknown): ApiError {
6070
if (error && typeof error === 'object' && 'isAxiosError' in error) {
6171
const axiosError = error as AxiosError<{ message?: string; error?: string; code?: string; details?: unknown }>;
6272
const data = axiosError.response?.data;
63-
const message = data?.message || data?.error || axiosError.message || 'An unexpected error occurred';
73+
const message =
74+
REQUEST_TIMED_OUT_CODE === axiosError.code ? REQUEST_TIMED_OUT_MESSAGE : data?.message || data?.error || axiosError.message || 'An unexpected error occurred';
6475
const code = data?.code || axiosError.code;
6576
const status = axiosError.response?.status;
6677
const details = data?.details;

0 commit comments

Comments
 (0)