pull/715/head
Travis Fischer 2025-06-13 03:37:25 +07:00
rodzic 4375dd743b
commit 8a8cb58267
14 zmienionych plików z 267 dodań i 113 usunięć

Wyświetl plik

@ -250,6 +250,8 @@ exports[`HTTP => OpenAPI origin everything "echo" tool with empty body > 9.0: PO
exports[`HTTP => OpenAPI origin everything "echo" tool with empty body > 9.1: POST @dev/test-everything-openapi/echo 1`] = `{}`;
exports[`HTTP => OpenAPI origin everything "echo_headers" tool > 12.0: GET @dev/test-everything-openapi@e738c8aa/custom_rate_limit_tool 1`] = `{}`;
exports[`HTTP => OpenAPI origin everything "pure" tool > 7.0: POST @dev/test-everything-openapi/pure 1`] = `
{
"foo": "bar",

Wyświetl plik

@ -16,7 +16,13 @@ const ky = defaultKy.extend({
})
for (const [i, fixtureSuite] of fixtureSuites.entries()) {
const { title, fixtures, compareResponseBodies = false } = fixtureSuite
const {
title,
fixtures,
compareResponseBodies = false,
repeat = 1,
repeatSuccessCriteria = 'all'
} = fixtureSuite
const describeFn = fixtureSuite.only ? describe.only : describe
describeFn(title, () => {
@ -56,74 +62,116 @@ for (const [i, fixtureSuite] of fixtureSuites.entries()) {
},
// eslint-disable-next-line no-loop-func
async () => {
const res = await ky(fixture.path, {
timeout,
...fixture.request
})
const numIterations = repeat ?? 1
let numRepeatSuccesses = 0
if (res.status !== status && res.status >= 500) {
let body: any
try {
body = await res.json()
} catch {}
for (let iteration = 0; iteration < numIterations; ++iteration) {
const repeatIterationPrefix = repeat
? `[${iteration}/${numIterations}] `
: ''
console.error(`${fixtureName} => UNEXPECTED ERROR ${res.status}`, {
body
const res = await ky(fixture.path, {
timeout,
...fixture.request
})
}
expect(res.status).toBe(status)
if (res.status !== status && res.status >= 500) {
let body: any
try {
body = await res.json()
} catch {}
const { type } = contentType.safeParse(
res.headers.get('content-type') ?? ''
)
expect(type).toBe(expectedContentType)
let body: any
if (type.includes('json')) {
try {
body = await res.json()
} catch (err) {
console.error('json error', err)
throw err
console.error(
`${repeatIterationPrefix}${fixtureName} => UNEXPECTED ERROR ${res.status}`,
{
body
}
)
}
} else if (type.includes('text')) {
body = await res.text()
} else {
body = await res.arrayBuffer()
}
if (debugFixture) {
console.log(`${fixtureName} => ${res.status}`, {
body,
headers: Object.fromEntries(res.headers.entries())
})
}
if (repeat) {
if (res.status === status) {
++numRepeatSuccesses
} else {
if (debugFixture) {
console.log(
`${repeatIterationPrefix}${fixtureName} => ${res.status} (invalid; expected ${status})`,
{
headers: Object.fromEntries(res.headers.entries())
}
)
}
if (expectedBody) {
expect(body).toEqual(expectedBody)
}
if (validate) {
await Promise.resolve(validate(body))
}
if (snapshot) {
expect(body).toMatchSnapshot()
}
if (expectedHeaders) {
for (const [key, value] of Object.entries(expectedHeaders)) {
expect(res.headers.get(key)).toBe(value)
}
}
if (compareResponseBodies && status >= 200 && status < 300) {
if (!fixtureResponseBody) {
fixtureResponseBody = body
continue
}
} else {
expect(body).toEqual(fixtureResponseBody)
expect(res.status).toBe(status)
}
const { type } = contentType.safeParse(
res.headers.get('content-type') ?? ''
)
expect(type).toBe(expectedContentType)
let body: any
if (type.includes('json')) {
try {
body = await res.json()
} catch (err) {
console.error('json error', err)
throw err
}
} else if (type.includes('text')) {
body = await res.text()
} else {
body = await res.arrayBuffer()
}
if (debugFixture) {
console.log(
`${repeatIterationPrefix}${fixtureName} => ${res.status}`,
{
body,
headers: Object.fromEntries(res.headers.entries())
}
)
}
if (expectedBody) {
expect(body).toEqual(expectedBody)
}
if (validate) {
await Promise.resolve(validate(body))
}
if (snapshot) {
expect(body).toMatchSnapshot()
}
if (expectedHeaders) {
for (const [key, value] of Object.entries(expectedHeaders)) {
expect(res.headers.get(key)).toBe(value)
}
}
if (compareResponseBodies && status >= 200 && status < 300) {
if (!fixtureResponseBody) {
fixtureResponseBody = body
} else {
expect(body).toEqual(fixtureResponseBody)
}
}
}
if (repeat) {
if (repeatSuccessCriteria === 'all') {
expect(numRepeatSuccesses).toBe(numIterations)
} else if (repeatSuccessCriteria === 'some') {
expect(numRepeatSuccesses).toBeGreaterThan(0)
} else if (typeof repeatSuccessCriteria === 'function') {
expect(repeatSuccessCriteria(numRepeatSuccesses)).toBe(true)
}
}
}

Wyświetl plik

@ -52,6 +52,15 @@ export type E2ETestFixtureSuite = {
/** @default undefined */
snapshot?: boolean
/** @default undefined */
repeat?: number
/** @default 'all' */
repeatSuccessCriteria?:
| 'all'
| 'some'
| ((numRepeatSuccesses: number) => boolean)
}
const now = Date.now()
@ -653,5 +662,23 @@ export const fixtureSuites: E2ETestFixtureSuite[] = [
}
}
]
},
{
title: 'HTTP => OpenAPI origin everything "custom_rate_limit_tool"',
only: true,
repeat: 1,
repeatSuccessCriteria: (numRepeatSuccesses) => numRepeatSuccesses <= 2,
fixtures: [
{
path: '@dev/test-everything-openapi/custom_rate_limit_tool',
response: {
status: 429,
headers: {
'ratelimit-policy': '2;w=30',
'ratelimit-limit': '2'
}
}
}
]
}
]

Wyświetl plik

@ -1,5 +1,6 @@
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import { HttpError } from '@agentic/platform-core'
import { suppressedHttpStatuses } from '@agentic/platform-hono'
import * as Sentry from '@sentry/cloudflare'
import { HTTPException } from 'hono/http-exception'
import { HTTPError } from 'ky'
@ -68,21 +69,23 @@ export function handleMcpToolCallError(
status = 500
}
if (status === 500) {
// eslint-disable-next-line no-console
console.error(`mcp tool call "${toolName}" error`, status, err)
if (!suppressedHttpStatuses.has(status)) {
if (status >= 500) {
// eslint-disable-next-line no-console
console.error(`mcp tool call "${toolName}" error`, status, err)
if (isProd) {
try {
Sentry.captureException(err)
} catch (err_) {
// eslint-disable-next-line no-console
console.error('Error Sentry.captureException failed', err, err_)
if (isProd) {
try {
Sentry.captureException(err)
} catch (err_) {
// eslint-disable-next-line no-console
console.error('Error Sentry.captureException failed', err, err_)
}
}
} else {
// eslint-disable-next-line no-console
console.warn(`mcp tool call "${toolName}" warning`, status, err)
}
} else {
// eslint-disable-next-line no-console
console.warn(`mcp tool call "${toolName}" warning`, status, err)
}
;(res._meta!.agentic as any).status = status

Wyświetl plik

@ -30,15 +30,24 @@ export class DurableRateLimiterBase extends DurableObject<RawEnv> {
// Update the alarm
const currentAlarm = await this.ctx.storage.getAlarm()
if (currentAlarm == null) {
if (!currentAlarm) {
await this.ctx.storage.setAlarm(resetTimeMs)
}
await this.ctx.storage.put('value', state)
// const updatedState = await this.ctx.storage.get<RateLimitState>('value')
// console.log('update', this.ctx.id, {
// existingState,
// state,
// updatedState
// })
return state
}
async reset() {
// console.log('reset rate-limit', this.ctx.id)
await this.ctx.storage.put('value', initialState)
}

Wyświetl plik

@ -21,7 +21,7 @@ export async function enforceRateLimit({
interval,
limit,
cost = 1,
async = true,
async: _async = true,
env,
cache = globalRateLimitCache,
waitUntil
@ -61,20 +61,20 @@ export async function enforceRateLimit({
}): Promise<RateLimitResult> {
assert(id, 400, 'Unauthenticated requests must have a valid IP address')
const async = false
const intervalMs = interval * 1000
const now = Date.now()
let rateLimitState: RateLimitState = cache.get(id) ?? {
const initialRateLimitState = cache.get(id) ?? {
current: 0,
resetTimeMs: now + intervalMs
}
let rateLimitState = initialRateLimitState
function updateCache(info: RateLimitState) {
const current = cache.get(id)?.current ?? 0
if (current && info.current > current) {
cache.set(id, info)
rateLimitState = info
}
cache.set(id, info)
rateLimitState = info
}
/**
@ -124,6 +124,13 @@ export async function enforceRateLimit({
updateCache(updatedRateLimitState)
}
// console.log('rateLimit', {
// id,
// initial: initialRateLimitState,
// current: rateLimitState,
// cost
// })
return {
id,
passed: rateLimitState.current <= limit,

Wyświetl plik

@ -59,7 +59,8 @@ export default defineConfig({
name: 'custom_rate_limit_tool',
rateLimit: {
interval: '30s',
limit: 2
limit: 2,
mode: 'strict'
}
},
{

Wyświetl plik

@ -0,0 +1,33 @@
import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'
const route = createRoute({
description: 'Custom rate limit tool (approximate mode)',
operationId: 'customRateLimitApproximateTool',
method: 'post',
path: '/custom-rate-limit-approximate-tool',
request: {
body: {
content: {
'application/json': {
schema: z.object({}).passthrough()
}
}
}
},
responses: {
200: {
description: 'Echoed request body',
content: {
'application/json': {
schema: z.object({}).passthrough()
}
}
}
}
})
export function registerCustomRateLimitApproximateTool(app: OpenAPIHono) {
return app.openapi(route, async (c) => {
return c.json(c.req.valid('json'))
})
}

Wyświetl plik

@ -1,7 +1,7 @@
import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'
const route = createRoute({
description: 'Custom rate limit tool',
description: 'Custom rate limit tool (strict mode)',
operationId: 'customRateLimitTool',
method: 'post',
path: '/custom-rate-limit-tool',

Wyświetl plik

@ -4,6 +4,7 @@ import { logger as honoLogger } from 'hono/logger'
import { initExitHooks } from './exit-hooks'
import { registerCustomCacheControlTool } from './routes/custom-cache-control-tool'
import { registerCustomRateLimitApproximateTool } from './routes/custom-rate-limit-approximate-tool'
import { registerCustomRateLimitTool } from './routes/custom-rate-limit-tool'
import { registerDisabledForFreePlanTool } from './routes/disabled-for-free-plan-tool'
import { registerDisabledRateLimitTool } from './routes/disabled-rate-limit-tool'
@ -31,6 +32,7 @@ registerEchoHeaders(app)
registerPure(app)
registerUnpureMarkedPure(app)
registerCustomCacheControlTool(app)
registerCustomRateLimitApproximateTool(app)
registerNoStoreCacheControlTool(app)
registerNoCacheCacheControlTool(app)
registerCustomRateLimitTool(app)

Wyświetl plik

@ -12,6 +12,10 @@ import {
JsonRpcErrorCodes
} from './json-rpc-errors'
// Don't log 429 errors because they may happen frequently and are just noise.
// Our access-logger should still log the 429 result, just not the whole error.
export const suppressedHttpStatuses = new Set([429])
/**
* Hono error handler that sanitizes all types of internal, http, json-rpc, and
* unexpected errors and responds with an appropate HTTP Response.
@ -59,19 +63,21 @@ export function errorHandler(
status = 500
}
if (status >= 500) {
logger.error(status, err)
if (!suppressedHttpStatuses.has(status)) {
if (status >= 500) {
logger.error(status, err)
if (isProd) {
try {
captureException(err)
} catch (err_) {
// eslint-disable-next-line no-console
console.error('Error Sentry.captureException failed', err, err_)
if (isProd) {
try {
captureException(err)
} catch (err_) {
// eslint-disable-next-line no-console
console.error('Error Sentry.captureException failed', err, err_)
}
}
} else {
logger.warn(status, err)
}
} else {
logger.warn(status, err)
}
if (isJsonRpcRequest) {

Wyświetl plik

@ -639,10 +639,10 @@ export type StripeSubscriptionItemIdMap = z.infer<
* per minute per customer.
*/
export const defaultRequestsRateLimit = {
enabled: true,
interval: 60,
limit: 1000,
async: true,
enabled: true
mode: 'approximate'
} as const satisfies Readonly<RateLimit>
/**

Wyświetl plik

@ -25,7 +25,7 @@ test('rateLimitSchema valid', () => {
rateLimitSchema.parse({
interval: '1 day',
limit: 1000,
async: false
mode: 'strict'
})
).toMatchSnapshot()
@ -39,7 +39,7 @@ test('rateLimitSchema valid', () => {
rateLimitSchema.parse({
interval: '10m',
limit: 100,
async: false,
mode: 'strict',
enabled: false
})
).toMatchSnapshot()
@ -79,28 +79,28 @@ test('RateLimit types', () => {
expectTypeOf({
interval: 10,
limit: 100,
async: false,
mode: 'approximate',
enabled: true
} as const).toExtend<RateLimit>()
expectTypeOf<{
interval: 10
limit: 100
async: false
mode: 'strict'
enabled: true
}>().toExtend<RateLimit>()
expectTypeOf({
interval: '10s',
limit: 100,
async: true,
mode: 'strict',
enabled: true
} as const).not.toExtend<RateLimit>()
expectTypeOf<{
interval: '10s'
limit: 100
async: false
mode: 'strict'
}>().not.toExtend<RateLimit>()
expectTypeOf({
@ -126,13 +126,13 @@ test('RateLimitInput types', () => {
expectTypeOf({
interval: 10,
limit: 100,
async: false
mode: 'strict'
} as const).toExtend<RateLimitInput>()
expectTypeOf<{
interval: 10
limit: 100
async: boolean
mode: 'approximate'
}>().toExtend<RateLimitInput>()
expectTypeOf({

Wyświetl plik

@ -8,12 +8,20 @@ import parseIntervalAsMs from 'ms'
// z.literal('all')
// ])
export const rateLimitModeSchema = z.union([
z.literal('strict'),
z.literal('approximate')
])
/**
* Rate limit config for metered LineItems.
*/
export const rateLimitSchema = z
.union([
z.object({
/**
* Whether or not this rate limit is enabled.
*/
enabled: z.literal(false)
}),
z.object({
@ -77,26 +85,29 @@ export const rateLimitSchema = z
.describe('Maximum number of operations per interval (unitless).'),
/**
* Whether to enforce the rate limit synchronously or asynchronously.
* How to enforce the rate limit:
*
* The default rate-limiting mode is asynchronous, which means that requests
* are allowed to proceed immediately, with the limit being enforced in the
* background. This is much faster than synchronous mode, but it is less
* consistent if precise adherence to rate-limits is required.
* - `strict` (more precise but slower)
* - `approximate` (the default; faster and asynchronous but less precise).
*
* With synchronous mode, requests are blocked until the current limit has
* The default rate-limiting mode is `approximate`, which means that requests
* are allowed to proceed immediately, with the limit being enforced
* asynchronously in the background. This is much faster than synchronous
* mode, but it is less consistent if precise adherence to rate-limits is
* required.
*
* With `strict` mode, requests are blocked until the current limit has
* been confirmed. The downside with this approach is that it introduces
* more latency to every request by default. The advantage is that it is
* more precise and consistent.
*
* @default true
* @default "approximate"
*/
async: z
.boolean()
mode: rateLimitModeSchema
.optional()
.default(true)
.default('approximate')
.describe(
'Whether to enforce the rate limit synchronously (strict but slower) or asynchronously (approximate and faster, the default).'
'How to enforce the rate limit: "strict" (more precise but slower) or "approximate" (the default; faster and asynchronous but less precise).'
),
// TODO: Consider adding support for this in the future
@ -112,6 +123,11 @@ export const rateLimitSchema = z
// */
// rateLimitBy: rateLimitBySchema.optional().default('customer'),
/**
* Whether or not this rate limit is enabled.
*
* @default true
*/
enabled: z.boolean().optional().default(true)
})
])