perf: use redis/upstash for ip rate limits (#28694)
This commit is contained in:
@@ -102,6 +102,7 @@
|
|||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
"@solidjs/start": "catalog:",
|
"@solidjs/start": "catalog:",
|
||||||
"@stripe/stripe-js": "8.6.1",
|
"@stripe/stripe-js": "8.6.1",
|
||||||
|
"@upstash/redis": "1.38.0",
|
||||||
"chart.js": "4.5.1",
|
"chart.js": "4.5.1",
|
||||||
"nitro": "3.0.1-alpha.1",
|
"nitro": "3.0.1-alpha.1",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
@@ -2469,6 +2470,8 @@
|
|||||||
|
|
||||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||||
|
|
||||||
|
"@upstash/redis": ["@upstash/redis@1.38.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg=="],
|
||||||
|
|
||||||
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
|
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
|
||||||
|
|
||||||
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
|
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
|
||||||
|
|||||||
@@ -250,6 +250,8 @@ new sst.cloudflare.x.SolidStart("Console", {
|
|||||||
bucket,
|
bucket,
|
||||||
bucketNew,
|
bucketNew,
|
||||||
database,
|
database,
|
||||||
|
SECRET.UpstashRedisRestUrl,
|
||||||
|
SECRET.UpstashRedisRestToken,
|
||||||
AUTH_API_URL,
|
AUTH_API_URL,
|
||||||
STRIPE_WEBHOOK_SECRET,
|
STRIPE_WEBHOOK_SECRET,
|
||||||
DISCORD_INCIDENT_WEBHOOK_URL,
|
DISCORD_INCIDENT_WEBHOOK_URL,
|
||||||
|
|||||||
@@ -8,4 +8,7 @@ export const SECRET = {
|
|||||||
R2AccessKey: new sst.Secret("R2AccessKey", "unknown"),
|
R2AccessKey: new sst.Secret("R2AccessKey", "unknown"),
|
||||||
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
|
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
|
||||||
HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
|
HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
|
||||||
|
UpstashRedisRestUrl: new sst.Secret("UpstashRedisRestUrl"),
|
||||||
|
UpstashRedisRestToken: new sst.Secret("UpstashRedisRestToken"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
"@solidjs/start": "catalog:",
|
"@solidjs/start": "catalog:",
|
||||||
"@stripe/stripe-js": "8.6.1",
|
"@stripe/stripe-js": "8.6.1",
|
||||||
|
"@upstash/redis": "1.38.0",
|
||||||
"chart.js": "4.5.1",
|
"chart.js": "4.5.1",
|
||||||
"nitro": "3.0.1-alpha.1",
|
"nitro": "3.0.1-alpha.1",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizz
|
|||||||
import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
||||||
import { FreeUsageLimitError } from "./error"
|
import { FreeUsageLimitError } from "./error"
|
||||||
import { logger } from "./logger"
|
import { logger } from "./logger"
|
||||||
|
import { buildRateLimitKey, getRedis } from "./redis"
|
||||||
import { i18n } from "~/i18n"
|
import { i18n } from "~/i18n"
|
||||||
import { localeFromRequest } from "~/lib/language"
|
import { localeFromRequest } from "~/lib/language"
|
||||||
import { Subscription } from "@opencode-ai/console-core/subscription.js"
|
import { Subscription } from "@opencode-ai/console-core/subscription.js"
|
||||||
@@ -23,43 +24,62 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
|
|||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const lifetimeInterval = ""
|
const lifetimeInterval = ""
|
||||||
const dailyInterval = rateLimit ? `${buildYYYYMMDD(now)}${modelId.substring(0, 2)}` : buildYYYYMMDD(now)
|
const dailyInterval = rateLimit ? `${buildYYYYMMDD(now)}${modelId.substring(0, 2)}` : buildYYYYMMDD(now)
|
||||||
|
const retryAfter = getRetryAfterDay(now)
|
||||||
let _isNew: boolean
|
const redis = getRedis()
|
||||||
|
const lifetimeKey = buildRateLimitKey("ip", ip)
|
||||||
|
const dailyKey = buildRateLimitKey("ip", ip, dailyInterval)
|
||||||
|
let isNew = false
|
||||||
|
|
||||||
return {
|
return {
|
||||||
check: async () => {
|
check: async () => {
|
||||||
const rows = await Database.use((tx) =>
|
const [counts, rows] = await Promise.all([
|
||||||
tx
|
redis.mget<(string | number | null)[]>(isDefaultModel ? [lifetimeKey, dailyKey] : [dailyKey]).catch(() => []),
|
||||||
.select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
|
Database.use((tx) =>
|
||||||
.from(IpRateLimitTable)
|
tx
|
||||||
.where(
|
.select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
|
||||||
and(
|
.from(IpRateLimitTable)
|
||||||
eq(IpRateLimitTable.ip, ip),
|
.where(
|
||||||
isDefaultModel
|
and(
|
||||||
? inArray(IpRateLimitTable.interval, [lifetimeInterval, dailyInterval])
|
eq(IpRateLimitTable.ip, ip),
|
||||||
: inArray(IpRateLimitTable.interval, [dailyInterval]),
|
isDefaultModel
|
||||||
|
? inArray(IpRateLimitTable.interval, [lifetimeInterval, dailyInterval])
|
||||||
|
: inArray(IpRateLimitTable.interval, [dailyInterval]),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
])
|
||||||
const lifetimeCount = rows.find((r) => r.interval === lifetimeInterval)?.count ?? 0
|
const redisLifetimeCount = isDefaultModel ? Number(counts[0] ?? 0) : 0
|
||||||
const dailyCount = rows.find((r) => r.interval === dailyInterval)?.count ?? 0
|
const redisDailyCount = Number(counts[isDefaultModel ? 1 : 0] ?? 0)
|
||||||
|
const databaseLifetimeCount = rows.find((r) => r.interval === lifetimeInterval)?.count ?? 0
|
||||||
|
const databaseDailyCount = rows.find((r) => r.interval === dailyInterval)?.count ?? 0
|
||||||
|
const lifetimeCount = Math.max(redisLifetimeCount, databaseLifetimeCount)
|
||||||
|
const dailyCount = Math.max(redisDailyCount, databaseDailyCount)
|
||||||
logger.debug(`rate limit lifetime: ${lifetimeCount}, daily: ${dailyCount}`)
|
logger.debug(`rate limit lifetime: ${lifetimeCount}, daily: ${dailyCount}`)
|
||||||
|
|
||||||
_isNew = isDefaultModel && lifetimeCount < dailyLimit * 7
|
isNew = isDefaultModel && lifetimeCount < dailyLimit * 7
|
||||||
|
if (isDefaultModel && databaseLifetimeCount > redisLifetimeCount)
|
||||||
|
await redis.set(lifetimeKey, databaseLifetimeCount).catch(() => {})
|
||||||
|
|
||||||
if ((_isNew && dailyCount >= dailyLimit * 2) || (!_isNew && dailyCount >= dailyLimit))
|
if ((isNew && dailyCount >= dailyLimit * 2) || (!isNew && dailyCount >= dailyLimit))
|
||||||
throw new FreeUsageLimitError(dict["zen.api.error.rateLimitExceeded"], getRetryAfterDay(now))
|
throw new FreeUsageLimitError(dict["zen.api.error.rateLimitExceeded"], retryAfter)
|
||||||
},
|
},
|
||||||
track: async () => {
|
track: async () => {
|
||||||
await Database.use((tx) =>
|
const pipeline = redis.pipeline()
|
||||||
tx
|
pipeline.incr(dailyKey)
|
||||||
.insert(IpRateLimitTable)
|
pipeline.expire(dailyKey, retryAfter)
|
||||||
.values([
|
if (isNew) pipeline.incr(lifetimeKey)
|
||||||
{ ip, interval: dailyInterval, count: 1 },
|
await Promise.all([
|
||||||
...(_isNew ? [{ ip, interval: lifetimeInterval, count: 1 }] : []),
|
pipeline.exec().catch(() => {}),
|
||||||
])
|
Database.use((tx) =>
|
||||||
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
|
tx
|
||||||
)
|
.insert(IpRateLimitTable)
|
||||||
|
.values([
|
||||||
|
{ ip, interval: dailyInterval, count: 1 },
|
||||||
|
...(isNew ? [{ ip, interval: lifetimeInterval, count: 1 }] : []),
|
||||||
|
])
|
||||||
|
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
|
||||||
|
),
|
||||||
|
])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
|
import { Redis } from "@upstash/redis/cloudflare"
|
||||||
|
|
||||||
|
let redis: Redis | undefined
|
||||||
|
|
||||||
|
export function getRedis() {
|
||||||
|
if (redis) return redis
|
||||||
|
redis = new Redis({
|
||||||
|
url: Resource.UpstashRedisRestUrl.value,
|
||||||
|
token: Resource.UpstashRedisRestToken.value,
|
||||||
|
enableTelemetry: false,
|
||||||
|
})
|
||||||
|
return redis
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRateLimitKey(kind: string, identifier: string, interval?: string) {
|
||||||
|
return `${Resource.App.stage}:ratelimit:${kind}:${identifier}${interval ? `:${interval}` : ""}`
|
||||||
|
}
|
||||||
Vendored
+8
@@ -137,6 +137,14 @@ declare module "sst" {
|
|||||||
"type": "sst.cloudflare.SolidStart"
|
"type": "sst.cloudflare.SolidStart"
|
||||||
"url": string
|
"url": string
|
||||||
}
|
}
|
||||||
|
"UpstashRedisRestToken": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
|
"UpstashRedisRestUrl": {
|
||||||
|
"type": "sst.sst.Secret"
|
||||||
|
"value": string
|
||||||
|
}
|
||||||
"Web": {
|
"Web": {
|
||||||
"type": "sst.cloudflare.Astro"
|
"type": "sst.cloudflare.Astro"
|
||||||
"url": string
|
"url": string
|
||||||
|
|||||||
Reference in New Issue
Block a user