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)
What version are you using?
Describe the Bug
First, thanks for the CLI — it's been a really clean abstraction for the
emails sendflow, 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 inresend/resend-node) forsend_error,host_not_allowed, andnon-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.comis intercepted by a forward proxy or egress filter that returns a non-JSON response (HTTP 4xx withContent-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:
Error: Internal server error. We are unable to process your request right now, please try again later.{"error":{"message":"Unable to fetch data. The request could not be resolved.","code":"send_error"}}Neither mode exposes the actual HTTP
statusCodeor responseheaders, even though they're available in the underlying SDK. Running the same request directly againstresend@6.12.2(usingRESEND_BASE_URLto 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: 403and the response headers (including the diagnosticx-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
messagefield 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 onresend/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 -vto the same URL revealed the real 403 +x-deny-reasonimmediately. 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_URLenv 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:Run it:
python3 fake_proxy.py2. Sanity-check via curl:
The proxy is returning the expected 403 +
x-deny-reasonheader + plain-text body.3. Run the CLI against it (interactive TTY):
No trace of the 403 status, the
x-deny-reasonheader, or the response body.4. Run the CLI piped (non-TTY mode) to see the JSON output path:
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:
statusCodeandheadersare 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 responsebodythat 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
--verboseflag, or only when--jsonis set). Glad to follow whatever convention you'd prefer.What's your node version? (if relevant)