HTTP文件上传时出现ERR_CONNECTION_RESET问题
先鉴权:省资源,但可能在上传 body 未发送完时提前关闭连接,浏览器显示连接错误。先解析 multipart 再鉴权:响应更稳定,但未登录用户也能先消耗服务器上传资源。以后如果做生产级大文件上传,更推荐:对象存储直传,短期预签名 URL,分片上传,上传完成后后端保存 metadata。这样可以把业务服务器从大文件传输链路中解放出来。
触发场景:服务器需要将文件保存在本机,客户端在文件上传期间,服务器提前响应导致客户端报错ERR_CONNECTION_RESET
在做文件上传接口时,遇到一个很容易误判的问题:
前端上传文件时,如果用户携带了一个已经过期的 session,后端中间件明明执行了:
response.WriteJSON(w, http.StatusUnauthorized, response.Fail(response.CodeAuthFail, "session expired"))
return
但是浏览器 Network 里看到的不是 401,而是返回了:net::ERR_CONNECTION_RESET
这会让人以为后端没有返回响应,或者以为是网络问题。
实际上,这不是普通 JSON 接口的问题,而是“文件上传请求还在发送 body,服务端提前拒绝请求”导致的连接层现象。
问题出现的场景
现在的上传接口大致是:
mux.Handle("POST /v1/audio/upload", chain(
http.HandlerFunc(audioHandler.AudioUpload),
middleware.AuthMiddleware(cfg.SessionStore),
middleware.LoggingMiddleware,
))
中间件先校验 session:
userValue, ok := sessionStore.Get(cookie.Value)
if !ok {
response.WriteJSON(w, http.StatusUnauthorized, response.Fail(response.CodeAuthFail, "session expired"))
return
}
而真正解析文件是在 handler 里:
func (h *AudioHandler) AudioUpload(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, "解析表单失败", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("file")
// ...
}
所以实际顺序是:
浏览器开始上传 multipart/form-data
-> 请求进入 Go 中间件
-> 中间件读取 Cookie
-> 中间件发现 session 过期
-> 服务端直接返回 401 并结束处理
-> 浏览器可能还在继续发送文件 body
-> 连接被关闭/重置
-> 浏览器显示 ERR_CONNECTION_RESET,而不是 401
关键点是:服务端返回 401 的时候,请求体可能还没有被读完。
普通 JSON 请求很小,通常感觉不到这个问题;但文件上传请求体可能很大,浏览器还在发送数据时,服务端提前结束连接,就可能表现为连接错误。
Gin是否也存在该现象
Gin 的中间件模型本质上也是先进入 middleware,再进入 handler:
r.POST("/upload", AuthMiddleware(), uploadHandler)
如果鉴权中间件里这样写:
if !validSession(c) {
c.JSON(http.StatusUnauthorized, gin.H{
"code": 40001,
"msg": "session expired",
})
c.Abort()
return
}
那么它和 net/http 的问题是一样的:
先鉴权
还没有读取 multipart body
直接返回 401
浏览器可能看到连接中断
Gin 并不会自动把上传 body 读完以后再返回错误。
如果在 Gin handler 里先调用:
file, err := c.FormFile("file")
再鉴权,那么 body 已经被解析,响应会更稳定。但是这样会让未登录用户也能先把文件传到服务器,鉴权就太晚了。
Java 使用的解决方案
Spring Boot 里如果使用的是 Controller 参数绑定:
@RequestMapping("/uploadVideo")
@GlobalInterceptor(checkLogin = true)
public ResponseVO uploadVideo(
@NotNull MultipartFile chunkFile,
@NotNull Integer chunkIndex,
@NotEmpty String uploadId
) throws IOException {
TokenUserInfoDto tokenUserInfoDto = getTokenUserInfoDto();
}
再配合 AOP:
@Before("@annotation(com.workshareplatform.web.annotation.GlobalInterceptor)")
public void interceptorDo(JoinPoint point) {
Method method = ((MethodSignature) point.getSignature()).getMethod();
GlobalInterceptor interceptor = method.getAnnotation(GlobalInterceptor.class);
if (null == interceptor) {
return;
}
if (interceptor.checkLogin()) {
checkLogin();
}
}
这个流程通常是:
请求进入
-> Servlet Filter 链
-> DispatcherServlet
-> multipart 解析
-> 参数绑定 MultipartFile
-> 准备调用 Controller
-> AOP @Before 执行 checkLogin
-> Controller 方法体
也就是说,AOP 鉴权执行时,MultipartFile 通常已经被 Spring MVC 解析出来了。请求体已经被读过,所以更容易正常返回业务错误。
但代价是:
未登录用户也可以先把文件上传到服务器临时目录,然后才被 checkLogin 拦截。
所以 Spring Boot 不是天然解决了这个问题,而是很多项目的鉴权点比较靠后,刚好避开了连接中断现象。
如果使用 Spring Security Filter 在前面鉴权,流程又会变成:
请求进入
-> Spring Security Filter 鉴权
-> DispatcherServlet
-> multipart 解析
这种情况下,如果 Filter 在 body 没读完前直接拒绝上传请求,也可能出现类似连接中断问题。
现代项目为什么少见
现代项目里,大文件通常不会经过业务服务器,而是采用对象存储直传:
前端 -> 后端:申请上传凭证
后端 -> 前端:返回预签名 URL
前端 -> 对象存储:直接上传文件
前端 -> 后端:上传完成后保存文件记录
例如 S3、阿里云 OSS、腾讯云 COS 都支持类似模式。
这样业务服务器只负责:
- 校验用户是否有上传权限
- 签发短期上传凭证
- 保存文件 metadata
- 处理上传完成回调
文件本身不经过业务服务器,所以不会在业务服务器上出现“大文件上传到一半,session 过期,中间件提前返回导致连接中断”的问题。
但这不代表鉴权问题消失了,只是模型变了:
只要用户申请上传凭证时是合法的,这次上传就允许继续完成。
即使 session 在上传过程中刚好过期,预签名 URL 在自己的有效期内仍然可以完成上传。
当前项目解决方案
这个项目是 Go 原生 Web 学习项目,文件上传也有限制:
r.ParseMultipartForm(10 << 20)
所以当前最合适的做法是后端中间件在 multipart 鉴权失败时,先丢弃请求体,再返回 401 JSON。
这样可以让浏览器更稳定地拿到业务响应。
可以在中间件里封装:
func writeAuthFail(w http.ResponseWriter, r *http.Request, msg string) {
if isMultipartRequest(r) {
_, _ = io.Copy(io.Discard, r.Body)
_ = r.Body.Close()
}
response.WriteJSON(w, http.StatusUnauthorized, response.Fail(response.CodeAuthFail, msg))
}
func isMultipartRequest(r *http.Request) bool {
return strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data")
}
然后把原来的:
response.WriteJSON(w, http.StatusUnauthorized, response.Fail(response.CodeAuthFail, "session expired"))
return
改成:
writeAuthFail(w, r, "session expired")
return
需要引入:
import (
"io"
"strings"
)
为了让客户端稳定收到 401,后端愿意把这个无效上传请求的 body 读完并丢弃。
它的缺点也很明确:session 已经过期了,但这次上传 body 仍然会被完整发送到服务器。
对于小文件上传,这个取舍是可以接受的。
前端兜底异常处理
即使后端 drain body,也不能保证浏览器永远拿到 401。因为还有很多真正的网络错误:
- 服务端没启动
- 端口写错
- 浏览器取消请求
- 文件超过限制
- 网络中断
- 代理或网关关闭连接
所以前端仍然要同时处理两类错误:
async function fileUpload(file) {
const data = new FormData()
data.set("file", file)
try {
const response = await fetch(`${serverUrl}/v1/audio/upload`, {
method: "POST",
body: data,
credentials: "include",
})
const result = await response.json()
if (response.status === 401 || result.code === 40001) {
localStorage.removeItem("user-info")
updateHeaderDOM()
dialogDOM.showModal()
return
}
if (!response.ok || result.code !== 0) {
console.error("上传失败:", result.msg)
return
}
return result
} catch (err) {
console.error("上传失败,请检查网络或服务器状态:", err)
}
}
后端负责尽量返回明确的业务错误,前端负责兜底处理真实的网络错误。
总结
文件上传接口的鉴权顺序有一个天然取舍:
- 先鉴权:省资源,但可能在上传 body 未发送完时提前关闭连接,浏览器显示连接错误。
- 先解析 multipart 再鉴权:响应更稳定,但未登录用户也能先消耗服务器上传资源。
以后如果做生产级大文件上传,更推荐:对象存储直传,短期预签名 URL,分片上传,上传完成后后端保存 metadata。这样可以把业务服务器从大文件传输链路中解放出来。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)