Summary
sendRequest in lib/Client.ts can return a promise that never settles, so any gateway call hangs indefinitely. The failure surfaces only as an unhandledRejection, which the caller's try/catch cannot observe.
Cause
return new Promise<T>((resolve, reject) => { // (A) — what the caller awaits
axios(options)
.then((response: any) => { /* settles (A) */ })
.catch((error: any) => { // (B) — held by nothing
const response = error.response;
const validationErrors: ValidationError[] = response.data.errors.map(...); // can throw
switch (response.status) { /* settles (A) */ }
});
});
If the .catch callback throws, it rejects (B), the promise returned by .catch(), which nothing holds or returns. (A) is never resolved or rejected, so it stays pending for the life of the process.
Two ways to reach the throw:
error.response is undefined — any transport failure: connection reset, socket hang up, DNS, or an axios timeout (rejects ECONNABORTED with no response) → TypeError: Cannot read properties of undefined (reading 'data')
response.data has no errors array — e.g. a 502/503 from a proxy or gateway returning HTML rather than the API's shape → TypeError: Cannot read properties of undefined (reading 'map')
Only a response that is both present and {errors: [...]}-shaped reaches the switch and settles the promise.
Reproduction
// npm i trolleyhq && node repro.js
const http = require("http");
const trolleyhq = require("trolleyhq");
process.on("unhandledRejection", (e) =>
console.log(` unhandledRejection: ${e && e.message}`),
);
function scenario(name, handler) {
return new Promise((resolve) => {
const server = http.createServer(handler);
server.listen(0, "127.0.0.1", () => {
const client = trolleyhq.connect({
key: "k",
secret: "s",
apiBase: `http://127.0.0.1:${server.address().port}/v1/`,
});
let settled = false;
client.batch.create({}).then(
() => (settled = true),
() => (settled = true),
);
setTimeout(() => {
console.log(`${settled ? "ok " : "HANGS "} ${name}`);
server.close();
resolve();
}, 1000);
});
});
}
(async () => {
await scenario('400 with {"errors":[...]}', (req, res) => {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(
JSON.stringify({ errors: [{ code: "x", field: "y", message: "z" }] }),
);
});
await scenario("502 from a proxy, HTML body (no .errors)", (req, res) => {
res.writeHead(502, { "Content-Type": "text/html" });
res.end("<html>502 Bad Gateway</html>");
});
await scenario("connection reset before any response", (req) =>
req.socket.destroy(),
);
process.exit(0);
})();
Output on 1.2.0:
ok 400 with {"errors":[...]}
unhandledRejection: Cannot read properties of undefined (reading 'map')
HANGS 502 from a proxy, HTML body (no .errors)
unhandledRejection: Cannot read properties of undefined (reading 'data')
HANGS connection reset before any response
Affected versions
1.1.0, 1.2.0 and current master — lib/Client.ts is unchanged across all three.
Both cases were handled before #65 ported the client from request to axios: it rejected transport errors explicitly, and wrapped the parsing in a try/catch that rejected rather than letting the throw escape.
Summary
sendRequestinlib/Client.tscan return a promise that never settles, so any gateway call hangs indefinitely. The failure surfaces only as anunhandledRejection, which the caller'stry/catchcannot observe.Cause
If the
.catchcallback throws, it rejects (B), the promise returned by.catch(), which nothing holds or returns. (A) is never resolved or rejected, so it stays pending for the life of the process.Two ways to reach the throw:
error.responseis undefined — any transport failure: connection reset, socket hang up, DNS, or an axios timeout (rejectsECONNABORTEDwith no response) →TypeError: Cannot read properties of undefined (reading 'data')response.datahas noerrorsarray — e.g. a 502/503 from a proxy or gateway returning HTML rather than the API's shape →TypeError: Cannot read properties of undefined (reading 'map')Only a response that is both present and
{errors: [...]}-shaped reaches theswitchand settles the promise.Reproduction
Output on 1.2.0:
Affected versions
1.1.0, 1.2.0 and current
master—lib/Client.tsis unchanged across all three.Both cases were handled before #65 ported the client from
requestto axios: it rejected transport errors explicitly, and wrapped the parsing in atry/catchthat rejected rather than letting the throw escape.