A streaming call worked against one server and quietly refused to stream against another. Same client, same code path, no change on my side. Both requests succeeded at the HTTP level: status 200, a body on the wire, nothing that looked like an error. But against the second server, the events I expected to arrive one at a time never surfaced, and the reader that parses them never ran.
Nothing in the failure pointed at the cause. That is the part worth writing down.
The setup
This was in a client I was building for the Model Context Protocol's Streamable HTTP transport. The shape of that transport is small: the client POSTs a JSON-RPC message to a single endpoint, and the server may answer in one of two ways. It can send back a single application/json document, or it can open a text/event-stream and push a sequence of Server-Sent Events. The client reads the response Content-Type to decide which mode it is in.
So there is a branch, and the branch looked like this:
ct := resp.Header.Get("Content-Type")
if ct == "text/event-stream" {
return readSSE(resp.Body)
}
return readJSON(resp.Body)
Read it once and it looks obviously correct. It is not.
The header I wasn't looking at
The server I had been testing against sent Content-Type: text/event-stream. Bare. My equality check passed, the SSE path ran, everything was green. Then I pointed the same client at a different server and streaming went dark.
I logged the raw header. It said:
Content-Type: text/event-stream; charset=utf-8
That trailing ; charset=utf-8 is a media type parameter, and it is completely legal. Content-Type is defined as a media type followed by optional parameters, and charset is the standard example. Both servers were compliant. One of them just chose to be explicit about the encoding.
My == did not care about any of that. The string text/event-stream; charset=utf-8 is not equal to text/event-stream, so the check returned false, the code took the non-streaming branch, and the SSE reader never touched the body. No exception, because nothing illegal was happening. Just the wrong branch.
The fix
The header has structure, so parse it and compare the part that carries the meaning. Go ships this in the standard library:
mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
if err != nil {
return nil, fmt.Errorf("parse content-type: %w", err)
}
if mediaType == "text/event-stream" {
return readSSE(resp.Body)
}
return readJSON(resp.Body)
mime.ParseMediaType splits the media type from its parameters and returns both. Give it text/event-stream; charset=utf-8 and you get back text/event-stream plus a map holding charset set to utf-8. It also lowercases the media type, which matters more than it looks. Media types are case-insensitive, so a server sending Text/Event-Stream is still sending an event stream, and my original == would have missed that one too. I confirmed this against the standard library: Text/Event-Stream; charset=UTF-8 parses straight to text/event-stream.
That is the whole fix. One import, three lines instead of one.
Why this kind of bug hides
The buggy version passes tests. That is the trap. Every test I ran against my own server was green, because that server sent the bare type. The bug only lives in the space between two compliant peers: mine, which demanded an exact string, and theirs, which added a parameter the spec explicitly allows. This is Postel's law showing up as a bug report. I was not being liberal in what I accepted, and there was no failure loud enough to say so.
You cannot test your way out of it by adding more cases against the same lenient server. Every one of them will pass. The fix is to stop comparing structured data as if it were a flat string.
The rule
Never string-compare a structured HTTP header. Content-Type carries parameters. So does Accept. Cache-Control is a list of directives. Authorization has a scheme plus a credential. Each one has a grammar, and == against a literal ignores the grammar and happens to work only for the inputs you already saw.
For the media type family, use the parser your language already gives you:
-
Go:
mime.ParseMediaTypeto read a header,mime.FormatMediaTypeto build one. -
Node.js: the
content-typepackage'sparse(), ortype-iswhen you only want a match. -
Python:
cgi.parse_headerused to do this, but thecgimodule is gone as of 3.13. Useemail.message.EmailMessageand callget_content_type(), which returns the lowercased type without parameters. -
Rust: parse into a
Mimefrom themimecrate and compareessence_str().
Different names, same idea. Turn the header into a typed value and compare the field you actually mean.
One caveat
Parsing moves a failure mode; it does not delete it. mime.ParseMediaType("") returns an error, so a missing Content-Type header now lands in the error branch instead of silently taking the JSON path. That is arguably more correct, but it is a behavior change, and you have to decide what an absent or malformed header should mean for your client. I treat a parse failure as "not a stream" and move on, but that is a choice, not a rule.
The bug itself was dull. That is sort of the point. A line that reads as obviously correct was wrong for every input except the ones I had tested, and an HTTP header is exactly the kind of structured value that tempts you into a lazy ==.