Compare commits

...

2 Commits

Author SHA1 Message Date
Nikhil Sonti
8eb4c1d850 fix: remove unused getDb() method
No longer needed after rate-limiter removal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 08:00:07 -07:00
Nikhil Sonti
b608f37c89 fix: remove daily rate-limit middleware
The daily conversation rate limit is no longer needed. Remove the
middleware, RateLimiter class, fetch-config, error type, shared
constants, DB schema table, and integration tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 07:59:30 -07:00
12 changed files with 32 additions and 436 deletions

View File

@@ -1,45 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
import { createMiddleware } from 'hono/factory'
import type { RateLimiter } from '../../lib/rate-limiter/rate-limiter'
import type { ChatRequest } from '../types'
interface RateLimitMiddlewareDeps {
rateLimiter?: RateLimiter
browserosId?: string
}
type ChatValidationInput = {
in: { json: ChatRequest }
out: { json: ChatRequest }
}
export function createBrowserosRateLimitMiddleware(
deps: RateLimitMiddlewareDeps,
) {
return createMiddleware<object, '*', ChatValidationInput>(async (c, next) => {
const { rateLimiter, browserosId } = deps
if (!rateLimiter || !browserosId) {
return next()
}
const request = c.req.valid('json')
if (request.provider === LLM_PROVIDERS.BROWSEROS) {
rateLimiter.check(browserosId)
rateLimiter.record({
conversationId: request.conversationId,
browserosId,
provider: request.provider,
})
}
return next()
})
}

View File

@@ -5,10 +5,8 @@ import type { Browser } from '../../browser/browser'
import { KlavisClient } from '../../lib/clients/klavis/klavis-client'
import { logger } from '../../lib/logger'
import { metrics } from '../../lib/metrics'
import type { RateLimiter } from '../../lib/rate-limiter/rate-limiter'
import { Sentry } from '../../lib/sentry'
import type { ToolRegistry } from '../../tools/tool-registry'
import { createBrowserosRateLimitMiddleware } from '../middleware/rate-limit'
import { ChatService } from '../services/chat-service'
import { ChatRequestSchema } from '../types'
import { ConversationIdParamSchema } from '../utils/validation'
@@ -17,12 +15,11 @@ interface ChatRouteDeps {
browser: Browser
registry: ToolRegistry
browserosId?: string
rateLimiter?: RateLimiter
aiSdkDevtoolsEnabled?: boolean
}
export function createChatRoutes(deps: ChatRouteDeps) {
const { browserosId, rateLimiter } = deps
const { browserosId } = deps
const sessionStore = new SessionStore()
const klavisClient = new KlavisClient()
@@ -36,46 +33,41 @@ export function createChatRoutes(deps: ChatRouteDeps) {
})
return new Hono()
.post(
'/',
zValidator('json', ChatRequestSchema),
createBrowserosRateLimitMiddleware({ rateLimiter, browserosId }),
async (c) => {
const request = c.req.valid('json')
.post('/', zValidator('json', ChatRequestSchema), async (c) => {
const request = c.req.valid('json')
// Sentry + metrics (HTTP concerns only)
Sentry.getCurrentScope().setTag(
'request-type',
request.isScheduledTask ? 'schedule' : 'chat',
)
Sentry.setContext('request', {
provider: request.provider,
model: request.model,
baseUrl: request.baseUrl
? (() => {
try {
return new URL(request.baseUrl).origin
} catch {
return undefined
}
})()
: undefined,
})
// Sentry + metrics (HTTP concerns only)
Sentry.getCurrentScope().setTag(
'request-type',
request.isScheduledTask ? 'schedule' : 'chat',
)
Sentry.setContext('request', {
provider: request.provider,
model: request.model,
baseUrl: request.baseUrl
? (() => {
try {
return new URL(request.baseUrl).origin
} catch {
return undefined
}
})()
: undefined,
})
metrics.log('chat.request', {
provider: request.provider,
model: request.model,
})
metrics.log('chat.request', {
provider: request.provider,
model: request.model,
})
logger.info('Chat request received', {
conversationId: request.conversationId,
provider: request.provider,
model: request.model,
})
logger.info('Chat request received', {
conversationId: request.conversationId,
provider: request.provider,
model: request.model,
})
return service.processMessage(request, c.req.raw.signal)
},
)
return service.processMessage(request, c.req.raw.signal)
})
.delete(
'/:conversationId',
zValidator('param', ConversationIdParamSchema),

View File

@@ -71,7 +71,6 @@ export async function createHttpServer(config: HttpServerConfig) {
browserosId,
executionDir,
resourcesDir,
rateLimiter,
version,
browser,
controller,
@@ -161,7 +160,6 @@ export async function createHttpServer(config: HttpServerConfig) {
browser,
registry,
browserosId,
rateLimiter,
aiSdkDevtoolsEnabled: config.aiSdkDevtoolsEnabled,
}),
)

View File

@@ -16,7 +16,6 @@ import { LLMConfigSchema } from '@browseros/shared/schemas/llm'
import { z } from 'zod'
import type { ControllerBackend } from '../browser/backends/controller'
import type { Browser } from '../browser/browser'
import type { RateLimiter } from '../lib/rate-limiter/rate-limiter'
import type { ToolRegistry } from '../tools/tool-registry'
// Re-export browser context types for consumers
@@ -99,8 +98,6 @@ export interface HttpServerConfig {
browserosId?: string
executionDir: string
resourcesDir: string
rateLimiter?: RateLimiter
codegenServiceUrl?: string
aiSdkDevtoolsEnabled?: boolean

View File

@@ -5,15 +5,6 @@
*/
import type { Database } from 'bun:sqlite'
// id is the conversation_id - using it as PK ensures same conversation is only counted once
const RATE_LIMITER_TABLE = `
CREATE TABLE IF NOT EXISTS rate_limiter (
id TEXT PRIMARY KEY,
browseros_id TEXT NOT NULL,
provider TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`
const IDENTITY_TABLE = `
CREATE TABLE IF NOT EXISTS identity (
id INTEGER PRIMARY KEY CHECK (id = 1),
@@ -36,7 +27,6 @@ CREATE TABLE IF NOT EXISTS oauth_tokens (
)`
export function initSchema(db: Database): void {
db.exec(RATE_LIMITER_TABLE)
db.exec(IDENTITY_TABLE)
db.exec(OAUTH_TOKENS_TABLE)
}

View File

@@ -1,32 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { HttpAgentError } from '../../agent/errors'
export class RateLimitError extends HttpAgentError {
constructor(
public used: number,
public limit: number,
) {
super(
`Daily limit reached (${used}/${limit}). Add your own API key for unlimited usage. https://dub.sh/browseros-usage-limit`,
429,
'RATE_LIMIT_EXCEEDED',
)
}
override toJSON() {
return {
error: {
name: this.name,
message: this.message,
code: this.code,
statusCode: this.statusCode,
used: this.used,
limit: this.limit,
},
}
}
}

View File

@@ -1,55 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Rate limit configuration fetching from remote config service.
*/
import { RATE_LIMITS } from '@browseros/shared/constants/limits'
import { INLINED_ENV } from '../../env'
import { fetchBrowserOSConfig } from '../clients/gateway'
import { logger } from '../logger'
export async function fetchDailyRateLimit(
browserosId: string,
): Promise<number> {
if (process.env.NODE_ENV === 'test') {
logger.info('Test mode: rate limiting disabled')
return RATE_LIMITS.TEST_DAILY
}
if (process.env.NODE_ENV === 'development') {
logger.info('Dev mode: using dev rate limit', {
dailyRateLimit: RATE_LIMITS.DEV_DAILY,
})
return RATE_LIMITS.DEV_DAILY
}
const configUrl = INLINED_ENV.BROWSEROS_CONFIG_URL
if (!configUrl) {
logger.info('No BROWSEROS_CONFIG_URL, using default rate limit', {
dailyRateLimit: RATE_LIMITS.DEFAULT_DAILY,
})
return RATE_LIMITS.DEFAULT_DAILY
}
try {
const browserosConfig = await fetchBrowserOSConfig(configUrl, browserosId)
const defaultProvider = browserosConfig.providers.find(
(p) => p.name === 'default',
)
const dailyRateLimit =
defaultProvider?.dailyRateLimit ?? RATE_LIMITS.DEFAULT_DAILY
logger.info('Rate limit config fetched', { dailyRateLimit })
return dailyRateLimit
} catch (error) {
logger.warn('Failed to fetch rate limit config, using default', {
error: error instanceof Error ? error.message : String(error),
dailyRateLimit: RATE_LIMITS.DEFAULT_DAILY,
})
return RATE_LIMITS.DEFAULT_DAILY
}
}

View File

@@ -1,74 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Database } from 'bun:sqlite'
import { RATE_LIMITS } from '@browseros/shared/constants/limits'
import { logger } from '../logger'
import { metrics } from '../metrics'
import { RateLimitError } from './errors'
export interface RecordParams {
conversationId: string
browserosId: string
provider: string
}
export class RateLimiter {
private countStmt: ReturnType<Database['prepare']>
private insertStmt: ReturnType<Database['prepare']>
private dailyRateLimit: number
constructor(
db: Database,
dailyRateLimit: number = RATE_LIMITS.DEFAULT_DAILY,
) {
this.dailyRateLimit = dailyRateLimit
this.countStmt = db.prepare(`
SELECT COUNT(*) as count
FROM rate_limiter
WHERE browseros_id = ?
AND date(created_at) = date('now')
`)
// INSERT OR IGNORE: duplicate conversation_ids are silently ignored
// This ensures the same conversation is only counted once for rate limiting
this.insertStmt = db.prepare(`
INSERT OR IGNORE INTO rate_limiter
(id, browseros_id, provider)
VALUES (?, ?, ?)
`)
}
check(browserosId: string): void {
const count = this.getTodayCount(browserosId)
if (count >= this.dailyRateLimit) {
logger.warn('Rate limit exceeded', {
browserosId,
count,
dailyRateLimit: this.dailyRateLimit,
})
metrics.log('rate_limit.triggered', {
count,
daily_limit: this.dailyRateLimit,
})
throw new RateLimitError(count, this.dailyRateLimit)
}
}
record(params: RecordParams): void {
const { conversationId, browserosId, provider } = params
this.insertStmt.run(conversationId, browserosId, provider)
}
private getTodayCount(browserosId: string): number {
const row = this.countStmt.get(browserosId) as { count: number } | null
return row?.count ?? 0
}
}
export { RateLimitError } from './errors'

View File

@@ -29,8 +29,6 @@ import { identity } from './lib/identity'
import { logger } from './lib/logger'
import { metrics } from './lib/metrics'
import { isPortInUseError } from './lib/port-binding'
import { fetchDailyRateLimit } from './lib/rate-limiter/fetch-config'
import { RateLimiter } from './lib/rate-limiter/rate-limiter'
import { Sentry } from './lib/sentry'
import { seedSoulTemplate } from './lib/soul'
import { migrateBuiltinSkills } from './skills/migrate'
@@ -59,8 +57,6 @@ export class Application {
await this.initCoreServices()
const dailyRateLimit = await fetchDailyRateLimit(identity.getBrowserOSId())
const controller = new ControllerBackend({
port: this.config.extensionPort,
})
@@ -104,7 +100,6 @@ export class Application {
browserosId: identity.getBrowserOSId(),
executionDir: this.config.executionDir,
resourcesDir: this.config.resourcesDir,
rateLimiter: new RateLimiter(this.getDb(), dailyRateLimit),
codegenServiceUrl: this.config.codegenServiceUrl,
aiSdkDevtoolsEnabled: this.config.aiSdkDevtoolsEnabled,
@@ -274,13 +269,4 @@ export class Application {
logger.info(` HTTP Server: http://127.0.0.1:${this.config.serverPort}`)
logger.info('')
}
private getDb(): Database {
if (!this.db) {
throw new Error(
'Database not initialized. Call initCoreServices() first.',
)
}
return this.db
}
}

View File

@@ -1,149 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Integration tests for RateLimiter
* Uses in-memory SQLite to test actual database behavior
*/
import { Database } from 'bun:sqlite'
import { beforeEach, describe, expect, it } from 'bun:test'
import {
RateLimitError,
RateLimiter,
} from '../../src/agent/rate-limiter/rate-limiter'
const DAILY_RATE_LIMIT_TEST = 3
function createTestDb(): Database {
const db = new Database(':memory:')
db.exec('PRAGMA journal_mode = WAL')
db.exec(`
CREATE TABLE IF NOT EXISTS rate_limiter (
id TEXT PRIMARY KEY,
browseros_id TEXT NOT NULL,
provider TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`)
return db
}
describe('RateLimiter', () => {
let db: Database
let rateLimiter: RateLimiter
beforeEach(() => {
db = createTestDb()
rateLimiter = new RateLimiter(db, DAILY_RATE_LIMIT_TEST)
})
describe('check()', () => {
it('allows first 3 conversations (check before record)', () => {
const browserosId = 'test-browseros-id'
// Simulates real flow: check() then record() for each conversation
for (let i = 1; i <= 3; i++) {
expect(() => rateLimiter.check(browserosId)).not.toThrow()
rateLimiter.record({
conversationId: `conv-${i}`,
browserosId,
provider: 'browseros',
})
}
})
it('blocks 4th conversation with RateLimitError', () => {
const browserosId = 'test-browseros-id'
// Use up all 3 slots
for (let i = 1; i <= 3; i++) {
rateLimiter.check(browserosId)
rateLimiter.record({
conversationId: `conv-${i}`,
browserosId,
provider: 'browseros',
})
}
// 4th should be blocked
expect(() => rateLimiter.check(browserosId)).toThrow(RateLimitError)
try {
rateLimiter.check(browserosId)
} catch (error) {
expect(error).toBeInstanceOf(RateLimitError)
const rateLimitError = error as RateLimitError
expect(rateLimitError.used).toBe(3)
expect(rateLimitError.limit).toBe(3)
expect(rateLimitError.statusCode).toBe(429)
}
})
})
describe('record() with duplicate conversation IDs', () => {
it('ignores duplicate conversation IDs (same conversation counted once)', () => {
const browserosId = 'test-browseros-id'
const sameConversationId = 'duplicate-conv-id'
// Record the same conversation 5 times
for (let i = 0; i < 5; i++) {
rateLimiter.record({
conversationId: sameConversationId,
browserosId,
provider: 'browseros',
})
}
// Should still pass - only counts as 1 conversation
expect(() => rateLimiter.check(browserosId)).not.toThrow()
// Add 2 more unique conversations (total 3)
rateLimiter.record({
conversationId: 'unique-conv-1',
browserosId,
provider: 'browseros',
})
rateLimiter.record({
conversationId: 'unique-conv-2',
browserosId,
provider: 'browseros',
})
// Now at limit (3 unique conversations)
expect(() => rateLimiter.check(browserosId)).toThrow(RateLimitError)
})
})
describe('separate limits per browserosId', () => {
it('tracks limits independently for different users', () => {
const user1 = 'browseros-user-1'
const user2 = 'browseros-user-2'
// User 1 uses all 3 conversations
for (let i = 1; i <= 3; i++) {
rateLimiter.record({
conversationId: `user1-conv-${i}`,
browserosId: user1,
provider: 'browseros',
})
}
// User 1 is blocked
expect(() => rateLimiter.check(user1)).toThrow(RateLimitError)
// User 2 should still have full quota
expect(() => rateLimiter.check(user2)).not.toThrow()
// User 2 can use their quota
rateLimiter.record({
conversationId: 'user2-conv-1',
browserosId: user2,
provider: 'browseros',
})
expect(() => rateLimiter.check(user2)).not.toThrow()
})
})
})

View File

@@ -99,12 +99,6 @@ describe('Application.start', () => {
log: mock(() => {}),
},
}))
mock.module('../src/lib/rate-limiter/fetch-config', () => ({
fetchDailyRateLimit: mock(async () => 100),
}))
mock.module('../src/lib/rate-limiter/rate-limiter', () => ({
RateLimiter: class {},
}))
mock.module('../src/lib/sentry', () => ({
Sentry: {
setContext: mock(() => {}),

View File

@@ -6,12 +6,6 @@
* Centralized limits and thresholds.
*/
export const RATE_LIMITS = {
DEFAULT_DAILY: 5,
DEV_DAILY: 100,
TEST_DAILY: Infinity,
} as const
export const AGENT_LIMITS = {
MAX_TURNS: 100,
DEFAULT_CONTEXT_WINDOW: 200_000,