feat(throttling): add global rate limit lockout flow

This commit is contained in:
2026-04-30 15:25:45 +03:30
parent 2772b36447
commit e635fd9c2c
10 changed files with 901 additions and 279 deletions

View File

@@ -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)
}

View File

@@ -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();
};