Content-Type 描述发送方打算提供的表示,但它不会检查正文、转换字节,也不保证所有接收方都采取相同的处理方式。可靠的接口需要让序列化结果、媒体类型声明和消费者的预期相互吻合。这个要求不仅适用于成功响应,也适用于认证失败、代理报错和应用异常等不同的返回路径。

描述表示,而不是文件名

对于携带正文的普通响应,application/json 表示 JSON 数据,text/html 表示 HTML。类型描述的是解除内容编码之后的数据格式。因此,一份经过 gzip 压缩的 JSON 响应仍然声明 Content-Type: application/json,同时用 Content-Encoding: gzip 说明压缩。内容编码属于表示的属性,不等于 HTTP 消息如何分帧;例如分块传输编码处理的是另一层问题。修改文件扩展名或响应头都不会自动完成这些转换。

媒体类型由主类型、子类型以及可选参数构成。主类型和子类型不区分大小写,参数是否有意义则取决于对应媒体类型的定义。消费者应使用已有的头字段解析接口,不要把完整字段与一个固定字符串直接比较。同时必须单独检查字段是否存在,因为部分解析器会给缺失类型提供默认值,掩盖服务端漏发元数据的事实。多个相互冲突的类型字段也不是合法的候选列表,而是需要在源站或代理处修正的缺陷。

对于 UTF-8 编码的 HTML,text/html; charset=utf-8 同时描述格式与字符编码。但不能据此给每一种文本格式统一追加字符集参数。RFC 8259 要求非封闭生态系统之间交换的 JSON 使用 UTF-8,而 application/json 的媒体类型注册没有定义 charset 参数。接收方不能靠另加一个参数,就把不符合该交换约定的字节变成合规 JSON。类型参数必须服从格式本身的规定,而不是套用全站通用字符串。

坏掉的样子

常见故障是反向代理把 API 响应替换成 HTML 登录页,却仍返回成功状态码。客户端一上来就调用 JSON 解析器,只能看到语法错误,反而隐藏了“收到另一种表示”这个更有价值的线索。另一种情况恰好相反:应用错误处理器输出 HTML,却沿用了成功路径的 JSON 响应头。这时仅检查声明也会放行无效正文。所以,状态码、响应元数据和实际字节是三个需要一起检查的证据。

缺失类型并不意味着所有客户端都会拒绝响应。RFC 9110 允许接收方把它当作不透明二进制数据,也允许检查正文来确定类型;部分用户代理还会通过嗅探覆盖已有声明。浏览器的具体行为与处理上下文有关,不能用一个命令行 JSON 客户端来证明所有页面、脚本或其他资源都安全。正确媒体类型是必要的元数据,但既不能净化 HTML,也不能证明用户上传的文件适合公开提供。内容安全需要单独依据实际使用场景验证。

快速验证

使用 Python 3.11 或更新版本运行下面的完整程序。它在回环地址申请临时端口,发出四次真实 HTTP 请求,最后关闭连接与服务器,不访问外网,也不安装依赖。实验消费者明确只接受 application/json,然后按 UTF-8 解码并解析 JSON。这是一个范围明确的消费者契约,不是通用内容协商器,也没有模拟浏览器的完整处理规则。

import json
from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread

cases = {
    "/ok": ("application/json", b'{"ok":true}'),
    "/wrong-type": ("text/plain", b'{"ok":true}'),
    "/bad-body": ("application/json", b"not JSON"),
    "/missing-type": (None, b'{"ok":true}'),
}


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        media, body = cases[self.path]
        self.send_response(200)
        if media is not None:
            self.send_header("Content-Type", media)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *args):
        pass


server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
worker = Thread(target=server.serve_forever)
worker.start()
try:
    outcomes = []
    for route in cases:
        connection = HTTPConnection("127.0.0.1", server.server_port, timeout=3)
        try:
            connection.request("GET", route)
            response = connection.getresponse()
            body = response.read()
            assert response.status == 200
            if response.headers.get("Content-Type") is None:
                outcome = "missing type"
            elif response.headers.get_content_type() != "application/json":
                outcome = "wrong type"
            else:
                try:
                    value = json.loads(body.decode("utf-8"))
                except (UnicodeDecodeError, json.JSONDecodeError):
                    outcome = "invalid body"
                else:
                    assert value == {"ok": True}
                    outcome = "accepted"
            outcomes.append(outcome)
            print(route, outcome)
        finally:
            connection.close()
    assert outcomes == ["accepted", "wrong type", "invalid body", "missing type"]
finally:
    server.shutdown()
    server.server_close()
    worker.join()

按路径顺序,结果应为 accepted、wrong type、invalid body、missing type,分别表示接收成功、类型错误、正文无效、缺失类型。第一、第二和第四条响应具有完全相同的正文,结果却不同,因此隔离出了声明检查的作用。第三条声明正确,但正文不能按 JSON 语法解析,证明标签检查无法替代内容验证。程序断言这些结果,出现不一致会直接失败;这些拒绝规则属于实验消费者,不是要求所有 HTTP 客户端必须作出相同选择。

排查实际故障时,应从真实失败的认证或匿名访问路径获取 GET 响应,但不要把凭据写进诊断输出。还要记录重定向后的最终地址,因为原本要访问的资源与登录页是不同端点。比较源站与代理的结果,既测成功路径,也测错误处理路径。仅查看 HEAD 元数据无法证明正文可以解析;仅在服务器本机打开文件,也无法证明公网经过代理后的响应仍然一致。找到第一次引入不一致的组件,在那里修复,然后沿完整交付路径再次请求并验证正文。不要用客户端猜类型来掩盖错误页面被当成 API 响应的问题。

延伸阅读

来源

  • RFC 9110:Content-Type,媒体类型、字段缺失、嗅探以及相邻章节的内容编码定义,已核验 2026-09-07。
  • RFC 8259:JSON,UTF-8 交换要求与 application/json 注册,已核验 2026-09-07。