Go 已写正文后再返回 500:ResponseWriter 的提交边界
日志里明明调用了 http.Error(..., 500),客户端却收到 200。处理器前面如果已经执行过一次 Write,这份响应很可能早就提交了成功状态;后面再报错,只是在原来的正文后追加了一段错误文字。
Go 的 ResponseWriter 不会把已经发出的字节撤回来,也不会因为最后一行代码看起来更严重就替换先前状态。排查这类问题,应先找第一次最终响应头或正文写出发生在哪里,而不是只盯着最后的错误分支。
下面固定 Go 1.24.1,用 httptest 比较四个处理器:先写后报错、先校验后写、先生成到缓冲,以及已经开始的流在正文里报告终止。它们针对的是不同阶段,不能用同一种错误返回策略处理。
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
)
func lateError(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, "partial result\n")
http.Error(w, "generation failed", http.StatusInternalServerError)
}
func validateFirst(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("valid") != "yes" {
http.Error(w, "invalid input", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true})
}
func bufferFirst(w http.ResponseWriter, r *http.Request) {
var staged bytes.Buffer
generate := func(destination io.Writer) error {
_, _ = io.WriteString(destination, "partial result\n")
return fmt.Errorf("generation failed")
}
if err := generate(&staged); err != nil {
http.Error(w, "generation failed", http.StatusInternalServerError)
return
}
_, _ = staged.WriteTo(w)
}
func streamResult(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/x-ndjson")
encoder := json.NewEncoder(w)
if err := encoder.Encode(map[string]any{
"type": "item", "id": 1,
}); err != nil {
return
}
// 已经开始传输;这份协议用终止记录表达生成失败。
_ = encoder.Encode(map[string]any{
"type": "error", "code": "GENERATION_FAILED",
})
}
func invoke(handler http.HandlerFunc, url string) (int, string) {
recorder := httptest.NewRecorder()
handler(recorder, httptest.NewRequest(http.MethodGet, url, nil))
response := recorder.Result()
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
panic(err)
}
return response.StatusCode, string(body)
}
func main() {
status, body := invoke(lateError, "/")
if status != 200 || !strings.Contains(body, "partial result") ||
!strings.Contains(body, "generation failed") {
panic("unexpected late error result")
}
fmt.Printf("late error: status=%d body=%q\n", status, body)
status, _ = invoke(validateFirst, "/")
if status != 400 {
panic("validation did not reject before writing")
}
fmt.Println("validation first: status=400")
status, body = invoke(bufferFirst, "/")
if status != 500 || strings.Contains(body, "partial result") {
panic("staged output was published before generation finished")
}
fmt.Println("buffer first: status=500, no partial success body")
status, body = invoke(streamResult, "/")
if status != 200 {
panic("stream status changed after body started")
}
decoder := json.NewDecoder(strings.NewReader(body))
var first, final map[string]any
if err := decoder.Decode(&first); err != nil { panic(err) }
if err := decoder.Decode(&final); err != nil { panic(err) }
if first["type"] != "item" || final["type"] != "error" {
panic("terminal event missing")
}
fmt.Println("stream: status=200, terminal record=error")
}
保存为 main.go,运行 go run main.go。第一条会同时出现部分结果和错误文字,但状态仍是 200;第二条在任何成功正文之前拒绝输入;缓冲方案丢弃尚未发布的部分内容并返回500;最后一条保持已提交状态,通过约定的终止记录说明没有正常完成。
第一次 Write 隐含了一次成功响应
尚未显式调用 WriteHeader 时,第一次 Write 会按默认成功状态开始响应。设置 Header map 本身不一定提交响应,但开始写正文就会跨过边界。
跨过以后再修改普通响应头,也不能指望之前已经发送的头部被更新。Content-Type、缓存策略和下载文件名等,应在写正文之前确定。否则测试里看到Header map后来有值,线路上的真实响应却未必包含它。
这里讨论的是最终普通响应。信息性1xx响应有另外的规则,可以在最终响应前发送;不要把这条例外理解成200之后还能换成500。业务错误处理通常面对的正是最终响应已定的情况。
http.Error 不是一个独立的事务性响应对象。它会尝试写状态和错误文本,不能替调用方检测并撤销更早的成功字节。最危险的路径是错误分支写完以后还继续执行,再追加一份成功JSON;因此每条已终止处理的分支都应该明确 return。
能提前失败的步骤,应留在提交边界前
参数校验、权限核对、查找目标和选择响应格式,大多可以在写出前完成。它们失败时,仍能选择清楚的状态码与一份完整错误对象。
小型模板或JSON结果,也可以先在受限缓冲区生成,成功后再写给客户端。这样能避免生成到一半才发现模板变量错误,代价是额外内存和延迟。缓冲必须有合理上限,不能为了得到漂亮错误码把大下载全部放进内存。
有些错误直到网络写出才知道,例如客户端断开。这类失败无法被同一条已断开的连接可靠告知,继续调用http.Error没有帮助。应记录必要诊断,并让资源释放;业务提交是否完成则需要另一份结果合同。
bufferFirst把生成器接受的类型限定为io.Writer,生成阶段只拿到bytes.Buffer,没有接触ResponseWriter,因此无法悄悄提交HTTP状态。等生成成功,适配层才把内容交给响应;这个职责边界比依赖每个模板函数都记得“现在别写状态”更容易检查。
但bytes.Buffer本身不提供业务大小上限,示例只生成固定的短字符串。把相同结构用于用户可控的巨大报告,需要增加受限writer或暂存文件,并处理生成失败后的清理。否则只是把半份网络响应的问题换成了无限内存增长。缓冲策略适用于能控制尺寸的内容,不能因为错误码更整齐就无条件使用。
中间件也可能提前写出。统一日志、压缩、模板布局或调试片段,都可能成为第一次Write的真正来源。检查处理器本身没有显式WriteHeader,不代表响应还没开始。
错误日志应同时保留业务失败与响应已经提交的事实。不能为了让监控出现500,就在中间件里再改一次状态;客户端已经收到200时,后续流失败需要独立的结果指标与终止记录。监控按真实合同统计,才能解释日志和线路状态为何不同。
流式合同需要一个能验证的结束状态
长结果不适合全部缓冲,这时应该接受状态先确定,再用流协议说明后续结果。例子选择每行一个对象,并用type=error作为受控终止;真实协议还应有正常结束记录,让客户端能区分“全部完成”和“连接中断在最后一条之前”。
HTTP200只说明响应按这条HTTP路径开始成功,不证明整个应用流已经完整生成。客户端若只检查状态就展示成功,会把中途断开的流误报为完整结果。逐条数据已经到达与整批结束,是两条不同证据。
也可以使用声明好的trailer传递尾部元数据,但要验证客户端、代理和部署协议的支持,不能假定所有浏览器与中间层都把它暴露给业务。没有实际支持链时,把关键成功标志只放在trailer里,会让验证变得不可实现。
对这份NDJSON合同,客户端应同时验证HTTP状态、每条记录的格式与最终结束记录。流在终止记录之前断开时,结果应保留为未完整完成,不能因为已经收到了若干item就将整批标为成功。