Skip to content

Commit 2b15905

Browse files
committed
fix(rabbitmq): reserve message metadata in the retrieval response budget
1 parent 384d21b commit 2b15905

5 files changed

Lines changed: 61 additions & 25 deletions

File tree

apps/docs/content/docs/en/integrations/rabbitmq.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Sim talks to RabbitMQ over its **Management HTTP API** — the same interface be
3838
- The user you authenticate as needs the **`management` tag** at minimum, plus read and write permissions on the virtual host you target. Administrative operations require broader permissions.
3939
- Publishing and reading messages over the HTTP API is **convenient but not a high-throughput transport** — RabbitMQ opens a new connection per request. It is well suited to workflow-rate traffic, inspection, and operational automation; a service consuming thousands of messages per second should use an AMQP client instead.
4040
- Queue statistics such as message and consumer counts are **collected on an interval**, so a queue declared moments ago may report them as empty until the broker's next sample.
41+
- Reading messages is **bounded per call** so one retrieval cannot exceed Sim's response limit. A batch is capped at 50 messages, payloads are truncated (each message reports whether it was), and a large batch shortens payloads further. AMQP properties and headers are returned in full — the broker offers no way to truncate them — so retrieving several messages carrying very large headers may still hit the limit; lower the count if that happens.
4142
{/* MANUAL-CONTENT-END */}
4243

4344

@@ -84,7 +85,7 @@ Retrieve messages from a RabbitMQ queue. Defaults to requeueing the messages so
8485
| `count` | number | No | Maximum number of messages to retrieve, from 1 to $\{MAX_MESSAGE_COUNT\}. Defaults to 1 |
8586
| `ackmode` | string | No | How retrieved messages are handled: ack_requeue_true \(default, leaves messages in the queue\), ack_requeue_false \(removes them\), reject_requeue_true, or reject_requeue_false |
8687
| `encoding` | string | No | auto \(default\) returns readable text where possible, base64 always returns base64 |
87-
| `truncate` | number | No | Truncate payloads longer than this many bytes. Defaults to $\{DEFAULT_TRUNCATE_BYTES\} and is capped at $\{MAX_TRUNCATE_BYTES\}, and lowered further when a large count would push the response past the transport limit. Each message reports whether it was truncated |
88+
| `truncate` | number | No | Truncate payloads longer than this many bytes. Defaults to $\{DEFAULT_TRUNCATE_BYTES\}, capped at $\{MAX_TRUNCATE_BYTES\}, and lowered further at high counts so the whole batch stays inside the response limit. Each message reports whether it was truncated |
8889

8990
#### Output
9091

apps/sim/blocks/blocks/rabbitmq.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`,
357357
title: 'Message Count',
358358
type: 'short-input',
359359
placeholder: '1',
360-
description: 'Maximum number of messages to retrieve, up to 100',
360+
description: 'Maximum number of messages to retrieve, up to 50',
361361
condition: { field: 'operation', value: 'rabbitmq_get_messages' },
362362
},
363363
{

apps/sim/tools/generated/tool-metadata.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

apps/sim/tools/rabbitmq/get_messages.ts

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,38 +19,58 @@ const ACK_MODES = new Set([
1919

2020
/**
2121
* The broker applies no upper bound of its own to `count`, so a single call could pull an
22-
* unbounded number of messages of unbounded size into memory. Both dimensions are bounded here:
23-
* `count` caps how many messages come back, and `truncate` caps each payload broker-side so the
24-
* oversized bytes are never transferred at all. The truncate default matches the management UI's own.
22+
* unbounded number of messages into memory, and the shared tool transport rejects any response
23+
* body over 10MB before a tool ever sees it. Both dimensions are bounded here.
2524
*/
26-
const MAX_MESSAGE_COUNT = 100
25+
const MAX_MESSAGE_COUNT = 50
2726
const DEFAULT_TRUNCATE_BYTES = 50_000
27+
const MIN_TRUNCATE_BYTES = 1_024
28+
const MAX_TRUNCATE_BYTES = 1_000_000
2829

2930
/**
30-
* Payload budget for one call. The shared tool transport rejects any response body over 10MB,
31-
* and `count * truncate` alone could exceed that — base64 payloads inflate a further 4/3 on top,
32-
* before JSON escaping. Keeping the combined payload under this budget means a large `truncate`
33-
* degrades to shorter payloads rather than failing the whole retrieval at the transport cap.
31+
* Budget for one retrieval, kept under the transport's 10MB cap with room for the JSON envelope.
3432
*/
35-
const MAX_TOTAL_PAYLOAD_BYTES = 4_000_000
36-
const MAX_TRUNCATE_BYTES = 1_000_000
33+
const RESPONSE_BUDGET_BYTES = 8_000_000
34+
35+
/**
36+
* Per-message allowance for AMQP properties and headers. `truncate` bounds the payload only —
37+
* the broker returns properties in full — so the budget has to reserve space for them rather
38+
* than assume they are small. This figure is RabbitMQ's default `frame_max`, which bounds the
39+
* content header frame carrying a message's properties, so any message published by a standard
40+
* AMQP client fits inside it.
41+
*
42+
* The one case this cannot cover is a message published through the management HTTP API itself,
43+
* which bypasses `frame_max` and accepts properties up to that API's ~10MB request-body limit.
44+
* Retrieving several of those can still exceed the transport cap and surfaces as a response-size
45+
* error; the remedy is a lower `count`.
46+
*/
47+
const PER_MESSAGE_METADATA_RESERVE_BYTES = 131_072
48+
49+
/** base64 payloads inflate 4/3 on the wire before JSON escaping. */
50+
const BASE64_INFLATION = 4 / 3
3751

3852
function resolveCount(count: number | undefined): number {
3953
if (typeof count !== 'number' || !Number.isFinite(count)) return 1
4054
return Math.min(Math.max(Math.trunc(count), 1), MAX_MESSAGE_COUNT)
4155
}
4256

4357
/**
44-
* Resolves the per-message byte limit actually sent to the broker, bounded both per message and
45-
* across the whole batch so the response always fits inside the shared transport cap.
58+
* Resolves the per-message payload limit sent to the broker. The whole batch — payloads plus the
59+
* reserved metadata allowance for every message — is held inside {@link RESPONSE_BUDGET_BYTES},
60+
* so asking for a large `truncate` alongside a large `count` yields shorter payloads rather than
61+
* a retrieval that fails at the transport cap.
4662
*/
4763
function resolveTruncate(truncate: number | undefined, count: number | undefined): number {
64+
const messages = resolveCount(count)
4865
const requested =
4966
typeof truncate === 'number' && Number.isFinite(truncate)
5067
? Math.max(Math.trunc(truncate), 1)
5168
: DEFAULT_TRUNCATE_BYTES
52-
const budgeted = Math.floor(MAX_TOTAL_PAYLOAD_BYTES / resolveCount(count))
53-
return Math.max(Math.min(requested, MAX_TRUNCATE_BYTES, budgeted), 1)
69+
70+
const payloadBudget = RESPONSE_BUDGET_BYTES - messages * PER_MESSAGE_METADATA_RESERVE_BYTES
71+
const perMessageBudget = Math.floor(payloadBudget / messages / BASE64_INFLATION)
72+
73+
return Math.max(Math.min(requested, MAX_TRUNCATE_BYTES, perMessageBudget), MIN_TRUNCATE_BYTES)
5474
}
5575

5676
export const rabbitmqGetMessagesTool: ToolConfig<
@@ -95,7 +115,7 @@ export const rabbitmqGetMessagesTool: ToolConfig<
95115
type: 'number',
96116
required: false,
97117
visibility: 'user-only',
98-
description: `Truncate payloads longer than this many bytes. Defaults to ${DEFAULT_TRUNCATE_BYTES} and is capped at ${MAX_TRUNCATE_BYTES}, and lowered further when a large count would push the response past the transport limit. Each message reports whether it was truncated`,
118+
description: `Truncate payloads longer than this many bytes. Defaults to ${DEFAULT_TRUNCATE_BYTES}, capped at ${MAX_TRUNCATE_BYTES}, and lowered further at high counts so the whole batch stays inside the response limit. Each message reports whether it was truncated`,
99119
},
100120
},
101121

apps/sim/tools/rabbitmq/rabbitmq.test.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ describe('rabbitmq_get_messages bounds', () => {
212212
})
213213

214214
it('caps a count the broker would otherwise accept unbounded', () => {
215-
expect(body({ queue: 'orders', count: 100_000 }).count).toBe(100)
215+
expect(body({ queue: 'orders', count: 100_000 }).count).toBe(50)
216216
expect(body({ queue: 'orders', count: 0 }).count).toBe(1)
217217
expect(body({ queue: 'orders', count: 25 }).count).toBe(25)
218218
})
@@ -420,20 +420,35 @@ describe('rabbitmq_get_messages response budget', () => {
420420
number
421421
>
422422

423-
it('keeps the whole batch under the shared transport response cap', () => {
424-
const TRANSPORT_CAP = 10 * 1024 * 1024
425-
for (const count of [1, 10, 50, 100]) {
423+
const TRANSPORT_CAP = 10 * 1024 * 1024
424+
// `truncate` bounds the payload only — the broker returns AMQP properties in full — so the
425+
// worst case a batch can produce is every message also carrying a full content header frame.
426+
const FRAME_MAX = 131_072
427+
428+
it('keeps payloads plus worst-case message metadata under the transport cap', () => {
429+
for (const count of [1, 2, 10, 25, 50, 100]) {
426430
const sent = body({ queue: 'q', count, truncate: 100_000_000 })
427-
// base64 inflates payloads by 4/3 before JSON escaping, so budget against the worst case.
428-
expect(sent.count * sent.truncate * (4 / 3)).toBeLessThan(TRANSPORT_CAP)
431+
expect(sent.count * (sent.truncate * (4 / 3) + FRAME_MAX)).toBeLessThan(TRANSPORT_CAP)
429432
}
430433
})
431434

435+
it('caps the batch so reserved metadata alone cannot exhaust the budget', () => {
436+
expect(body({ queue: 'q', count: 100_000 }).count).toBe(50)
437+
expect(body({ queue: 'q', count: 100_000 }).count * FRAME_MAX).toBeLessThan(TRANSPORT_CAP)
438+
})
439+
432440
it('caps a single oversized truncate request', () => {
433441
expect(body({ queue: 'q', truncate: 100_000_000 }).truncate).toBe(1_000_000)
434442
})
435443

436-
it('leaves a reasonable truncate untouched', () => {
444+
it('leaves a reasonable truncate untouched at a low count', () => {
437445
expect(body({ queue: 'q', truncate: 20_000, count: 5 }).truncate).toBe(20_000)
438446
})
447+
448+
it('shortens payloads rather than failing the retrieval when count is high', () => {
449+
expect(body({ queue: 'q', truncate: 50_000, count: 2 }).truncate).toBe(50_000)
450+
const high = body({ queue: 'q', truncate: 50_000, count: 50 }).truncate
451+
expect(high).toBeLessThan(50_000)
452+
expect(high).toBeGreaterThanOrEqual(1_024)
453+
})
439454
})

0 commit comments

Comments
 (0)