Welcome back. This is the fourth article about my Purchase Decision API, after error handling, validation and the AI feature. Those three were about building it. This one is about the day it broke in a way that made no sense.
Postman said POST. The server said GET. Both were telling the truth.
The API worked on my machine. I deployed it to Railway, sent the first request, and got a 500. Not a validation error, not an auth error. Just a generic failure on the endpoint that starts the entire flow.
It took about a day to find, and the fix was three characters long. The interesting part is not the bug. It is that my own error handling had made the bug invisible, and I had written that error handling on purpose, and I had published an article about how nice it was.
The symptom that made no sense
Every POST and PUT failed on the deployed URL. POST /api/auth/register is where I fought it, because it is the first call in the flow and nothing else works until it does.
In Postman, the method dropdown said POST. The body was valid JSON. I screenshotted it more than once, because at some point I stopped trusting my own eyes.
The platform logs said something else:
HttpRequestMethodNotSupportedException: Request method 'GET' is not supported
Over and over. A GET error, on an endpoint I was only ever hitting with POST.
So I stopped believing the logs. Not consciously, but that is what it amounted to. The working theory was healthcheck pings, or a browser tab somewhere replaying an old URL. Anything except the obvious reading, which was that my request was arriving as a GET, because that was impossible.
The logs had the answer the whole time. I filed it as noise.
The false leads
The wrong turns are most of the story, so here they are.
I chased the JWT secret first. It was still the literal placeholder your-jwt-secret-value, twenty-one characters, under the minimum length HMAC needs. A genuine bug. I fixed it, redeployed, and the 500 did not move. That is the most expensive kind of red herring: a real problem that is not the problem. Finding it felt like progress and bought me nothing.
Then I decided the environment variables were not saving, because Railway's UI does not commit an edit until you click the tick and then deploy, which caught me twice. Then I decided I was testing a stale deploy, and for one round that was actually true: the commit that added the logging had not been pushed, so I was reading logs from old code.
Every theory had the same shape. The cause was out there, in the platform, somewhere I could not inspect. Which conveniently meant I never had to check the one thing I could inspect, which was what my server was actually receiving.
Bringing the error to me
The logs were flooded and I was tired of scrolling. So instead of going to the evidence, I brought the evidence to me.
My global handler was returning a polite, useless message. I changed it temporarily to say what had actually happened:
new ErrorResponse(500, "DEBUG: " + ex.getClass().getSimpleName() + " — " + ex.getMessage(), ...)
That is not code to leave in. Exception details in a response body are a security problem, and this came straight back out afterwards. But for one deploy, it turns your API client into your log viewer.
I sent the request again, as a POST, and read the response:
DEBUG: HttpRequestMethodNotSupportedException — Request method 'GET' is not supported
That was the moment. Not a healthcheck. Not a stray browser tab. The request I had just sent, arriving as a GET.
Three characters
I had typed the URL into Postman without a scheme. Postman filled in http://. The platform edge answered with a redirect to https://. Postman followed the redirect and re-issued the request as a GET, with no body.
The fix was typing https:// myself.
No config change. No nginx, no code. I had spent a day inside my application hunting a bug that happened before my application was ever reached.
Redirects are allowed to change your method
This is the part I did not know, and it is why the symptom looked impossible.
Think about posting a filled-in form to an office. The office has moved, so the forwarding service sends your letter on to the new address, except what it delivers is an empty envelope with a note saying this person would like to look at something. The address is honoured. The form you filled in, and the fact that you were submitting rather than browsing, are not.
That is what a redirect is permitted to do to a POST.
A 301 or a 302 is permission for a client to rewrite the request method to GET and drop the body. This is not the client misbehaving. RFC 7231 codified it because it is what browsers had always done, back when redirects mostly pointed at pages to look at rather than forms to submit. The specification is now in line with the behaviour.
The body disappears for the same reason. A GET is a request to fetch something, so a client that rewrites your POST into a GET has no reason to carry your JSON along. What reaches the server is not a damaged POST. It is a perfectly well formed GET that nobody meant to send.
Because this caused years of confusion, there are now codes that promise not to do it:
- 301, 302: the client may change your POST to a GET. Most do.
- 307, 308: the method is preserved. These exist specifically to fix the above.
- 303: always converts to GET, deliberately, for the redirect-after-submit pattern.
So "redirects break POST requests" is too broad to be true. The accurate version is narrower and more useful: a 301 or a 302 is allowed to turn your POST into a GET, and your client probably will.
I never captured which code my platform sent, so I will not claim one. The behaviour narrows it down on its own, which is useful to know: if a POST comes out the other side of a redirect as a GET, the response was one of the codes that permit it. A 307 or a 308 could not have produced what I saw.
Postman is one of those clients that rewrites. Its own documentation has a page titled "My request is redirected to a GET request", and its tracker has an issue describing my exact bug: http to https, POST arriving as GET. There is a per-request setting called Follow original HTTP method that stops this, and it is off by default.
Two practical things, then. Type the scheme, and turn that setting on. If you suspect this is happening to you, Postman's console shows the full redirect chain, so you can see the hop and the method change rather than inferring them.
My complaints desk shredded the evidence
Now the part that bothers me.
Spring already had the answer. HttpRequestMethodNotSupportedException has a natural response, and it is 405 Method Not Allowed. A 405 has the rejected method right there in it. A 405 on a request I had just sent as POST would have stopped me in minute one, because the contradiction would have been sitting in the response instead of buried in a log I had decided to distrust.
I never saw the 405. I had written this, and then written an article praising it:
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleAll(Exception ex) {
return ResponseEntity.status(500)
.body(new ErrorResponse(500, "Something went wrong", ...));
}
Every exception in the application, including the ones Spring throws with a precise status and a precise message, came out the other side as an anonymous 500. The complaints desk I was so pleased with was taking every complaint, writing "something went wrong" on a fresh form, and shredding the original.
The handler did not cause the bug. It replaced a self-diagnosing 405 with an anonymous 500, and destroyed the evidence. Then it did something slower and worse: it taught me my own error output was uninformative, which is why I dismissed those logs for a day.
I wrote nearly this same sentence three weeks ago, about an AI fallback that hid its own trigger and cost me days over one missing space. I had not finished learning it. A fallback that swallows silently and a handler that flattens everything are the same mistake at two sizes: the system keeps working well enough to stop telling you anything.
Honest notes
A few things this does not settle.
The catch-all is not wrong in itself. Something has to be the last line of defence, or an unexpected exception leaks a stack trace to a user. The fix is not deleting it. The fix is logging inside it, and letting framework exceptions that already carry a correct status and message pass through instead of collapsing them into a 500.
The DEBUG trick is a temporary tool, not a pattern. It puts internal exception detail into a response body. That is fine for one deploy on a project nobody is using yet. It is not fine anywhere real.
And the hole is still open. My OpenAPI config does not declare a server URL, so Swagger's "Try it out" resolves against whatever scheme the page was loaded with. One line, .addServersItem(new Server().url("https://...")), would close it. Writing this article is what made me go and check, and finding it missing is a more honest ending than claiming I had already tidied it up.
Recap
My POST request really was arriving as a GET. I had typed a URL without a scheme, the client defaulted to http, the platform redirected to https, and the client did what 301 and 302 allow: re-issued the request as a GET and dropped the body. Use 307 or 308 when the method has to survive, and turn on Follow original HTTP method in Postman.
Underneath that is the lesson I keep having to relearn. My global exception handler turned a 405 that named the problem into a 500 that named nothing, and a day of my time went into rediscovering what Spring had been ready to tell me immediately.
Handle your exceptions. Just do not let the handling become the reason you cannot see them.
When a framework exception already carries a precise status and message, like a 405 or a 415, do you pass it straight through to the client, or normalise everything into one consistent error shape and accept the loss? I have been going back and forth on this and would like to hear which side you land on.
P.S. If this was useful, subscribe. I write one piece like this every week.


Top comments (0)