In Part 1 I fixed the obvious things — contract approvals, the signature type, and filtering out closed markets — and orders finally started flowing. Then, weeks later, they stopped again. Six orders went out over about three and a half hours. All six were rejected. Zero fills. And this time the logs told me nothing useful at all.
This entry is about the worst kind of bug: the one that hides every other bug. As before, I am keeping the money-making details out of it — which wallets, sizing, filters — and sharing only the infrastructure and the debugging, because that is the part that generalizes.
My error handler was throwing errors
When an order is rejected, the only thing that matters is why. So my execution code did the natural thing: on rejection, it logged the response object so I could read the reason. The line was roughly const reason = JSON.stringify(response).
That line was the entire problem.
The rejection response coming back from the HTTP layer was not a clean little JSON object. It carried, somewhere deep in its structure, a reference to the live network socket — a TLSSocket — and sockets reference themselves in loops. When JSON.stringify walks an object that eventually points back to itself, it throws: Converting circular structure to JSON.
So here is what was actually happening on every single rejected order: the exchange returned a perfectly good rejection reason, my code tried to stringify the whole response to log it, the stringify threw its own exception because of the circular socket reference, and that new exception replaced the real reason. Every rejection in my logs read Converting circular structure to JSON. Not one of them told me what the exchange had actually said. I was debugging blind, for hours, because the diagnostic instrument itself was broken.
This is a genuinely nasty class of bug. The system was failing, and the failure that should have explained the failure was overwriting the explanation. It is the software equivalent of a smoke detector that, on detecting smoke, blows a fuse and turns off the lights.
The fix: never let logging throw
The repair had two parts, and both are worth stealing for any project that logs network responses.
First, read the structured error fields before you ever fall back to stringifying the whole object. The exchange was already handing me the reason in a known place; I just was not looking there first. The rejection handler became a simple chain: take errorMsg if present, else data.error, else error, and only as a last resort serialize the object.
Second, make the serialization itself incapable of throwing. A stringify that can crash is not a logger; it is a liability. The standard trick is a replacer backed by a WeakSet that remembers objects it has already visited and substitutes a marker instead of recursing into them:
function safeStringify(obj) {
const seen = new WeakSet();
return JSON.stringify(obj, (key, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) return '[Circular]';
seen.add(value);
}
return value;
});
}
With those two changes, the next rejected order finally printed a real reason instead of a self-inflicted exception. The bug had been masking the truth for so long that simply being able to see the rejection reasons again felt like a new feature. Observability is not a nice-to-have you bolt on later; on a system that talks to an exchange, it is the difference between debugging and guessing.
What the fix revealed underneath
Once the logs were honest again, two more infrastructure problems became visible — both of which had been there the whole time, quietly degrading things while I was blinded by the circular-JSON noise.
Every order was paying for a fresh TLS handshake
My HTTP layer had been configured with connection keep-alive turned off globally. That meant every single request to the exchange opened a brand-new TLS connection, paid the full handshake cost, and threw the connection away. On a casual app you would never notice. On a copy-trading bot, where you are racing to mirror a fast trader before the market moves, an extra 100 to 300 milliseconds per order is not cosmetic — it is the gap between a fill and a rejection.
The fix was to enable keep-alive with a small connection pool — a bounded number of sockets kept warm and reused (think maximum sockets in the low tens, a handful kept free) — so repeated orders reuse an existing encrypted connection instead of rebuilding one each time. Latency you remove from the transport layer is latency you do not have to make up anywhere else.
A slow leak of socket listeners
The runtime had also started printing MaxListenersExceededWarning: 31 listeners — one past Node’s default ceiling of 30. By itself, one extra listener does nothing. But the warning is a symptom: error listeners were accumulating on sockets faster than they were being cleaned up. Left alone over a long-running process, that is a textbook slow memory leak — the kind that does not crash you on day one but degrades you by day ten.
The immediate fix was to raise the ceiling deliberately and reuse a single configured agent rather than spawning listeners ad hoc. The deeper lesson is to treat warnings on a long-lived process as future incidents, not background noise. A bot that is supposed to run for weeks cannot afford to ignore anything that grows without bound.
What I would take away from this one
- Logging must never be able to throw. If your error path can itself fail, your error path will hide the errors you most need to see.
- Read structured fields before serializing. The reason you want is usually sitting in a named field; reach for it before you stringify the universe.
- Circular references hide near anything live. Sockets, streams, and client objects loop back on themselves. Assume any network response can contain one.
- Fix observability first. You cannot debug what you cannot see. Restoring honest logs was the single highest-leverage change in this whole episode.
- Warnings on a long-running process are deferred incidents. Keep-alive and listener limits are not glamorous, but on a bot meant to run for weeks they decide whether it survives.
The theme connecting Part 1 and Part 2 is the same: on a real trading system, the strategy is almost never the thing that breaks first. The signature type, the closed market, the truncated sample, and now a logging line that crashed while trying to report a crash — these are the failures that actually stand between an idea and a working bot. None of them are clever. All of them are silent until you go looking, and the system will rarely tell you which one you are facing.
In the next entry I will move up a layer from execution to selection: how I scan for wallets worth following, why most “profitable” wallets are statistical mirages, and the difference between a trader with an edge and a market-making bot that merely looks like one over a short window.
Disclaimer: This article is a technical development log for informational and educational purposes only. It is not financial advice, not a recommendation to trade prediction markets, and not an endorsement of any platform. Prediction markets are restricted or prohibited in many jurisdictions — check your local laws before participating. Do your own research.