Rocket.Chat 429 Too Many Requests: Stop Polling and Use the Realtime API
A Node.js integration that polled Rocket.Chat’s REST API worked in testing and failed for every visitor at once in production. The limiter was doing its job. The design was wrong. Here is the failure, the numbers, and the websocket client that fixed it.
The OpenClaw sales page has a box where a visitor can ask the product a question and get an answer from a live worker. Behind it, the web server posts the question into that worker’s private Rocket.Chat room, tagged with a random marker, and waits for a bot in the room to reply with the same marker. Simple, and for a while it worked.
Then a visitor asked a question that took the worker forty seconds to answer, and for the next minute every other visitor got an error. Not a slow answer. A failure, on the first try, for everyone.
What the rate limiter actually counts
Rocket.Chat’s REST API ships with a rate limiter turned on, and the stock setting is 10 calls per 60,000 milliseconds, per endpoint, per client IP address. Every response tells you where you stand:
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1756000123456 # epoch milliseconds
HTTP/1.1 429 Too Many Requests
{"success":false,"error":"Error, too many requests. Please slow down. You must wait 60 seconds before trying this endpoint again [error-too-many-requests]"}Read those three words again: per client IP. A server-side integration is one client. It does not matter that it is acting for a hundred different people; to the limiter it is one address hitting one endpoint, and every visitor it serves draws from the same ten calls.
My integration polled groups.history every 1.5 seconds while it waited for an answer. That is forty calls a minute against a budget of ten. A fast answer arrived in one or two polls and nobody noticed. A slow answer burned the budget in fifteen seconds, and then every question, from every visitor, failed for the rest of the minute with a 429 on its first poll. The limiter was not being unfair. Polling had turned one slow answer into an outage for the endpoint.
Raising the limit is allowed, and it is not the fix
The limit is a setting. For a trusted server-side integration on your own instance it is reasonable to raise it, and you can do it through the settings API without a restart:
# Log in as an admin, then write the two defaults. Rocket.Chat rebuilds its
# limiter rules when a setting changes; no restart.
POST /api/v1/settings/API_Enable_Rate_Limiter_Limit_Calls_Default {"value": 600}
POST /api/v1/settings/API_Enable_Rate_Limiter_Limit_Time_Default {"value": 60000}
# {"value": 10} on the first one restores the stock behaviour.I did that, and I kept a script for it because setting ids have moved between versions. But raising the ceiling only changes when the same failure happens. Polling was still the wrong shape for the job: it pays a call for every look, it adds up to a poll interval of latency to every answer, and it shares the endpoint’s budget with every other integration on that server. The right fix was to stop asking whether a message had arrived and be told when it did.
The realtime API is a subscription, not a request
Rocket.Chat also speaks DDP, Meteor’s protocol, over a websocket at /websocket. You connect once, log in once, subscribe to a room’s message stream, and the server pushes each new message to you as it is posted. Sending a message is a method call on the same connection. There is no endpoint to exhaust, because after the subscription there are no calls at all.
The whole exchange, for one question, is six frames in the client’s direction:
→ {"msg":"connect","version":"1","support":["1"]}
← {"msg":"connected","session":"…"}
→ {"msg":"method","id":"1","method":"login","params":[{
"user":{"username":"demo-bot"},
"password":{"digest":"<sha256 hex of the password>","algorithm":"sha-256"}}]}
← {"msg":"result","id":"1","result":{"id":"…","token":"…"}}
→ {"msg":"method","id":"2","method":"getRoomIdByNameOrId","params":["openclaw1-demo"]}
← {"msg":"result","id":"2","result":"<room id>"}
→ {"msg":"sub","id":"3","name":"stream-room-messages","params":["<room id>",false]}
← {"msg":"ready","subs":["3"]}
→ {"msg":"method","id":"4","method":"sendMessage","params":[{"_id":"<17 chars>","rid":"<room id>","msg":"…"}]}
← {"msg":"changed","collection":"stream-room-messages",
"fields":{"eventName":"<room id>","args":[{ "msg":"…","u":{"username":"bridge-bot"}, … }]}}Every new message in the room arrives as a changed frame whose collection is stream-room-messages and whose fields.eventName is the room id, with the message object in fields.args. The server also sends ping frames that you must answer with pong, or it will close the socket.
A small DDP client in Node.js
There are libraries for this, but the protocol is small enough that a hundred lines with the ws package is easier to own than a dependency. This is the shape of the client that runs in production, trimmed to its essentials: method calls and subscriptions are promises keyed by id, and everything else is a listener.
import WebSocket from "ws";
class DdpSession {
nextId = 1;
pending = new Map(); // method id -> { resolve, reject }
subs = new Map(); // subscription id -> { resolve, reject }
listeners = new Set();
static connect(origin) {
const socket = new WebSocket(origin.replace(/^http/, "ws") + "/websocket");
return new Promise((resolve, reject) => {
const session = new DdpSession(socket);
socket.once("open", () => session.send({ msg: "connect", version: "1", support: ["1"] }));
const off = session.onMessage((m) => {
if (m.msg === "connected") { off(); resolve(session); }
if (m.msg === "failed") { off(); reject(new Error("connect rejected")); }
});
});
}
constructor(socket) {
this.socket = socket;
socket.on("message", (data) => this.receive(JSON.parse(data.toString())));
}
call(method, params) {
const id = String(this.nextId++);
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.send({ msg: "method", id, method, params });
});
}
subscribe(name, params) {
const id = String(this.nextId++);
return new Promise((resolve, reject) => {
this.subs.set(id, { resolve, reject });
this.send({ msg: "sub", id, name, params });
});
}
onMessage(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
send(payload) { this.socket.send(JSON.stringify(payload)); }
receive(m) {
if (m.msg === "ping") return this.send({ msg: "pong", ...(m.id ? { id: m.id } : {}) });
if (m.msg === "result") {
const p = this.pending.get(m.id); this.pending.delete(m.id);
m.error ? p?.reject(new Error(m.error.reason ?? m.error.message)) : p?.resolve(m.result);
}
if (m.msg === "ready") for (const id of m.subs) { this.subs.get(id)?.resolve(); this.subs.delete(id); }
if (m.msg === "nosub") { this.subs.get(m.id)?.reject(new Error(m.error?.reason ?? "refused")); this.subs.delete(m.id); }
for (const l of this.listeners) l(m);
}
}And the question itself. The order matters: subscribe first, then send, so a fast reply cannot arrive in the gap before the subscription is ready. The marker is how one question’s answer is told apart from another’s in a shared room, and the deadline is how a worker that never answers stops costing a socket.
import crypto from "crypto";
async function askWorker({ origin, user, password, room, bot }, question, marker, deadlineMs) {
const session = await DdpSession.connect(origin);
try {
await session.call("login", [{
user: { username: user },
password: { digest: crypto.createHash("sha256").update(password).digest("hex"), algorithm: "sha-256" },
}]);
const rid = await session.call("getRoomIdByNameOrId", [room]);
const answer = new Promise((resolve) => {
session.onMessage((m) => {
if (m.msg !== "changed" || m.collection !== "stream-room-messages" || m.fields?.eventName !== rid) return;
for (const posted of m.fields.args ?? []) {
if (posted.u?.username === bot && typeof posted.msg === "string" && posted.msg.includes(marker)) resolve(posted.msg);
}
});
});
await session.subscribe("stream-room-messages", [rid, false]);
await session.call("sendMessage", [{ _id: randomId(), rid, msg: question }]);
const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("no answer")), deadlineMs));
return await Promise.race([answer, timeout]);
} finally {
session.socket.close();
}
}Three details that cost me time:
- The password goes over as a SHA-256 digest, not plaintext, with
algorithm: "sha-256"beside it. The REST login takes plaintext; the DDP login does not. - Room names are not room ids. The subscription wants the id.
getRoomIdByNameOrIdresolves it once, and it is safe to cache. - The
_idon a sent message is yours to generate. Seventeen characters from Meteor’s alphabet is the convention, and supplying it makes the send idempotent if you ever retry. - In Next.js, put
wsinserverExternalPackages. It is a native-flavoured package that the bundler should leave alone.
Keep REST, but as a fallback that behaves
Websockets fail for boring reasons: a proxy that drops idle connections, a restart mid-question. When the realtime path throws, the integration falls back to posting with chat.postMessage and polling groups.history, but the polling now respects the limiter instead of fighting it. One quick look after 1.5 seconds, then every 6.5 seconds, which is under ten calls a minute on its own, and a 429 is not an error: it is an instruction to sleep until the timestamp in X-RateLimit-Reset, capped by the question’s own deadline.
async function waitForRateLimit(response, deadlineAt) {
const resetAt = Number(response.headers.get("x-ratelimit-reset"));
const now = Date.now();
if (now >= deadlineAt) throw new Error("no answer");
const wait = Math.min(Math.max((resetAt || now + 5_000) - now, 1_000), deadlineAt - now);
await new Promise((r) => setTimeout(r, wait + 250));
}Every answer records which transport delivered it. Since the switch, the realtime path has carried every answer that arrived at all; the fallback has only ever been reached when the worker itself was down, and then it fails at the deadline like it should.
Two things at the edges
Deadlines have to fit the path in front of you. The origin sits behind Cloudflare, which gives an idle response about sixty seconds. The question’s deadline is fifty-five, so a slow worker produces a clean timeout from my code rather than a proxy error from theirs.
Return 503 for a failure, not 502 or 504. Cloudflare replaces a 502 or 504 body from the origin with its own error page, so a JSON error the front end could have shown becomes a wall of orange. A 503 passes through untouched.
What to take from this
- Rocket.Chat’s REST limiter is per endpoint per IP. A server-side integration shares one budget across all of its users.
- Polling for a reply is the worst way to spend that budget, and one slow reply is enough to lock the endpoint for everyone.
- The realtime API costs one connection and one subscription, then nothing per message. Subscribe before you send.
- Read
X-RateLimit-Resetand sleep until it. Never retry a 429 on a fixed interval. - Raise the limit for trusted integrations if you like, through the settings API, without a restart. Then fix the design anyway.
Frequently asked questions
- What is Rocket.Chat's default REST API rate limit?
- Ten calls per 60,000 milliseconds, applied per endpoint and per client IP address. The settings are Enable Rate Limiter, Default number calls to the rate limiter, and Default time limit for the rate limiter under Administration, General, REST API. A server-side integration counts as one client, so every user it serves shares that budget.
- How do I read Rocket.Chat's rate limit headers?
- Every REST response carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset. Reset is a timestamp in milliseconds. When you get a 429, sleep until that timestamp instead of retrying on a fixed interval, and cap the sleep by your own deadline.
- How do I raise the Rocket.Chat rate limit without a restart?
- Write the settings API_Enable_Rate_Limiter_Limit_Calls_Default and API_Enable_Rate_Limiter_Limit_Time_Default through the admin UI or with POST /api/v1/settings/{id}. Rocket.Chat rebuilds its limiter rules when the settings change; no restart is needed. Raising it is reasonable for a trusted server-side integration, but it does not fix a design that polls.
- Does the Rocket.Chat realtime API have a rate limit too?
- Yes, a separate DDP limiter with its own settings: by IP, by user, by connection, and per method. The defaults are far higher than the REST limiter's, and a subscription delivers messages without any call at all, so a client that subscribes once and sends one method per action stays well inside them.
- How do I receive new messages from a room in real time with Node.js?
- Open a websocket to /websocket, send the DDP connect message, call login, subscribe to stream-room-messages with the room id, and handle the changed messages whose collection is stream-room-messages and whose eventName is that room id. Each carries the new message in fields.args. The full client is in this article.
Related
- How I host 100+ OpenClaw instances: the fleet whose chat this integration talks to.
- Event sourcing on SQLite in production: every question and answer here is an event, transport included.
- Xero was operational; I still could not connect: why the demo box is a synthetic check as much as a feature.
Comments 0
No comments yet. Start the conversation.