feat(throttling): add global rate limit lockout flow
This commit is contained in:
@@ -1,4 +1,10 @@
|
||||
import { API_BASE_URL } from "../config/constants"
|
||||
import {
|
||||
activateRateLimitLock,
|
||||
getRateLimitRemainingSeconds,
|
||||
getStoredRateLimitLock,
|
||||
isRateLimitActive,
|
||||
} from "../lib/rateLimit"
|
||||
import {
|
||||
clearSessionTokens,
|
||||
emitSessionChanged,
|
||||
@@ -8,6 +14,42 @@ import {
|
||||
|
||||
let refreshRequest: Promise<string | null> | null = null
|
||||
|
||||
export interface ApiErrorMessage {
|
||||
attr?: string | null
|
||||
detail: string
|
||||
code?: string | null
|
||||
}
|
||||
|
||||
export interface ApiErrorPayload {
|
||||
error?: string
|
||||
status_code?: number
|
||||
messages?: ApiErrorMessage[]
|
||||
code?: string
|
||||
retry_after_seconds?: number | null
|
||||
throttled_until?: string | null
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
error: string
|
||||
messages: ApiErrorMessage[]
|
||||
code: string | null
|
||||
retryAfterSeconds: number | null
|
||||
throttledUntil: string | null
|
||||
|
||||
constructor(status: number, payload: ApiErrorPayload, fallbackMessage: string) {
|
||||
const detailMessage = payload.messages?.[0]?.detail
|
||||
super(detailMessage || payload.error || fallbackMessage)
|
||||
this.name = "ApiError"
|
||||
this.status = status
|
||||
this.error = payload.error || fallbackMessage
|
||||
this.messages = payload.messages || []
|
||||
this.code = payload.code || null
|
||||
this.retryAfterSeconds = payload.retry_after_seconds ?? null
|
||||
this.throttledUntil = payload.throttled_until ?? null
|
||||
}
|
||||
}
|
||||
|
||||
const cleanBaseUrl = API_BASE_URL.replace(/\/+$/, "")
|
||||
|
||||
const buildUrl = (endpoint: string) => {
|
||||
@@ -52,6 +94,50 @@ const clearSessionAndRedirect = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const toIntOrNull = (value: string | number | null | undefined) => {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return Math.max(0, Math.ceil(value))
|
||||
}
|
||||
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
if (Number.isFinite(parsed)) {
|
||||
return Math.max(0, parsed)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const redirectToRateLimitPage = () => {
|
||||
if (window.location.pathname !== "/rate-limit") {
|
||||
window.location.replace("/rate-limit")
|
||||
}
|
||||
}
|
||||
|
||||
const createLockedResponse = () => {
|
||||
const lock = getStoredRateLimitLock()
|
||||
const retryAfterSeconds = getRateLimitRemainingSeconds(lock)
|
||||
const payload = {
|
||||
error: lock?.message || "Too many requests",
|
||||
status_code: 429,
|
||||
messages: [{ detail: lock?.message || "Too many requests" }],
|
||||
code: lock?.code || "throttled",
|
||||
retry_after_seconds: retryAfterSeconds,
|
||||
throttled_until: lock?.throttledUntil || null,
|
||||
}
|
||||
|
||||
return normalizeJsonResponse(
|
||||
new Response(JSON.stringify(payload), {
|
||||
status: 429,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Retry-After": String(retryAfterSeconds),
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const shouldAttemptRefresh = (endpoint: string) => {
|
||||
const normalizedEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`
|
||||
return ![
|
||||
@@ -103,7 +189,34 @@ const refreshAccessToken = async () => {
|
||||
return refreshRequest
|
||||
}
|
||||
|
||||
export const buildApiError = async (response: Response) => {
|
||||
let payload: ApiErrorPayload = {}
|
||||
|
||||
try {
|
||||
payload = await response.clone().json()
|
||||
} catch {
|
||||
payload = {}
|
||||
}
|
||||
|
||||
if (payload.retry_after_seconds == null) {
|
||||
const retryAfter = toIntOrNull(response.headers.get("Retry-After"))
|
||||
if (retryAfter != null) {
|
||||
payload.retry_after_seconds = retryAfter
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackMessage =
|
||||
response.statusText || (response.status === 429 ? "Too many requests" : "Request failed")
|
||||
|
||||
return new ApiError(response.status, payload, fallbackMessage)
|
||||
}
|
||||
|
||||
export const authFetch = async (endpoint: string, options: RequestInit = {}, allowRetry = true): Promise<Response> => {
|
||||
if (isRateLimitActive()) {
|
||||
redirectToRateLimitPage()
|
||||
return createLockedResponse()
|
||||
}
|
||||
|
||||
const token = getAccessToken()
|
||||
const isFormData = options.body instanceof FormData
|
||||
|
||||
@@ -144,5 +257,24 @@ export const authFetch = async (endpoint: string, options: RequestInit = {}, all
|
||||
return response
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const apiError = await buildApiError(response)
|
||||
if (
|
||||
response.status === 429 ||
|
||||
apiError.code === "throttled" ||
|
||||
apiError.retryAfterSeconds != null ||
|
||||
apiError.throttledUntil
|
||||
) {
|
||||
activateRateLimitLock({
|
||||
status: response.status,
|
||||
code: apiError.code,
|
||||
message: apiError.message,
|
||||
retryAfterSeconds: apiError.retryAfterSeconds,
|
||||
throttledUntil: apiError.throttledUntil,
|
||||
})
|
||||
redirectToRateLimitPage()
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeJsonResponse(response)
|
||||
}
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import { authFetch } from './client';
|
||||
import { authFetch, buildApiError } from './client';
|
||||
|
||||
// --- Auth Endpoints ---
|
||||
|
||||
export const loginWithPassword = async (mobile: string, password: string) => {
|
||||
const response = await authFetch('/api/users/login/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mobile, password })
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to login with password');
|
||||
return response.json();
|
||||
};
|
||||
export const loginWithPassword = async (mobile: string, password: string) => {
|
||||
const response = await authFetch('/api/users/login/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mobile, password })
|
||||
});
|
||||
if (!response.ok) throw await buildApiError(response);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const sendOtp = async (mobile: string, mode: string) => {
|
||||
const response = await authFetch('/api/users/otp/send/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mobile, mode })
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to send OTP');
|
||||
return response.json();
|
||||
};
|
||||
export const sendOtp = async (mobile: string, mode: string) => {
|
||||
const response = await authFetch('/api/users/otp/send/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mobile, mode })
|
||||
});
|
||||
if (!response.ok) throw await buildApiError(response);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const loginWithOtp = async (mobile: string, otp: string) => {
|
||||
const response = await authFetch('/api/users/otp/login/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mobile, code: otp })
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to login with OTP');
|
||||
if (!response.ok) throw await buildApiError(response);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user