Skip to content

CLI doesn't surface HTTP status/headers/body that the underlying SDK preserves on non-JSON 4xx responses #312

@wojteninho

Description

@wojteninho

What version are you using?

resend-cli@2.2.1
resend@6.12.2 (transitive)

Describe the Bug

First, thanks for the CLI — it's been a really clean abstraction for the emails send flow, and we've been using it daily. This report is about one edge case where we hit a wall debugging a failure and traced the issue back to information loss between the SDK and the CLI.

I searched the open and closed issues in resend/resend-cli (and a few in resend/resend-node) for send_error, host_not_allowed, and non-JSON 4xx — no matches. Closed #70 is a different validation concern. If I missed an existing report, very happy to redirect there.

The behavior I'm seeing: when the request to api.resend.com is intercepted by a forward proxy or egress filter that returns a non-JSON response (HTTP 4xx with Content-Type: text/plain), the CLI's output drops diagnostic fields that the underlying SDK preserves.

Concretely, the CLI has at least two output code paths and both drop the diagnostic data:

  • Interactive TTY mode emits a single-line human-readable error: Error: Internal server error. We are unable to process your request right now, please try again later.
  • Non-TTY mode (piped, redirected, or run in CI / from a non-interactive shell) emits a JSON envelope: {"error":{"message":"Unable to fetch data. The request could not be resolved.","code":"send_error"}}

Neither mode exposes the actual HTTP statusCode or response headers, even though they're available in the underlying SDK. Running the same request directly against resend@6.12.2 (using RESEND_BASE_URL to point at the same mock proxy) returns:

{
  "data": null,
  "error": {
    "name": "application_error",
    "statusCode": 403,
    "message": "Internal server error. We are unable to process your request right now, please try again later."
  },
  "headers": {
    "content-length": "21",
    "content-type": "text/plain",
    "x-deny-reason": "host_not_allowed"
  }
}

The SDK preserves statusCode: 403 and the response headers (including the diagnostic x-deny-reason: host_not_allowed). The CLI appears to drop those fields when forming its output envelope, in both interactive and non-TTY modes.

There's a secondary observation worth mentioning for context: the SDK's own message field reads "Internal server error... please try again later." — wording that suggests a transient 5xx from Resend's backend, even when the actual response was a non-transient 403 from an intermediary. This may be worth a separate report on resend/resend-node; flagging it here only because it compounds the debugging difficulty alongside the CLI behaviour above.

Why this matters in practice: in a sandboxed CI / cloud environment with an outbound allowlist, we spent ~30 minutes assuming Resend was having a transient outage before realising the request never reached Resend at all — the proxy in between had denied it. curl -v to the same URL revealed the real 403 + x-deny-reason immediately. If those fields had been visible in the CLI output, the debugging would have been ~30 seconds.

To Reproduce

The reproduction doesn't require a real proxy or network — just any local HTTP server returning a non-JSON 4xx. The SDK's RESEND_BASE_URL env var redirects to a local URL.

1. Start a minimal local HTTP server that returns 403 + plain text on POST. Save as fake_proxy.py:

from http.server import BaseHTTPRequestHandler, HTTPServer

class H(BaseHTTPRequestHandler):
    def do_POST(self):
        body = b"Host not in allowlist"
        self.send_response(403)
        self.send_header("Content-Type", "text/plain")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("X-Deny-Reason", "host_not_allowed")
        self.end_headers()
        self.wfile.write(body)

HTTPServer(("127.0.0.1", 9999), H).serve_forever()

Run it: python3 fake_proxy.py

2. Sanity-check via curl:

$ curl -v -X POST http://127.0.0.1:9999/emails -d '{}' 2>&1 | grep -E '^< |Host not'
< HTTP/1.0 403 Forbidden
< Content-Type: text/plain
< Content-Length: 21
< X-Deny-Reason: host_not_allowed
Host not in allowlist

The proxy is returning the expected 403 + x-deny-reason header + plain-text body.

3. Run the CLI against it (interactive TTY):

$ RESEND_BASE_URL=http://127.0.0.1:9999 \
  RESEND_API_KEY=re_fake_key_for_repro \
  npx -y resend-cli@2.2.1 emails send \
    --from "noreply@example.com" \
    --to "delivered@resend.dev" \
    --subject "repro" \
    --html "<p>repro</p>"

Error: Internal server error. We are unable to process your request right now, please try again later.
# Exit code: 1

No trace of the 403 status, the x-deny-reason header, or the response body.

4. Run the CLI piped (non-TTY mode) to see the JSON output path:

$ RESEND_BASE_URL=http://127.0.0.1:9999 \
  RESEND_API_KEY=re_fake_key_for_repro \
  npx -y resend-cli@2.2.1 emails send \
    --from "noreply@example.com" \
    --to "delivered@resend.dev" \
    --subject "repro" \
    --html "<p>repro</p>" | cat

{
  "error": {
    "message": "Unable to fetch data. The request could not be resolved.",
    "code": "send_error"
  }
}

Different message wording and a JSON envelope this time, but the diagnostic fields (statusCode, response headers) are still absent.

5. (Optional) Compare with SDK output to show the fields that are available upstream:

// sdk_repro.mjs
import { Resend } from 'resend';
const resend = new Resend('re_fake_key_for_repro');
const result = await resend.emails.send({
  from: 'noreply@example.com',
  to: 'delivered@resend.dev',
  subject: 'repro',
  html: '<p>repro</p>',
});
console.log(JSON.stringify(result, null, 2));
$ RESEND_BASE_URL=http://127.0.0.1:9999 node sdk_repro.mjs
{
  "data": null,
  "error": {
    "name": "application_error",
    "statusCode": 403,
    "message": "Internal server error. We are unable to process your request right now, please try again later."
  },
  "headers": {
    "content-length": "21",
    "content-type": "text/plain",
    "x-deny-reason": "host_not_allowed",
    ...
  }
}

statusCode and headers are present in the SDK response but absent from the CLI's output in both modes.

Expected Behavior

If the CLI's error path surfaced the statusCode, headers, and (where available) the raw response body that the SDK already has, the diagnostic path would be much shorter. Something along these lines for the JSON output mode:

{
  "error": {
    "code": "send_error",
    "message": "Unable to fetch data. The request could not be resolved.",
    "statusCode": 403,
    "headers": {
      "x-deny-reason": "host_not_allowed",
      "content-type": "text/plain"
    },
    "body": "Host not in allowlist"
  }
}

For the interactive TTY mode, even a short second line citing the status and one or two key headers (e.g. [HTTP 403; x-deny-reason: host_not_allowed]) would be enough to point a reader at the right layer.

That would make it immediately clear whether the failure is at Resend's backend, at a proxy in between, or somewhere else.

I'd be happy to send a PR for this if it's in scope and the change would be welcome — let me know if there's a preferred shape for the error envelope (e.g. add the new fields alongside, or behind a --verbose flag, or only when --json is set). Glad to follow whatever convention you'd prefer.

What's your node version? (if relevant)

v24.6.0

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions