Skip to content

sendRequest hangs forever on unexpected error responses #80

Description

@aboltunov

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions