Tracing a 502: from Cloudflare to an unhandled Promise rejection
A walkthrough of tracing a Cloudflare 502 error through Kubernetes logs, uncovering an unhandled Promise rejection inside an npm package that crashed pm2-runtime and restarted the container.
A developer on our team reported that the QA environment was returning a 502 Bad Gateway from Cloudflare. The endpoint was /v1/redirect, a redirect endpoint that calls an external OAuth API to generate a token. Nothing changed on our side. No recent deployments, no config updates. Yet Cloudflare was showing 502.
I opened the pod logs to see what was happening. And I found something confusing.
The log says 401, but Cloudflare says 502
I ran the usual command:
kubectl logs -f <pod-name> -n <namespace> --tail=200
And there it was. The service was logging a 401:
[2026-08-14T10:56:54.377Z] INFO: API Call
url: 'https://<partner-host>/oauth/token'
method: 'POST'
data: '{"clientId":"<client-id>","clientSecret":"<client-secret>"}'
You have triggered an unhandledRejection, you may have forgotten to catch a Promise rejection:
[object Object]
[2026-08-14T10:56:54.465Z] ERROR: Generate access token
error: undefined
[2026-08-14T10:56:54.466Z] FATAL:
message: {"status":401,"statusText":"Error generating access token"}
I told the developer: we got a 401 from the external API. The credentials might be wrong, or the partner API is having issues.
And then the developer asked the question that made me stop and think:
"If the error is 401, why does Cloudflare show 502? Why isn't it forwarding the 401 to the client?"
That was a good question. A 401 is a valid HTTP response. If our service received a 401 from the external API and passed it back to the client, Cloudflare should have shown 401, not 502. The fact that Cloudflare returned 502 meant something else was happening between the 401 and the response.
So I tried to reproduce it locally to understand the behavior.
The environment
The service is a Node.js Express app running on Kubernetes. It uses pm2-runtime as the process manager inside the container. The Dockerfile has this as the entrypoint:
CMD ["./node_modules/.bin/pm2-runtime", "starter.js", "--instances", "1"]
The service calls an external partner API to get an OAuth access token. That token is then used to generate a redirect URL for the user. The external API lives at https://<partner-host>/oauth/token.
The call is made through an internal npm package, which wraps the HTTP request with circuit breaker logic. The credentials are stored in a config file.
Reproducing the crash
I built a minimal reproduction. Two services: a Flask API that returns 401 when no valid token is provided, and a Node.js frontend that calls it. The frontend has two endpoints: one that handles the 401 properly, and one that does not.
When the frontend handles the 401 properly, Cloudflare forwards the 401 to the browser. The status code is preserved. Everything works as expected.
But when the Promise rejects without being caught, the Node.js process crashes. And when the process crashes, the container dies. And when the container dies, the backend is unreachable. Cloudflare, seeing an unreachable backend, returns 502.
That is the answer to the developer's question. The 401 never makes it back to Cloudflare. The service crashes before it can send any response. Cloudflare only sees that the backend is gone, so it returns 502.
The chain of events
I tested the external API directly from inside the pod to confirm:
curl -X POST "https://<partner-host>/oauth/token" \
-H "Content-Type: application/json" \
-d '{"clientId":"<client-id>","clientSecret":"<client-secret>"}'
The response: no healthy upstream.
So the partner's backend was down. The chain looks like this:
- External API backend is down, returns
no healthy upstream. - The internal npm package makes the request, gets an error response, but the error object is
undefined. - The undefined error causes an unhandled Promise rejection.
- Node.js triggers the
unhandledRejectionevent. The service logs it as FATAL. - The process exits. pm2-runtime detects the child process died and stops entirely.
- The container's main process (pm2-runtime) is gone. The container exits.
- Kubernetes restarts the container. During the restart, the backend is unreachable.
- Cloudflare sees the backend as unreachable and returns 502.
The 401 appears in the logs because the service logged it before crashing. But the HTTP response with that 401 status code was never sent to the client. The process was already dead by then.
What I got wrong about pm2-runtime
This is where I wasted some time during the investigation. I assumed pm2-runtime would restart the child process, just like regular pm2 does. I kept checking pm2 list expecting to see the restart count go up. But pm2-runtime is different.
From the logs:
2026-08-14T17:57:23: PM2 log: Stopping app:starter id:0
2026-08-14T17:57:24: PM2 log: App [starter:0] exited with code [1] via signal [SIGINT]
2026-08-14T17:57:24: PM2 log: PM2 successfully stopped
pm2-runtime does not restart the child. It exits the entire container. This is by design. pm2-runtime is meant to run as PID 1 in a container and let the orchestrator handle restarts. When the child dies, pm2-runtime dies too. The container stops. Kubernetes restarts it.
This means the restart takes 10 to 30 seconds. Container startup, database connection, table sync, Redis connection. All of that has to happen again. That 10 to 30 second window is when the backend is unreachable and Cloudflare returns 502.
And since this is the UAT environment, we only have one pod. No replicas. There is no other pod to pick up the traffic while the container restarts. Every request during that window hits a dead backend.
If the service used plain pm2 instead of pm2-runtime, the child process would restart in 1 to 2 seconds and the container would stay alive. But that also means Kubernetes would not know the process crashed, which is a tradeoff.
pm2 vs pm2-runtime, from a DevOps perspective
I come from an ops background, not development, so I had to look this up. Both pm2 and pm2-runtime come from the same npm package. When you run npm install, both binaries end up in node_modules/.bin/. The difference is in how they behave, not what they are.
pm2 is a process manager daemon. You run pm2 start app.js, and it forks itself into the background, then starts your app as a child. If your app crashes, pm2 restarts it. pm2 itself stays alive. This is designed for running on bare metal or VMs, where you want the process manager to keep things running no matter what.
pm2-runtime is the container-friendly version. It does not daemonize. It runs as the main process (PID 1) and starts your app as a child. But here is the key difference: when your app crashes, pm2-runtime also exits. It does not restart the child. It lets the whole container die so the orchestrator (Kubernetes) can handle the restart.
Why would anyone use pm2-runtime then? Because in Kubernetes, you want the container to die when the app dies. That way the liveness probe fails, the restart counter goes up, and your monitoring picks it up. With plain pm2, the container stays alive, Kubernetes thinks everything is fine, and you lose visibility into the crash.
But the tradeoff is the restart time. 1 to 2 seconds versus 10 to 30 seconds is a big difference when you only have one pod and no replicas.
Why the pod name changed (and why it did not matter)
During the investigation, I noticed the pod name changed from <pod>-r8tc2 to <pod>-h4vbl. I initially thought the crash caused the pod to be recreated. It did not.
The pod name change was my own fault. I accidentally ran pm2-runtime list inside the pod, thinking it would show the process list. But pm2-runtime is not a CLI tool. It interprets list as a script path, fails to find it, and exits. That killed the container, and Kubernetes recreated the pod with a new name. This was a self-inflicted restart, completely unrelated to the crash we were investigating.
The actual crash from the unhandled rejection only restarts the container inside the same pod. The pod name stays the same, and the RESTARTS counter in kubectl get pods increments.
To confirm this, I checked:
kubectl get pods -n <namespace> | grep <service-name>
The pod had RESTARTS: 1, not a new pod name from the crash.
What actually happened, and what we did about it
At the end of the day, the root cause was simple: the external API backend was down, returning no healthy upstream. Our service called it, the request failed, and the unhandled rejection crashed the container.
The fix was straightforward: ask the 3rd party to bring their backend back up. Once they fixed their load balancer and the upstream became healthy again, the requests started succeeding. No more 401, no more crash, no more 502.
That said, the incident exposed two weaknesses in our code that are worth noting as tech debt:
- The internal npm package does not handle errors properly. When the HTTP request fails, the error object is
undefined, which causes an unhandled rejection. A well-written package should catch that and reject with a proper error object. - The service has no global unhandled rejection handler. A single
process.on('unhandledRejection', ...)would have logged the error without crashing the process. The service would have stayed alive, returned a proper error response to the client, and Cloudflare would have shown 401 instead of 502. - Using
pm2-runtimeinstead ofpm2means every crash costs 10 to 30 seconds of container restart instead of 1 to 2 seconds of process restart. With only one pod in UAT, that window means complete downtime.
These are not my scope to fix, but they are worth a ticket.
Closing
The question that started this investigation was a good one: "if the error is 401, why does Cloudflare show 502?" The answer is that the 401 never reached Cloudflare. The service crashed before it could send any response. The 401 exists only in the logs, as evidence of what happened. Cloudflare only saw a dead backend.
The external API being down was out of our control. But crashing the entire service because of it was completely avoidable. Your service should never crash because an external dependency is unavailable. Handle it, log it, return an error to the client, and move on.