refactor: remove all rate limiting across the application (Task 11)
This commit is contained in:
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect } from "vitest"
|
||||||
|
import fs from "node:fs"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
describe("rate limiting removal", () => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
|
||||||
|
it("lib/auth/rate-limit.ts no longer exports the rateLimit function", () => {
|
||||||
|
const filePath = path.join(cwd, "lib", "auth", "rate-limit.ts")
|
||||||
|
expect(fs.existsSync(filePath)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("lib/api/app.ts no longer imports or uses rateLimit", () => {
|
||||||
|
const content = fs.readFileSync(
|
||||||
|
path.join(cwd, "lib", "api", "app.ts"),
|
||||||
|
"utf8",
|
||||||
|
)
|
||||||
|
expect(content).not.toContain('import { rateLimit }')
|
||||||
|
expect(content).not.toContain('.use(rateLimit(')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("lib/auth.ts has Better Auth rateLimit disabled", () => {
|
||||||
|
const content = fs.readFileSync(
|
||||||
|
path.join(cwd, "lib", "auth.ts"),
|
||||||
|
"utf8",
|
||||||
|
)
|
||||||
|
const hasRateLimitBlock = content.includes("rateLimit:")
|
||||||
|
if (hasRateLimitBlock) {
|
||||||
|
expect(content).toContain("enabled: false")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("lib/api/contact.ts no longer calls checkContactRateLimit", () => {
|
||||||
|
const content = fs.readFileSync(
|
||||||
|
path.join(cwd, "lib", "api", "contact.ts"),
|
||||||
|
"utf8",
|
||||||
|
)
|
||||||
|
expect(content).not.toContain("checkContactRateLimit")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("lib/api/comments.ts no longer enforces hourly cap", () => {
|
||||||
|
const content = fs.readFileSync(
|
||||||
|
path.join(cwd, "lib", "api", "comments.ts"),
|
||||||
|
"utf8",
|
||||||
|
)
|
||||||
|
expect(content).not.toContain("hourly limit")
|
||||||
|
expect(content).not.toContain('"Duplicate comment detected')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("lib/api/performance-submit.ts no longer enforces submission cooldown", () => {
|
||||||
|
const content = fs.readFileSync(
|
||||||
|
path.join(cwd, "lib", "api", "performance-submit.ts"),
|
||||||
|
"utf8",
|
||||||
|
)
|
||||||
|
expect(content).not.toContain("Submission cooldown")
|
||||||
|
expect(content).not.toContain("sixtySecondsAgo")
|
||||||
|
expect(content).not.toContain("Please wait before submitting")
|
||||||
|
})
|
||||||
|
})
|
||||||
+6
-12
@@ -2,7 +2,6 @@ import { Elysia, t } from "elysia"
|
|||||||
import { openapi } from "@elysia/openapi"
|
import { openapi } from "@elysia/openapi"
|
||||||
import { cron, Patterns } from "@elysia/cron"
|
import { cron, Patterns } from "@elysia/cron"
|
||||||
import { auth } from "@/lib/auth"
|
import { auth } from "@/lib/auth"
|
||||||
import { rateLimit } from "@/lib/auth/rate-limit"
|
|
||||||
import { isDeckyVaultEmail, DOMAIN_BLOCK_ERROR } from "@/lib/auth/domain-block"
|
import { isDeckyVaultEmail, DOMAIN_BLOCK_ERROR } from "@/lib/auth/domain-block"
|
||||||
import { taskRegistry } from "./cron"
|
import { taskRegistry } from "./cron"
|
||||||
import {
|
import {
|
||||||
@@ -154,10 +153,9 @@ export const app = new Elysia({ prefix: "/api" })
|
|||||||
error: code === "NOT_FOUND" ? "Not found" : "Internal server error",
|
error: code === "NOT_FOUND" ? "Not found" : "Internal server error",
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// ── Auth routes (auth rate limit + betterAuth) ──────────────
|
// ── Auth routes ─────────────────────────────────────────────
|
||||||
.group("", (app) =>
|
.group("", (app) =>
|
||||||
app
|
app
|
||||||
.use(rateLimit("auth"))
|
|
||||||
.onBeforeHandle(async ({ request, set }) => {
|
.onBeforeHandle(async ({ request, set }) => {
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
const isSignUp =
|
const isSignUp =
|
||||||
@@ -214,10 +212,9 @@ export const app = new Elysia({ prefix: "/api" })
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
// ── Read-heavy public routes (read rate limit) ───────────────
|
// ── Read-heavy public routes ──────────────────────────────
|
||||||
.group("", (app) =>
|
.group("", (app) =>
|
||||||
app
|
app
|
||||||
.use(rateLimit("read"))
|
|
||||||
.use(healthRoutes)
|
.use(healthRoutes)
|
||||||
.use(gamesRoutes)
|
.use(gamesRoutes)
|
||||||
.use(gameVersionsRoutes)
|
.use(gameVersionsRoutes)
|
||||||
@@ -246,10 +243,9 @@ export const app = new Elysia({ prefix: "/api" })
|
|||||||
.use(screenshotRoutes)
|
.use(screenshotRoutes)
|
||||||
.use(mobileRoutes)
|
.use(mobileRoutes)
|
||||||
)
|
)
|
||||||
// ── Write routes (write rate limit + betterAuth) ─────────────
|
// ── Write routes ───────────────────────────────────────────
|
||||||
.group("", (app) =>
|
.group("", (app) =>
|
||||||
app
|
app
|
||||||
.use(rateLimit("write"))
|
|
||||||
.use(betterAuth)
|
.use(betterAuth)
|
||||||
.use(clientVersionRoutes)
|
.use(clientVersionRoutes)
|
||||||
.use(performanceVerifyRoutes)
|
.use(performanceVerifyRoutes)
|
||||||
@@ -262,17 +258,15 @@ export const app = new Elysia({ prefix: "/api" })
|
|||||||
.use(adminStorageRoutes)
|
.use(adminStorageRoutes)
|
||||||
.use(adminAnalyticsRoutes)
|
.use(adminAnalyticsRoutes)
|
||||||
)
|
)
|
||||||
// ── Strict rate limit (public forms, no auth) ───────────────
|
// ── Public forms (no auth) ─────────────────────────────────
|
||||||
.group("", (app) =>
|
.group("", (app) =>
|
||||||
app
|
app
|
||||||
.use(rateLimit("strict"))
|
|
||||||
.use(contactRoutes)
|
.use(contactRoutes)
|
||||||
.use(communitySuggestionRoutes)
|
.use(communitySuggestionRoutes)
|
||||||
)
|
)
|
||||||
// ── Cron (no rate limit) ─────────────────────────────────────
|
// ── Cron ─────────────────────────────────────────────────────
|
||||||
.use(cronRoutes)
|
.use(cronRoutes)
|
||||||
// ── Root (default rate limit) ────────────────────────────────
|
// ── Root ────────────────────────────────────────────────────
|
||||||
.use(rateLimit("default"))
|
|
||||||
.get("/", () => ({
|
.get("/", () => ({
|
||||||
name: "DeckyVault API",
|
name: "DeckyVault API",
|
||||||
version: "2026.2.2",
|
version: "2026.2.2",
|
||||||
|
|||||||
@@ -119,52 +119,6 @@ export const commentsRoutes = new Elysia({
|
|||||||
return { error: "Comment content exceeds maximum size (50KB)" }
|
return { error: "Comment content exceeds maximum size (50KB)" }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Anti-spam: duplicate detection ─────────────────────────
|
|
||||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000)
|
|
||||||
const [duplicate] = await db
|
|
||||||
.select({ id: gameComments.id })
|
|
||||||
.from(gameComments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(gameComments.gameId, params.gameId),
|
|
||||||
eq(gameComments.userId, guard.user.id),
|
|
||||||
eq(gameComments.isRemoved, false),
|
|
||||||
sql`${gameComments.createdAt} >= ${fiveMinutesAgo}`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (duplicate) {
|
|
||||||
const [recent] = await db
|
|
||||||
.select({ content: gameComments.content })
|
|
||||||
.from(gameComments)
|
|
||||||
.where(eq(gameComments.id, duplicate.id))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (recent && JSON.stringify(recent.content) === contentStr) {
|
|
||||||
set.status = 409
|
|
||||||
return { error: "Duplicate comment detected — you posted identical content in the last 5 minutes" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Anti-spam: per-user hourly cap ─────────────────────────
|
|
||||||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000)
|
|
||||||
const [{ count: recentCount }] = await db
|
|
||||||
.select({ count: sql<number>`count(*)::int` })
|
|
||||||
.from(gameComments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(gameComments.userId, guard.user.id),
|
|
||||||
eq(gameComments.isRemoved, false),
|
|
||||||
sql`${gameComments.createdAt} >= ${oneHourAgo}`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (recentCount >= 30) {
|
|
||||||
set.status = 429
|
|
||||||
return { error: "Too many comments — you've reached the hourly limit of 30" }
|
|
||||||
}
|
|
||||||
|
|
||||||
// If parentId is provided, verify it exists and belongs to the same game
|
// If parentId is provided, verify it exists and belongs to the same game
|
||||||
if (body.parentId) {
|
if (body.parentId) {
|
||||||
const [parent] = await db
|
const [parent] = await db
|
||||||
|
|||||||
+1
-46
@@ -1,42 +1,5 @@
|
|||||||
import { Elysia, t } from "elysia"
|
import { Elysia, t } from "elysia"
|
||||||
|
|
||||||
// ── In-memory rate limiter for contact form ──────────────────────
|
|
||||||
const contactLimiter = new Map<string, { count: number; resetAt: number }>()
|
|
||||||
const RATE_LIMIT_MAX = 3
|
|
||||||
const RATE_LIMIT_WINDOW = 60 * 60 * 1000 // 1 hour in ms
|
|
||||||
|
|
||||||
// Clean up expired entries every 5 minutes
|
|
||||||
setInterval(() => {
|
|
||||||
const now = Date.now()
|
|
||||||
for (const [key, entry] of contactLimiter) {
|
|
||||||
if (now > entry.resetAt) contactLimiter.delete(key)
|
|
||||||
}
|
|
||||||
}, 5 * 60 * 1000)
|
|
||||||
|
|
||||||
function getClientIP(request: Request): string {
|
|
||||||
const forwarded = request.headers.get("x-forwarded-for")
|
|
||||||
if (forwarded) return forwarded.split(",")[0].trim()
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkContactRateLimit(ip: string): { allowed: boolean; retryAfter: number } {
|
|
||||||
const now = Date.now()
|
|
||||||
const entry = contactLimiter.get(ip)
|
|
||||||
|
|
||||||
if (!entry || now > entry.resetAt) {
|
|
||||||
contactLimiter.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW })
|
|
||||||
return { allowed: true, retryAfter: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.count >= RATE_LIMIT_MAX) {
|
|
||||||
const retryAfter = Math.ceil((entry.resetAt - now) / 1000)
|
|
||||||
return { allowed: false, retryAfter }
|
|
||||||
}
|
|
||||||
|
|
||||||
entry.count++
|
|
||||||
return { allowed: true, retryAfter: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Discord embed colors by category ─────────────────────────────
|
// ── Discord embed colors by category ─────────────────────────────
|
||||||
const CATEGORY_COLORS: Record<string, number> = {
|
const CATEGORY_COLORS: Record<string, number> = {
|
||||||
bug: 0xe74c3c, // red
|
bug: 0xe74c3c, // red
|
||||||
@@ -87,14 +50,6 @@ export const contactRoutes = new Elysia({ prefix: "/contact", detail: { tags: ["
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rate limit ──────────────────────────────────────────────
|
|
||||||
const ip = getClientIP(request)
|
|
||||||
const rateCheck = checkContactRateLimit(ip)
|
|
||||||
if (!rateCheck.allowed) {
|
|
||||||
set.status = 429
|
|
||||||
return { error: "Too many submissions. Please try again later.", retryAfter: rateCheck.retryAfter }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Validate category ──────────────────────────────────────
|
// ── Validate category ──────────────────────────────────────
|
||||||
if (!VALID_CATEGORIES.includes(payload.category)) {
|
if (!VALID_CATEGORIES.includes(payload.category)) {
|
||||||
set.status = 400
|
set.status = 400
|
||||||
@@ -149,7 +104,7 @@ export const contactRoutes = new Elysia({ prefix: "/contact", detail: { tags: ["
|
|||||||
...(payload.name ? [{ name: "Name", value: payload.name, inline: true }] : []),
|
...(payload.name ? [{ name: "Name", value: payload.name, inline: true }] : []),
|
||||||
...(payload.email ? [{ name: "Email", value: payload.email, inline: true }] : []),
|
...(payload.email ? [{ name: "Email", value: payload.email, inline: true }] : []),
|
||||||
...(payload.gameUrl ? [{ name: "Game URL", value: payload.gameUrl, inline: false }] : []),
|
...(payload.gameUrl ? [{ name: "Game URL", value: payload.gameUrl, inline: false }] : []),
|
||||||
{ name: "IP Hash", value: `\`${ip.slice(0, 8)}...\``, inline: true },
|
{ name: "IP Hash", value: `\`${(request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown").slice(0, 8)}...\``, inline: true },
|
||||||
],
|
],
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,31 +145,6 @@ export const performanceSubmitRoutes = new Elysia({ prefix: "/performance", deta
|
|||||||
return { error: "Missing required fields: versionId, hardwareSlug, fpsAvg" }
|
return { error: "Missing required fields: versionId, hardwareSlug, fpsAvg" }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Submission cooldown: 60 seconds between entries per user ──
|
|
||||||
const sixtySecondsAgo = new Date(Date.now() - 60 * 1000)
|
|
||||||
const [lastEntry] = await db
|
|
||||||
.select({ createdAt: performanceEntries.createdAt })
|
|
||||||
.from(performanceEntries)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(performanceEntries.userId, guard.user.id),
|
|
||||||
sql`${performanceEntries.createdAt} >= ${sixtySecondsAgo}`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(sql`${performanceEntries.createdAt} DESC`)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (lastEntry) {
|
|
||||||
const retryAfter = Math.ceil(
|
|
||||||
(lastEntry.createdAt.getTime() + 60_000 - Date.now()) / 1000
|
|
||||||
)
|
|
||||||
set.status = 429
|
|
||||||
return {
|
|
||||||
error: "Please wait before submitting another benchmark",
|
|
||||||
retryAfter: Math.max(1, retryAfter),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Validation: fpsAvg bounds ───────────────────────────────
|
// ── Validation: fpsAvg bounds ───────────────────────────────
|
||||||
if (typeof fpsAvg !== "number" || fpsAvg < 1 || fpsAvg > 500) {
|
if (typeof fpsAvg !== "number" || fpsAvg < 1 || fpsAvg > 500) {
|
||||||
set.status = 400
|
set.status = 400
|
||||||
|
|||||||
+1
-3
@@ -82,9 +82,7 @@ export const auth = betterAuth({
|
|||||||
},
|
},
|
||||||
trustedOrigins,
|
trustedOrigins,
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
enabled: true,
|
enabled: false,
|
||||||
window: 60,
|
|
||||||
max: 100
|
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
expiresIn: 60 * 60 * 24 * 7,
|
expiresIn: 60 * 60 * 24 * 7,
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest"
|
|
||||||
import { rateLimit } from "../rate-limit"
|
|
||||||
import { Elysia } from "elysia"
|
|
||||||
|
|
||||||
describe("rateLimit plugin", () => {
|
|
||||||
it("creates an Elysia plugin instance", () => {
|
|
||||||
const plugin = rateLimit("default")
|
|
||||||
expect(plugin).toBeInstanceOf(Elysia)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("sets rate limit headers on allowed requests", async () => {
|
|
||||||
const app = new Elysia()
|
|
||||||
.use(rateLimit("default"))
|
|
||||||
.get("/test", () => "ok")
|
|
||||||
|
|
||||||
const response = await app.handle(
|
|
||||||
new Request("http://localhost/test")
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(response.status).toBe(200)
|
|
||||||
expect(response.headers.get("X-RateLimit-Limit")).toBeTruthy()
|
|
||||||
expect(response.headers.get("X-RateLimit-Remaining")).toBeTruthy()
|
|
||||||
expect(response.headers.get("X-RateLimit-Reset")).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("allows requests within limit", async () => {
|
|
||||||
const app = new Elysia()
|
|
||||||
.use(rateLimit("default"))
|
|
||||||
.get("/test", () => "ok")
|
|
||||||
|
|
||||||
// Make a reasonable number of requests that should all succeed
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
const response = await app.handle(
|
|
||||||
new Request("http://localhost/test")
|
|
||||||
)
|
|
||||||
expect(response.status).toBe(200)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it("different categories have independent limits", async () => {
|
|
||||||
const app = new Elysia()
|
|
||||||
.use(rateLimit("default"))
|
|
||||||
.get("/test", () => "ok")
|
|
||||||
|
|
||||||
// Just verify it works at all — actual limit enforcement depends on the config
|
|
||||||
const response = await app.handle(new Request("http://localhost/test"))
|
|
||||||
expect(response.status).toBe(200)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns Retry-After header on rate limit", async () => {
|
|
||||||
const app = new Elysia()
|
|
||||||
.use(rateLimit("strict"))
|
|
||||||
.get("/test", () => "ok")
|
|
||||||
|
|
||||||
// Make requests until the remaining goes to 0, then check Retry-After
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
const response = await app.handle(new Request("http://localhost/test"))
|
|
||||||
if (response.status === 429) {
|
|
||||||
const retryAfter = response.headers.get("Retry-After")
|
|
||||||
expect(retryAfter).toBeTruthy()
|
|
||||||
expect(Number(retryAfter)).toBeGreaterThan(0)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { Elysia } from "elysia"
|
|
||||||
|
|
||||||
type RateLimitEntry = {
|
|
||||||
count: number
|
|
||||||
resetAt: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: This is an in-memory rate limiter for development/single-instance
|
|
||||||
// deployments. For production with multiple instances or serverless, use a
|
|
||||||
// shared store like Redis or Upstash.
|
|
||||||
const store = new Map<string, RateLimitEntry>()
|
|
||||||
|
|
||||||
// Clean up expired entries every 60 seconds
|
|
||||||
setInterval(() => {
|
|
||||||
const now = Date.now()
|
|
||||||
for (const [key, entry] of store) {
|
|
||||||
if (now > entry.resetAt) {
|
|
||||||
store.delete(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 60_000)
|
|
||||||
|
|
||||||
function getClientIP(request: Request): string {
|
|
||||||
const forwarded = request.headers.get("x-forwarded-for")
|
|
||||||
if (forwarded) {
|
|
||||||
return forwarded.split(",")[0].trim()
|
|
||||||
}
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkRateLimit(
|
|
||||||
key: string,
|
|
||||||
window: number,
|
|
||||||
max: number,
|
|
||||||
): { allowed: boolean; remaining: number; resetAt: number } {
|
|
||||||
const now = Date.now()
|
|
||||||
const entry = store.get(key)
|
|
||||||
|
|
||||||
if (!entry || now > entry.resetAt) {
|
|
||||||
const resetAt = now + window * 1000
|
|
||||||
store.set(key, { count: 1, resetAt })
|
|
||||||
return { allowed: true, remaining: max - 1, resetAt }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.count >= max) {
|
|
||||||
return { allowed: false, remaining: 0, resetAt: entry.resetAt }
|
|
||||||
}
|
|
||||||
|
|
||||||
entry.count++
|
|
||||||
return { allowed: true, remaining: max - entry.count, resetAt: entry.resetAt }
|
|
||||||
}
|
|
||||||
|
|
||||||
const CATEGORY_LIMITS: Record<string, { window: number; max: number }> = {
|
|
||||||
default: { window: 60, max: 100 },
|
|
||||||
auth: { window: 60, max: 20 },
|
|
||||||
read: { window: 60, max: 300 },
|
|
||||||
write: { window: 60, max: 10 },
|
|
||||||
strict: { window: 60, max: 5 },
|
|
||||||
}
|
|
||||||
|
|
||||||
export const rateLimit = (
|
|
||||||
category: string = "default",
|
|
||||||
) => {
|
|
||||||
const { window, max } = CATEGORY_LIMITS[category] ?? CATEGORY_LIMITS.default
|
|
||||||
|
|
||||||
return new Elysia({ name: `rate-limit-${category}` }).onRequest(({ request, set }) => {
|
|
||||||
const ip = getClientIP(request)
|
|
||||||
const path = new URL(request.url).pathname
|
|
||||||
const key = `${category}:${ip}:${path}`
|
|
||||||
|
|
||||||
const result = checkRateLimit(key, window, max)
|
|
||||||
|
|
||||||
if (!result.allowed) {
|
|
||||||
const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000)
|
|
||||||
set.status = 429
|
|
||||||
set.headers["Retry-After"] = String(retryAfter)
|
|
||||||
return {
|
|
||||||
error: "Too many requests",
|
|
||||||
retryAfter,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// These headers are informational — clients can use them to throttle
|
|
||||||
set.headers["X-RateLimit-Limit"] = String(max)
|
|
||||||
set.headers["X-RateLimit-Remaining"] = String(result.remaining)
|
|
||||||
set.headers["X-RateLimit-Reset"] = String(Math.ceil(result.resetAt / 1000))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user