feat: fix and add e2e tests for rate-limits

pull/715/head
Travis Fischer 2025-06-13 05:02:16 +07:00
rodzic 8a8cb58267
commit d4d15dc920
18 zmienionych plików z 542 dodań i 339 usunięć

Wyświetl plik

@ -16,9 +16,9 @@ const fixtures = [
// 'pricing-3-plans',
// 'pricing-monthly-annual',
// 'pricing-custom-0',
'basic-openapi',
'basic-mcp',
'everything-openapi'
'basic-openapi'
// 'basic-mcp',
// 'everything-openapi'
]
const fixturesDir = path.join(

Wyświetl plik

@ -21,14 +21,15 @@
},
"dependencies": {
"ky": "catalog:",
"p-map": "catalog:"
"p-map": "catalog:",
"p-times": "^4.0.0"
},
"devDependencies": {
"@agentic/platform": "workspace:*",
"@agentic/platform-api-client": "workspace:*",
"@agentic/platform-core": "workspace:*",
"@agentic/platform-fixtures": "workspace:*",
"fast-content-type-parse": "catalog:",
"@modelcontextprotocol/sdk": "catalog:"
"@modelcontextprotocol/sdk": "catalog:",
"fast-content-type-parse": "catalog:"
}
}

Wyświetl plik

@ -250,8 +250,6 @@ 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

@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`MCP => OpenAPI origin basic @ 010332cf get_post success > 2.0: @dev/test-basic-openapi@010332cf/mcp get_post 1`] = `
exports[`MCP => OpenAPI origin basic @ 726d9f61 get_post success > 2.0: @dev/test-basic-openapi@726d9f61/mcp get_post 1`] = `
{
"content": [],
"isError": false,
@ -48,7 +48,7 @@ nostrum rerum est autem sunt rem eveniet architecto",
}
`;
exports[`MCP => OpenAPI origin basic caching > 7.0: @dev/test-basic-openapi@010332cf/mcp get_post 1`] = `
exports[`MCP => OpenAPI origin basic caching > 7.0: @dev/test-basic-openapi@726d9f61/mcp get_post 1`] = `
{
"content": [],
"isError": false,
@ -64,7 +64,7 @@ nostrum rerum est autem sunt rem eveniet architecto",
}
`;
exports[`MCP => OpenAPI origin basic caching > 7.1: @dev/test-basic-openapi@010332cf/mcp get_post 1`] = `
exports[`MCP => OpenAPI origin basic caching > 7.1: @dev/test-basic-openapi@726d9f61/mcp get_post 1`] = `
{
"content": [],
"isError": false,
@ -80,7 +80,7 @@ nostrum rerum est autem sunt rem eveniet architecto",
}
`;
exports[`MCP => OpenAPI origin basic caching > 7.2: @dev/test-basic-openapi@010332cf/mcp get_post 1`] = `
exports[`MCP => OpenAPI origin basic caching > 7.2: @dev/test-basic-openapi@726d9f61/mcp get_post 1`] = `
{
"content": [],
"isError": false,
@ -128,7 +128,7 @@ nostrum rerum est autem sunt rem eveniet architecto",
}
`;
exports[`MCP => OpenAPI origin basic normalized caching > 8.0: @dev/test-basic-openapi@010332cf/mcp get_post 1`] = `
exports[`MCP => OpenAPI origin basic normalized caching > 8.0: @dev/test-basic-openapi@726d9f61/mcp get_post 1`] = `
{
"content": [],
"isError": false,
@ -144,7 +144,7 @@ nostrum rerum est autem sunt rem eveniet architecto",
}
`;
exports[`MCP => OpenAPI origin basic normalized caching > 8.1: @dev/test-basic-openapi@010332cf/mcp get_post 1`] = `
exports[`MCP => OpenAPI origin basic normalized caching > 8.1: @dev/test-basic-openapi@726d9f61/mcp get_post 1`] = `
{
"content": [],
"isError": false,
@ -190,28 +190,6 @@ exports[`MCP => OpenAPI origin everything "pure" tool > 11.1: @dev/test-everythi
}
`;
exports[`MCP => OpenAPI origin everything "unpure_marked_pure" tool > 14.0: @dev/test-everything-openapi/mcp unpure_marked_pure 1`] = `
{
"content": [],
"isError": false,
"structuredContent": {
"nala": "cat",
"now": 1749674829923,
},
}
`;
exports[`MCP => OpenAPI origin everything "unpure_marked_pure" tool > 14.1: @dev/test-everything-openapi/mcp unpure_marked_pure 1`] = `
{
"content": [],
"isError": false,
"structuredContent": {
"nala": "cat",
"now": 1749674829923,
},
}
`;
exports[`MCP => OpenAPI origin everything errors > 5.0: @dev/test-everything-openapi/mcp strict_additional_properties 1`] = `
{
"content": [],

Wyświetl plik

@ -1,5 +1,6 @@
import contentType from 'fast-content-type-parse'
import defaultKy from 'ky'
import pTimes from 'p-times'
import { describe, expect, test } from 'vitest'
import { env } from './env'
@ -20,7 +21,8 @@ for (const [i, fixtureSuite] of fixtureSuites.entries()) {
title,
fixtures,
compareResponseBodies = false,
repeat = 1,
repeat,
repeatConcurrency = 1,
repeatSuccessCriteria = 'all'
} = fixtureSuite
@ -28,6 +30,10 @@ for (const [i, fixtureSuite] of fixtureSuites.entries()) {
describeFn(title, () => {
let fixtureResponseBody: any | undefined
if (repeat) {
expect(repeat).toBeGreaterThan(0)
}
for (const [j, fixture] of fixtures.entries()) {
const method = fixture.request?.method ?? 'GET'
const timeout = fixture.timeout ?? 30_000
@ -63,115 +69,117 @@ for (const [i, fixtureSuite] of fixtureSuites.entries()) {
// eslint-disable-next-line no-loop-func
async () => {
const numIterations = repeat ?? 1
let numRepeatSuccesses = 0
let numSuccessCases = 0
for (let iteration = 0; iteration < numIterations; ++iteration) {
const repeatIterationPrefix = repeat
? `[${iteration}/${numIterations}] `
: ''
await pTimes(
numIterations,
async (iteration: number) => {
const repeatIterationPrefix = repeat
? `[${iteration}/${numIterations}] `
: ''
const res = await ky(fixture.path, {
timeout,
...fixture.request
})
const res = await ky(fixture.path, {
timeout,
...fixture.request
})
if (res.status !== status && res.status >= 500) {
let body: any
try {
body = await res.json()
} catch {}
if (res.status !== status && res.status >= 500) {
let body: any
try {
body = await res.json()
} catch {}
console.error(
`${repeatIterationPrefix}${fixtureName} => UNEXPECTED ERROR ${res.status}`,
{
console.error(
`${repeatIterationPrefix}${fixtureName} => UNEXPECTED ERROR ${res.status}`,
body
}
)
}
)
}
if (repeat) {
if (res.status === status) {
++numRepeatSuccesses
if (repeat) {
if (res.status === status) {
++numSuccessCases
} else {
if (debugFixture) {
console.log(
`${repeatIterationPrefix}${fixtureName} => ${res.status} (invalid sample; expected ${status})`,
{
headers: Object.fromEntries(res.headers.entries())
}
)
}
return
}
} else {
if (debugFixture) {
console.log(
`${repeatIterationPrefix}${fixtureName} => ${res.status} (invalid; expected ${status})`,
{
headers: Object.fromEntries(res.headers.entries())
}
)
}
continue
expect(res.status).toBe(status)
}
} else {
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())
}
const { type } = contentType.safeParse(
res.headers.get('content-type') ?? ''
)
}
expect(type).toBe(expectedContentType)
if (expectedBody) {
expect(body).toEqual(expectedBody)
}
let body: any
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
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 {
expect(body).toEqual(fixtureResponseBody)
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)
}
}
},
{ concurrency: repeatConcurrency, stopOnError: true }
)
if (repeat) {
if (repeatSuccessCriteria === 'all') {
expect(numRepeatSuccesses).toBe(numIterations)
expect(numSuccessCases).toBe(numIterations)
} else if (repeatSuccessCriteria === 'some') {
expect(numRepeatSuccesses).toBeGreaterThan(0)
expect(numSuccessCases).toBeGreaterThan(0)
} else if (typeof repeatSuccessCriteria === 'function') {
expect(repeatSuccessCriteria(numRepeatSuccesses)).toBe(true)
await Promise.resolve(repeatSuccessCriteria(numSuccessCases))
}
}
}

Wyświetl plik

@ -56,11 +56,14 @@ export type E2ETestFixtureSuite = {
/** @default undefined */
repeat?: number
/** @default 1 */
repeatConcurrency?: number
/** @default 'all' */
repeatSuccessCriteria?:
| 'all'
| 'some'
| ((numRepeatSuccesses: number) => boolean)
| ((numRepeatSuccesses: number) => void | Promise<void>)
}
const now = Date.now()
@ -641,17 +644,17 @@ export const fixtureSuites: E2ETestFixtureSuite[] = [
snapshot: false,
fixtures: [
{
path: '@dev/test-everything-openapi@c8c25547/echo_headers',
path: '@dev/test-everything-openapi@707562a9/echo_headers',
response: {
validate: (body) => {
expect(body['x-agentic-proxy-secret']).toEqual(
'f279280a67a15df6e0245511bdeb11854fc8f6f702c49d028431bb1dbc03bfdc'
)
expect(body['x-agentic-deployment-id']).toEqual(
'depl_kb0jszdetahn52ospawj3reu'
'depl_tj03dd941xfrcd8cjqhg1b9w'
)
expect(body['x-agentic-deployment-identifier']).toEqual(
'@dev/test-everything-openapi@c8c25547'
'@dev/test-everything-openapi@707562a9'
)
expect(body['x-agentic-is-customer-subscription-active']).toEqual(
'false'
@ -664,10 +667,15 @@ export const fixtureSuites: E2ETestFixtureSuite[] = [
]
},
{
title: 'HTTP => OpenAPI origin everything "custom_rate_limit_tool"',
only: true,
repeat: 1,
repeatSuccessCriteria: (numRepeatSuccesses) => numRepeatSuccesses <= 2,
title:
'HTTP => OpenAPI origin everything "custom_rate_limit_tool" (strict mode)',
repeat: 5,
repeatSuccessCriteria: (numRepeatSuccesses) => {
expect(
numRepeatSuccesses,
'should have at least three 429 responses out of 5 requests with a strict rate limit of 2 requests per 30s'
).toBeGreaterThanOrEqual(3)
},
fixtures: [
{
path: '@dev/test-everything-openapi/custom_rate_limit_tool',
@ -680,5 +688,29 @@ export const fixtureSuites: E2ETestFixtureSuite[] = [
}
}
]
},
{
title:
'HTTP => OpenAPI origin everything "custom_rate_limit_approximate_tool" (approximate mode)',
repeat: 16,
repeatConcurrency: 8,
repeatSuccessCriteria: (numRepeatSuccesses) => {
expect(
numRepeatSuccesses,
'should have at least one 429 response'
).toBeGreaterThan(0)
},
fixtures: [
{
path: '@dev/test-everything-openapi/custom_rate_limit_approximate_tool',
response: {
status: 429,
headers: {
'ratelimit-policy': '2;w=30',
'ratelimit-limit': '2'
}
}
}
]
}
]

Wyświetl plik

@ -1,19 +1,31 @@
import { pick } from '@agentic/platform-core'
import { Client as McpClient } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import pTimes from 'p-times'
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
import { env } from './env'
import { fixtureSuites } from './mcp-fixtures'
for (const [i, fixtureSuite] of fixtureSuites.entries()) {
const { title, fixtures, compareResponseBodies = false } = fixtureSuite
const {
title,
fixtures,
compareResponseBodies = false,
repeat,
repeatConcurrency = 1,
repeatSuccessCriteria = 'all'
} = fixtureSuite
const describeFn = fixtureSuite.only ? describe.only : describe
describeFn(title, () => {
let fixtureResult: any | undefined
let client: McpClient
if (repeat) {
expect(repeat).toBeGreaterThan(0)
}
beforeAll(async () => {
client = new McpClient({
name: fixtureSuite.path,
@ -82,94 +94,141 @@ for (const [i, fixtureSuite] of fixtureSuites.entries()) {
},
// eslint-disable-next-line no-loop-func
async () => {
const result = await client.callTool({
name: toolName,
arguments: fixture.request.args,
_meta: fixture.request._meta
})
const numIterations = repeat ?? 1
let numSuccessCases = 0
if (debugFixture) {
console.log(fixtureName, '=>', result)
}
await pTimes(
numIterations,
async (iteration: number) => {
const repeatIterationPrefix = repeat
? `[${iteration}/${numIterations}] `
: ''
if (isError) {
expect(result.isError).toBeTruthy()
} else {
expect(result.isError).toBeFalsy()
}
const result = await client.callTool({
name: toolName,
arguments: fixture.request.args,
_meta: fixture.request._meta
})
if (expectedResult) {
expect(result).toEqual(expectedResult)
}
if (repeat) {
if (result.isError === isError) {
++numSuccessCases
} else {
if (debugFixture) {
console.log(
`${repeatIterationPrefix}${fixtureName} => (invalid sample; expected ${result.isError ? 'error' : 'no error'})`,
JSON.stringify(result, null, 2)
)
}
if (expectedContent) {
expect(result.content).toEqual(expectedContent)
}
return
}
}
if (expectedStructuredContent) {
expect(result.structuredContent).toEqual(expectedStructuredContent)
}
if (debugFixture) {
console.log(
`${repeatIterationPrefix}${fixtureName} =>`,
JSON.stringify(result, null, 2)
)
}
if (expectedMeta) {
expect(result._meta).toBeDefined()
expect(typeof result._meta).toEqual('object')
expect(!Array.isArray(result._meta)).toBeTruthy()
for (const [key, value] of Object.entries(expectedMeta)) {
expect(result._meta![key]).toEqual(value)
}
}
if (expectedAgenticMeta) {
expect(result._meta).toBeDefined()
expect(result._meta?.agentic).toBeDefined()
expect(typeof result._meta?.agentic).toEqual('object')
expect(!Array.isArray(result._meta?.agentic)).toBeTruthy()
for (const [key, value] of Object.entries(expectedAgenticMeta)) {
expect((result._meta!.agentic as any)[key]).toEqual(value)
}
}
if (isError) {
expect(result.isError).toBeTruthy()
} else {
expect(result.isError).toBeFalsy()
}
if (expectedAgenticMetaHeaders) {
expect(result._meta).toBeDefined()
expect(result._meta?.agentic).toBeDefined()
expect(typeof result._meta?.agentic).toEqual('object')
expect(!Array.isArray(result._meta?.agentic)).toBeTruthy()
expect(typeof (result._meta?.agentic as any)?.headers).toEqual(
'object'
)
expect(
!Array.isArray((result._meta?.agentic as any)?.headers)
).toBeTruthy()
for (const [key, value] of Object.entries(
expectedAgenticMetaHeaders
)) {
expect((result._meta!.agentic as any).headers[key]).toEqual(value)
}
}
if (expectedResult) {
expect(result).toEqual(expectedResult)
}
if (expectedSnapshot) {
expect(result).toMatchSnapshot()
}
if (expectedContent) {
expect(result.content).toEqual(expectedContent)
}
const stableResult = pick(
result,
'content',
'structuredContent',
'isError'
if (expectedStructuredContent) {
expect(result.structuredContent).toEqual(
expectedStructuredContent
)
}
if (expectedMeta) {
expect(result._meta).toBeDefined()
expect(typeof result._meta).toEqual('object')
expect(!Array.isArray(result._meta)).toBeTruthy()
for (const [key, value] of Object.entries(expectedMeta)) {
expect(result._meta![key]).toEqual(value)
}
}
if (expectedAgenticMeta) {
expect(result._meta).toBeDefined()
expect(result._meta?.agentic).toBeDefined()
expect(typeof result._meta?.agentic).toEqual('object')
expect(!Array.isArray(result._meta?.agentic)).toBeTruthy()
for (const [key, value] of Object.entries(
expectedAgenticMeta
)) {
expect((result._meta!.agentic as any)[key]).toEqual(value)
}
}
if (expectedAgenticMetaHeaders) {
expect(result._meta).toBeDefined()
expect(result._meta?.agentic).toBeDefined()
expect(typeof result._meta?.agentic).toEqual('object')
expect(!Array.isArray(result._meta?.agentic)).toBeTruthy()
expect(typeof (result._meta?.agentic as any)?.headers).toEqual(
'object'
)
expect(
!Array.isArray((result._meta?.agentic as any)?.headers)
).toBeTruthy()
for (const [key, value] of Object.entries(
expectedAgenticMetaHeaders
)) {
expect((result._meta!.agentic as any).headers[key]).toEqual(
value
)
}
}
if (expectedSnapshot) {
expect(result).toMatchSnapshot()
}
const stableResult = pick(
result,
'content',
'structuredContent',
'isError'
)
if (expectedStableSnapshot) {
expect(stableResult).toMatchSnapshot()
}
if (validate) {
await Promise.resolve(validate(result))
}
if (compareResponseBodies && !isError) {
if (!fixtureResult) {
fixtureResult = stableResult
} else {
expect(stableResult).toEqual(fixtureResult)
}
}
},
{ concurrency: repeatConcurrency, stopOnError: true }
)
if (expectedStableSnapshot) {
expect(stableResult).toMatchSnapshot()
}
if (validate) {
await Promise.resolve(validate(result))
}
if (compareResponseBodies && !isError) {
if (!fixtureResult) {
fixtureResult = stableResult
} else {
expect(stableResult).toEqual(fixtureResult)
if (repeat) {
if (repeatSuccessCriteria === 'all') {
expect(numSuccessCases).toBe(numIterations)
} else if (repeatSuccessCriteria === 'some') {
expect(numSuccessCases).toBeGreaterThan(0)
} else if (typeof repeatSuccessCriteria === 'function') {
await Promise.resolve(repeatSuccessCriteria(numSuccessCases))
}
}
}

Wyświetl plik

@ -61,6 +61,18 @@ export type MCPE2ETestFixtureSuite = {
/** @default undefined */
stableSnapshot?: boolean
/** @default undefined */
repeat?: number
/** @default 1 */
repeatConcurrency?: number
/** @default 'all' */
repeatSuccessCriteria?:
| 'all'
| 'some'
| ((numRepeatSuccesses: number) => void | Promise<void>)
}
const now = Date.now()
@ -95,8 +107,8 @@ export const fixtureSuites: MCPE2ETestFixtureSuite[] = [
]
},
{
title: 'MCP => OpenAPI origin basic @ 010332cf get_post success ',
path: '@dev/test-basic-openapi@010332cf/mcp',
title: 'MCP => OpenAPI origin basic @ 726d9f61 get_post success ',
path: '@dev/test-basic-openapi@726d9f61/mcp',
fixtures: [
{
request: {
@ -286,7 +298,7 @@ export const fixtureSuites: MCPE2ETestFixtureSuite[] = [
},
{
title: 'MCP => OpenAPI origin basic caching',
path: '@dev/test-basic-openapi@010332cf/mcp',
path: '@dev/test-basic-openapi@726d9f61/mcp',
fixtures: [
{
request: {
@ -340,7 +352,7 @@ export const fixtureSuites: MCPE2ETestFixtureSuite[] = [
},
{
title: 'MCP => OpenAPI origin basic normalized caching',
path: '@dev/test-basic-openapi@010332cf/mcp',
path: '@dev/test-basic-openapi@726d9f61/mcp',
fixtures: [
{
request: {
@ -521,6 +533,7 @@ export const fixtureSuites: MCPE2ETestFixtureSuite[] = [
title: 'MCP => OpenAPI origin everything "unpure_marked_pure" tool',
path: '@dev/test-everything-openapi/mcp',
compareResponseBodies: true,
stableSnapshot: false,
fixtures: [
{
request: {
@ -560,7 +573,7 @@ export const fixtureSuites: MCPE2ETestFixtureSuite[] = [
},
{
title: 'MCP => OpenAPI origin everything "echo_headers" tool',
path: '@dev/test-everything-openapi/mcp',
path: '@dev/test-everything-openapi@707562a9/mcp',
stableSnapshot: false,
fixtures: [
{
@ -573,6 +586,84 @@ export const fixtureSuites: MCPE2ETestFixtureSuite[] = [
expect(result.structuredContent['x-agentic-proxy-secret']).toEqual(
'f279280a67a15df6e0245511bdeb11854fc8f6f702c49d028431bb1dbc03bfdc'
)
expect(result.structuredContent['x-agentic-deployment-id']).toEqual(
'depl_tj03dd941xfrcd8cjqhg1b9w'
)
expect(
result.structuredContent['x-agentic-deployment-identifier']
).toEqual('@dev/test-everything-openapi@707562a9')
expect(
result.structuredContent[
'x-agentic-is-customer-subscription-active'
]
).toEqual('false')
expect(
result.structuredContent['x-agentic-user-id']
).toBeUndefined()
expect(
result.structuredContent['x-agentic-customer-id']
).toBeUndefined()
}
}
}
]
},
{
title:
'MCP => OpenAPI origin everything "custom_rate_limit_tool" (strict mode)',
path: '@dev/test-everything-openapi/mcp',
repeat: 5,
repeatSuccessCriteria: (numRepeatSuccesses) => {
expect(
numRepeatSuccesses,
'should have at least three 429 responses out of 5 requests with a strict rate limit of 2 requests per 30s'
).toBeGreaterThanOrEqual(3)
},
fixtures: [
{
request: {
name: 'custom_rate_limit_tool',
args: {}
},
response: {
isError: true,
_agenticMeta: {
status: 429
},
_agenticMetaHeaders: {
'ratelimit-policy': '2;w=30',
'ratelimit-limit': '2'
}
}
}
]
},
{
title:
'MCP => OpenAPI origin everything "custom_rate_limit_approximate_tool" (approximate mode)',
path: '@dev/test-everything-openapi/mcp',
repeat: 16,
repeatConcurrency: 8,
repeatSuccessCriteria: (numRepeatSuccesses) => {
expect(
numRepeatSuccesses,
'should have at least one 429 response'
).toBeGreaterThan(0)
},
fixtures: [
{
request: {
name: 'custom_rate_limit_approximate_tool',
args: {}
},
response: {
isError: true,
_agenticMeta: {
status: 429
},
_agenticMetaHeaders: {
'ratelimit-policy': '2;w=30',
'ratelimit-limit': '2'
}
}
}

Wyświetl plik

@ -17,30 +17,38 @@ export class DurableRateLimiterBase extends DurableObject<RawEnv> {
intervalMs: number
cost?: number
}): Promise<RateLimitState> {
const existingState =
(await this.ctx.storage.get<RateLimitState>('value')) || initialState
const existingState = await this.ctx.storage.get<RateLimitState>('value')
const currentAlarm = await this.ctx.storage.getAlarm()
const now = Date.now()
const updatedResetTimeMs = now + intervalMs
// Update the payload
const resetTimeMs = existingState.resetTimeMs ?? Date.now() + intervalMs
const state: RateLimitState = {
current: existingState.current + cost,
resetTimeMs
}
const state =
existingState && currentAlarm && currentAlarm > now
? existingState
: {
current: 0,
resetTimeMs: updatedResetTimeMs
}
state.current += cost
// Update the alarm
const currentAlarm = await this.ctx.storage.getAlarm()
if (!currentAlarm) {
await this.ctx.storage.setAlarm(resetTimeMs)
if (!currentAlarm || currentAlarm <= now) {
await this.ctx.storage.setAlarm(state.resetTimeMs)
}
await this.ctx.storage.put('value', state)
// const updatedState = await this.ctx.storage.get<RateLimitState>('value')
// console.log('update', this.ctx.id, {
// console.log('DurableRateLimiter.update', this.ctx.id.toString(), {
// existingState,
// state,
// updatedState
// updatedState,
// now,
// intervalMs,
// updatedResetTimeMs,
// currentAlarm
// })
return state

Wyświetl plik

@ -1,3 +1,4 @@
import type { RateLimit } from '@agentic/platform-types'
import { assert } from '@agentic/platform-core'
import type { RawEnv } from '../env'
@ -17,30 +18,23 @@ import type { DurableRateLimiterBase } from './durable-rate-limiter'
const globalRateLimitCache: RateLimitCache = new Map()
export async function enforceRateLimit({
rateLimit,
id,
interval,
limit,
cost = 1,
async: _async = true,
env,
cache = globalRateLimitCache,
waitUntil
}: {
/**
* The rate limit to enforce.
*/
rateLimit: RateLimit
/**
* The identifier used to uniquely track this rate limit.
*/
id: string
/**
* Interval in seconds over which the rate limit is enforced.
*/
interval: number
/**
* Maximum number of requests that can be made per interval.
*/
limit: number
/**
* The cost of the request.
*
@ -48,21 +42,17 @@ export async function enforceRateLimit({
*/
cost?: number
/**
* Whether to enforce the rate limit synchronously or asynchronously.
*
* @default true (asynchronous)
*/
async?: boolean
env: RawEnv
cache?: RateLimitCache
waitUntil: WaitUntil
}): Promise<RateLimitResult> {
}): Promise<RateLimitResult | undefined> {
if (rateLimit.enabled === false) {
return
}
assert(id, 400, 'Unauthenticated requests must have a valid IP address')
const async = false
const { interval, limit, mode } = rateLimit
const intervalMs = interval * 1000
const now = Date.now()
@ -103,7 +93,10 @@ export async function enforceRateLimit({
const updatedRateLimitStateP = durableRateLimiter.update({ cost, intervalMs })
if (async) {
if (mode === 'strict') {
const updatedRateLimitState = await updatedRateLimitStateP
updateCache(updatedRateLimitState)
} else {
waitUntil(
updatedRateLimitStateP
.then((updatedRateLimitState: RateLimitState) => {
@ -119,25 +112,23 @@ export async function enforceRateLimit({
rateLimitState.current += cost
updateCache(rateLimitState)
} else {
const updatedRateLimitState = await updatedRateLimitStateP
updateCache(updatedRateLimitState)
}
// console.log('rateLimit', {
// id,
// initial: initialRateLimitState,
// current: rateLimitState,
// mode,
// cost
// })
return {
id,
passed: rateLimitState.current <= limit,
current: rateLimitState.current,
limit,
current: rateLimitState.current,
remaining: Math.max(0, limit - rateLimitState.current),
resetTimeMs: rateLimitState.resetTimeMs,
intervalMs,
remaining: Math.max(0, limit - rateLimitState.current)
intervalMs
}
}

Wyświetl plik

@ -164,20 +164,18 @@ export async function resolveOriginToolCall({
}
}
if (rateLimit && rateLimit.enabled !== false) {
if (rateLimit) {
// TODO: Consider decrementing rate limit if the response is cached or
// errors? this doesn't seem too important, so will leave as-is for now.
rateLimitResult = await enforceRateLimit({
rateLimit,
id: consumer?.id ?? ip ?? sessionId,
interval: rateLimit.interval,
limit: rateLimit.limit,
async: rateLimit.async,
cost: numRequestsCost,
env,
waitUntil
})
if (!rateLimitResult.passed) {
if (rateLimitResult && !rateLimitResult.passed) {
throw new RateLimitError({ rateLimitResult })
}
}

Wyświetl plik

@ -75,6 +75,7 @@ export function createAgenticMcpMetadata(
existingMetadata?: Record<string, any>
): Record<string, any> {
const rawAgenticMcpMetadata = pruneEmpty({
status: 200,
...existingMetadata?.agentic,
...metadata,
headers: {

Wyświetl plik

@ -63,6 +63,14 @@ export default defineConfig({
mode: 'strict'
}
},
{
name: 'custom_rate_limit_approximate_tool',
rateLimit: {
interval: '30s',
limit: 2,
mode: 'approximate'
}
},
{
name: 'disabled_rate_limit_tool',
rateLimit: { enabled: false }

File diff suppressed because one or more lines are too long

Wyświetl plik

@ -487,10 +487,10 @@ export interface components {
/** @description Maximum number of operations per interval (unitless). */
limit: number;
/**
* @description Whether to enforce the rate limit synchronously (strict but slower) or asynchronously (approximate and faster, the default).
* @default true
* @description How to enforce the rate limit: "strict" (more precise but slower) or "approximate" (the default; faster and asynchronous but less precise).
* @default approximate
*/
async: boolean;
mode: "strict" | "approximate";
/** @default true */
enabled: boolean;
};
@ -685,10 +685,10 @@ export interface components {
* }
* ],
* "rateLimit": {
* "enabled": true,
* "interval": 60,
* "limit": 1000,
* "async": true,
* "enabled": true
* "mode": "approximate"
* }
* }
* ]
@ -1668,10 +1668,10 @@ export interface operations {
* }
* ],
* "rateLimit": {
* "enabled": true,
* "interval": 60,
* "limit": 1000,
* "async": true,
* "enabled": true
* "mode": "approximate"
* }
* }
* ]

Wyświetl plik

@ -12,6 +12,7 @@ export const rateLimitModeSchema = z.union([
z.literal('strict'),
z.literal('approximate')
])
export type RateLimitMode = z.infer<typeof rateLimitModeSchema>
/**
* Rate limit config for metered LineItems.

Wyświetl plik

@ -417,6 +417,9 @@ importers:
p-map:
specifier: 'catalog:'
version: 7.0.3
p-times:
specifier: ^4.0.0
version: 4.0.0
devDependencies:
'@agentic/platform':
specifier: workspace:*
@ -462,7 +465,7 @@ importers:
version: link:../../packages/validators
'@hono/zod-validator':
specifier: 'catalog:'
version: 0.7.0(hono@4.7.11)(zod@3.25.51)
version: 0.7.0(hono@4.7.11)(zod@3.25.62)
'@modelcontextprotocol/sdk':
specifier: 'catalog:'
version: 1.12.1
@ -880,10 +883,6 @@ packages:
peerDependencies:
zod: ^3.20.2
'@babel/code-frame@7.26.2':
resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==}
engines: {node: '>=6.9.0'}
'@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'}
@ -896,10 +895,6 @@ packages:
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.25.9':
resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.27.1':
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
engines: {node: '>=6.9.0'}
@ -2783,6 +2778,10 @@ packages:
peerDependencies:
react: '*'
aggregate-error@4.0.1:
resolution: {integrity: sha512-0poP0T7el6Vq3rstR8Mn4V/IQrpBLO6POkUSrN7RhyY+GF/InCFShQzsQ39T25gkHhLgSLByyAz+Kjb+c2L98w==}
engines: {node: '>=12'}
ai@4.3.16:
resolution: {integrity: sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g==}
engines: {node: '>=18'}
@ -3029,6 +3028,10 @@ packages:
resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==}
engines: {node: '>=4'}
clean-stack@4.2.0:
resolution: {integrity: sha512-LYv6XPxoyODi36Dp976riBtSY27VmFo+MKqEU9QCCWyTrdEPDog+RWA7xQWHi6Vbp61j5c4cdzzX1NidnwtUWg==}
engines: {node: '>=12'}
cli-cursor@5.0.0:
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
engines: {node: '>=18'}
@ -3486,6 +3489,10 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
escape-string-regexp@5.0.0:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
eslint-config-prettier@10.1.5:
resolution: {integrity: sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==}
hasBin: true
@ -4637,6 +4644,10 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
p-map@5.5.0:
resolution: {integrity: sha512-VFqfGDHlx87K66yZrNdI4YGtD70IRyd+zSvgks6mzHPRNkoKy+9EKP4SFC77/vTTQYmRmti7dvqC+m5jBrBAcg==}
engines: {node: '>=12'}
p-map@6.0.0:
resolution: {integrity: sha512-T8BatKGY+k5rU+Q/GTYgrEf2r4xRMevAN5mtXc2aPc4rS1j3s+vWTaO2Wag94neXuCAUAs8cxBL9EeB5EA6diw==}
engines: {node: '>=16'}
@ -4645,6 +4656,10 @@ packages:
resolution: {integrity: sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==}
engines: {node: '>=18'}
p-times@4.0.0:
resolution: {integrity: sha512-KqBkcxIZ2EZHHkt8lbNORmRofFLrLlHQh0ObFO3moOlrdN/v+sbJ2ssZspP5GN7E7zwvcqiqV0xnJydkQuoNyw==}
engines: {node: '>=12.20'}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
@ -5846,33 +5861,33 @@ snapshots:
hono: 4.7.11
jose: 5.9.6
'@ai-sdk/provider-utils@2.2.8(zod@3.25.51)':
'@ai-sdk/provider-utils@2.2.8(zod@3.25.62)':
dependencies:
'@ai-sdk/provider': 1.1.3
nanoid: 3.3.11
secure-json-parse: 2.7.0
zod: 3.25.51
zod: 3.25.62
'@ai-sdk/provider@1.1.3':
dependencies:
json-schema: 0.4.0
'@ai-sdk/react@1.2.12(react@19.1.0)(zod@3.25.51)':
'@ai-sdk/react@1.2.12(react@19.1.0)(zod@3.25.62)':
dependencies:
'@ai-sdk/provider-utils': 2.2.8(zod@3.25.51)
'@ai-sdk/ui-utils': 1.2.11(zod@3.25.51)
'@ai-sdk/provider-utils': 2.2.8(zod@3.25.62)
'@ai-sdk/ui-utils': 1.2.11(zod@3.25.62)
react: 19.1.0
swr: 2.3.3(react@19.1.0)
throttleit: 2.1.0
optionalDependencies:
zod: 3.25.51
zod: 3.25.62
'@ai-sdk/ui-utils@1.2.11(zod@3.25.51)':
'@ai-sdk/ui-utils@1.2.11(zod@3.25.62)':
dependencies:
'@ai-sdk/provider': 1.1.3
'@ai-sdk/provider-utils': 2.2.8(zod@3.25.51)
zod: 3.25.51
zod-to-json-schema: 3.24.5(zod@3.25.51)
'@ai-sdk/provider-utils': 2.2.8(zod@3.25.62)
zod: 3.25.62
zod-to-json-schema: 3.24.5(zod@3.25.62)
'@apideck/better-ajv-errors@0.3.6(ajv@8.17.1)':
dependencies:
@ -5886,12 +5901,6 @@ snapshots:
openapi3-ts: 4.4.0
zod: 3.25.62
'@babel/code-frame@7.26.2':
dependencies:
'@babel/helper-validator-identifier': 7.25.9
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/code-frame@7.27.1':
dependencies:
'@babel/helper-validator-identifier': 7.27.1
@ -5908,8 +5917,6 @@ snapshots:
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.25.9': {}
'@babel/helper-validator-identifier@7.27.1': {}
'@babel/parser@7.27.5':
@ -6276,11 +6283,6 @@ snapshots:
hono: 4.7.11
zod: 3.25.62
'@hono/zod-validator@0.7.0(hono@4.7.11)(zod@3.25.51)':
dependencies:
hono: 4.7.11
zod: 3.25.51
'@hono/zod-validator@0.7.0(hono@4.7.11)(zod@3.25.62)':
dependencies:
hono: 4.7.11
@ -7604,26 +7606,31 @@ snapshots:
agents@0.0.95(@cloudflare/workers-types@4.20250610.0)(react@19.1.0):
dependencies:
'@modelcontextprotocol/sdk': 1.12.1
ai: 4.3.16(react@19.1.0)(zod@3.25.51)
ai: 4.3.16(react@19.1.0)(zod@3.25.62)
cron-schedule: 5.0.4
nanoid: 5.1.5
partyserver: 0.0.71(@cloudflare/workers-types@4.20250610.0)
partysocket: 1.1.4
react: 19.1.0
zod: 3.25.51
zod: 3.25.62
transitivePeerDependencies:
- '@cloudflare/workers-types'
- supports-color
ai@4.3.16(react@19.1.0)(zod@3.25.51):
aggregate-error@4.0.1:
dependencies:
clean-stack: 4.2.0
indent-string: 5.0.0
ai@4.3.16(react@19.1.0)(zod@3.25.62):
dependencies:
'@ai-sdk/provider': 1.1.3
'@ai-sdk/provider-utils': 2.2.8(zod@3.25.51)
'@ai-sdk/react': 1.2.12(react@19.1.0)(zod@3.25.51)
'@ai-sdk/ui-utils': 1.2.11(zod@3.25.51)
'@ai-sdk/provider-utils': 2.2.8(zod@3.25.62)
'@ai-sdk/react': 1.2.12(react@19.1.0)(zod@3.25.62)
'@ai-sdk/ui-utils': 1.2.11(zod@3.25.62)
'@opentelemetry/api': 1.9.0
jsondiffpatch: 0.6.0
zod: 3.25.51
zod: 3.25.62
optionalDependencies:
react: 19.1.0
@ -7882,6 +7889,10 @@ snapshots:
dependencies:
escape-string-regexp: 1.0.5
clean-stack@4.2.0:
dependencies:
escape-string-regexp: 5.0.0
cli-cursor@5.0.0:
dependencies:
restore-cursor: 5.1.0
@ -8336,6 +8347,8 @@ snapshots:
escape-string-regexp@4.0.0: {}
escape-string-regexp@5.0.0: {}
eslint-config-prettier@10.1.5(eslint@9.28.0(jiti@2.4.2)):
dependencies:
eslint: 9.28.0(jiti@2.4.2)
@ -9645,10 +9658,18 @@ snapshots:
dependencies:
p-limit: 3.1.0
p-map@5.5.0:
dependencies:
aggregate-error: 4.0.1
p-map@6.0.0: {}
p-map@7.0.3: {}
p-times@4.0.0:
dependencies:
p-map: 5.5.0
package-json-from-dist@1.0.1: {}
parent-module@1.0.1:
@ -9657,7 +9678,7 @@ snapshots:
parse-json@8.3.0:
dependencies:
'@babel/code-frame': 7.26.2
'@babel/code-frame': 7.27.1
index-to-position: 1.1.0
type-fest: 4.41.0

Wyświetl plik

@ -27,9 +27,8 @@
- how to handle binary bodies and responses?
- improve logger vs console for non-hono path and util methods
- default rate limits?
- **test rate limiting**
- **test usage tracking and reporting**
- disallow `mcp` as a tool name or figure out another workaround
- disallow `mcp` as a tool name or figure out a different workaround
- **Public MCP server interface**
- how does oauth work with this flow?
- pass requestId to DurableMcpServer somehow on a per-request basis