本文是一个关于 HTTP Status 499 的实验
实验搭建#
首先我们创建一个 HTTP Server,模拟我们的业务应用。为了方便,通过 sleep
来故意制造 slow response。代码如下,使用的是 Go 的 echo 框架
package main
import (
"context"
"fmt"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"net/http"
"time"
)
func TimeoutMiddleware(timeout time.Duration) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), timeout)
defer cancel()
req := c.Request().WithContext(ctx)
c.SetRequest(req)
done := make(chan error, 1)
go func() {
done <- next(c)
}()
select {
case err := <-done:
return err
case <-ctx.Done():
fmt.Println("Request canceled or timed out")
return ctx.Err()
}
}
}
}
func main() {
e := echo.New()
e.Use(middleware.Logger())
e.Use(TimeoutMiddleware(5 * time.Second))
e.GET("/", func(c echo.Context) error {
time.Sleep(10 * time.Second)
return c.String(http.StatusOK, "Hello world!")
})
e.Logger.Fatal(e.Start(":8081"))
}
其次我们需要一个 Proxy,可以使用 Nginx,也可以继续使用 echo。代码如下
package main
import (
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"net/url"
)
func main() {
e := echo.New()
e.Use(middleware.Logger())
upstreamURL, err := url.Parse("http://localhost:8081")
if err != nil {
e.Logger.Fatal(err)
}
target := &middleware.ProxyTarget{
URL: upstreamURL,
}
e.Use(middleware.Proxy(middleware.NewRoundRobinBalancer([]*middleware.ProxyTarget{target})))
e.Logger.Fatal(e.Start(":8080"))
}
等待 Proxy 和 Server 启动,我们通过 curl 来观察客户端断开时候的行为。下图上方为 server,下方为 proxy
可以发现在 curl 终止后,proxy 报出来了 499,而 server 则是 500 context canceled
网络报文分析#
通过 wireshark 抓包来分析一下这个时序
通过这张图可以得知
- curl 源端口为 49636,经过 3 次握手(29 - 31)后,和监听 8080 端口的 Proxy 建立连接,发送 HTTP Request
- Proxy 使用 43804 端口向上游监听 8081 端口的 Server 建立连接 (34 - 36) 后,转发请求
- 编号 41 是一个 FIN, ACK 的报文,curl 想要主动关闭连接。在 Proxy 收到后,编号 42 是一个由 Proxy 向 Server 发送的 Fin
- Proxy 识别到客户端主动关闭,响应 499 status code 报文
- curl 进程已经退出(
close(socket_fd)
),收到 499 后,RST 被自动发送会给 Proxy。同理 Proxy 和 Server 之间的 500 报文和 RST
常见疑问#
我们可以整理一下,然后得出下面的问题
为什么需要 Status Code 499#
其中一个原因便是,我们需要记录每一次请求,比如请求日志。HTTP 是 Request 和 Response 的,当客户端主动关闭的时候,我们现在有可能没有一个 Response Header,所以并没有 status code。为了能够识别这种情况,自己定义一个错误码。这个最初好像是 Nginx 这么玩的,后来成为了不成文的标准
为什么 curl 在发送 FIN 之后,Proxy 要继续发送 HTTP Response 呢#
这种行为取决于具体实现,可以发送也可以不发送。发送的原因是因为 TCP 是全双工的,双方都可以主动断开连接。在 TCP 协议中,当客户端发送 FIN 之后,代表客户端不会发送数据。并不意味着无法继续接收数据。但是我们在代码中往往都是通过 close
操作 socket 的,这个不仅会导致关闭连接,还是释放 fd。如果在这个 fd 上继续调用 read
那么会得到一个 EBADF
。数据在这种情况是无法读出的。不过 shutdown
有额外的 flag,可以来精细控制
实现代码如下,替换掉实验中的 curl
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#define SERVER_IP "127.0.0.1"
#define SERVER_PORT 8080
int main() {
int sock;
struct sockaddr_in server;
char request[] = "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";
char response[4096];
int bytes_received;
sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock == -1) {
perror("Socket creation failed");
return 1;
}
server.sin_family = AF_INET;
server.sin_port = htons(SERVER_PORT);
server.sin_addr.s_addr = inet_addr(SERVER_IP);
if (connect(sock, (struct sockaddr *)&server, sizeof(server)) < 0) {
perror("Connection failed");
close(sock);
return 1;
}
printf("Connected to %s:%d\n", SERVER_IP, SERVER_PORT);
if (send(sock, request, strlen(request), 0) < 0) {
perror("Send failed");
close(sock);
return 1;
}
printf("HTTP GET request sent\n");
if (shutdown(sock, SHUT_WR) < 0) {
perror("Shutdown failed");
close(sock);
return 1;
}
printf("Shutdown SHUT_WR called\n\n");
while ((bytes_received = recv(sock, response, sizeof(response) - 1, 0)) > 0) {
response[bytes_received] = '\0';
printf("Received: %s", response);
}
if (bytes_received < 0) {
perror("Receive failed");
} else if (bytes_received == 0) {
printf("Connection closed by server\n");
}
close(sock);
return 0;
}
运行结果,和网络报文如下
Connected to 127.0.0.1:8080
HTTP GET request sent
Shutdown SHUT_WR called
Received: HTTP/1.1 499 status code 499
Content-Type: application/json
Date: Thu, 12 Sep 2024 12:01:14 GMT
Content-Length: 57
Connection: close
{"message":"client closed connection: context canceled"}
Connection closed by server
在 shutdown(sock, SHUT_WR)
后,我们发送了 FIN
。然后通过循环等待 server 的 499 返回,最后调用了 close
。可以看到这次少了一条 RST 记录,多了一个完整的四次挥手。所以仅仅凭一个客户端的 Fin 我们无法判断对方是 close
还是 shutdown(sock, SHUT_WR)
,这个 499 的响应还是发回去比较好。不过在实践中,大部分客户端是不会去接收这个 499 的,因此 499 都集中出现在 Proxy 端的日志中
为什么 Server 会 500#
Server 的 500 来源于我们在 echo 通过中间件的方式捕获了 Context 的 Cancel 错误,然后直接抛出。对于其他语言来说,比如 Python 很有可能 Server 端的日志是一个 200。最常见的就是 Nginx 499 + uwsgi + Django 的部署方式。这是因为同步的处理方式,使得我们的线程陷入的是业务逻辑的处理当中,而在 10s 的超时后,我们的响应是可以成功的写入 socket buffer 的。这也就造成了 Proxy 499,Server 200 的现象。比如使用下面的 Go 代码替换 Server
package main
import (
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"net/http"
"time"
)
func main() {
e := echo.New()
e.Use(middleware.Logger())
e.GET("/", func(c echo.Context) error {
time.Sleep(10 * time.Second)
return c.String(http.StatusOK, "Hello, World!")
})
e.Logger.Fatal(e.Start(":8081"))
}
这个 Server 会在 curl 已经被停止后,依旧苦苦等待 10s,然后输出 200 的响应,最终换来一个 RST。对于 GET 请求来说这可能就是浪费资源了,但是 POST 请求它是有副作用的。取消还是继续执行我们业务逻辑需要商榷,而且最好有一个幂等的考虑
不同语言/框架下抓包结果是一致的么#
不一致,取决于如何处理 socket。我们拿 Node.js 再来举例子,使用下面的代码替换 Server
import http from "node:http";
import * as timers from "node:timers";
const server = http.createServer((req, res) => {
const timer = timers.setTimeout(() => {
console.log("timer");
res.statusCode = 200;
res.setHeader("Content-Type", "text/plain");
res.end("Hello World");
}, 10_000);
req.on("close", () => {
console.log("Emit close event");
});
req.on("error", () => {
console.log("Emit error event");
});
req.on("aborted", () => {
console.log("Emit aborted event");
console.log("req.socket.closed", req.socket.closed);
console.log("res.socket.closed", res.socket.closed);
timers.clearTimeout(timer);
res.statusCode = 500;
res.setHeader("Content-Type", "text/plain");
res.end("client Aborted");
});
});
server.listen(8081, "0.0.0.0", () => {
console.log("server is running on :8081");
});
在 Node.js v22.8.0 下,这个程序的输出结果是
Emit aborted event
req.socket.closed true
res.socket.closed true
Emit error event
Emit close event
有趣的是,你无法通过 Bun 1.1.26 和 deno 1.46.0 复现这个场景
在接收到 aborted
事件的时候,从输出可以发现我们的 socket 读写端都已经关闭。即使我们可以继续通过 res.end
等 API 写入 HTTP Body,但这些都会被丢弃。我们可以看一下在 Node.js 服务下的抓包结果,这里出现了完整的四次挥手(实际为三次,因为 FIN+ACK 合并了一次),但是没有我们的 HTTP 500 的响应发送给 Proxy
也可以结合 NODE_DEBUG=*
开启 Node.js 本身的 debug
日志,能够发现
NET 550231: _final: not ended, call shutdown()
STREAM 550231: endReadableNT
NET 550231: afterShutdown destroyed=false
NET 550231: destroy
NET 550231: close
NET 550231: close handle
NET 550231: has server
NET 550231: SERVER _emitCloseIfDrained
NET 550231: SERVER handle? true connections? 0
NET 550231: emit close
HTTP 550231: server socket close
Proxy 和 Server 间为什么有的进行了正常挥手,有的则是 RST 呢#
从上面可以发现,我们虽然使用同一个 Proxy,但是对于 Node.js 和 Go Server 抓包的结果是不一致的
- Node.js: 正常进行四次挥手
- Go echo Server: RST
这行为之间的差异来自于取决于如何处理 socket。我们可以通过一个 naive C server 来看一下
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#define PORT 8081
#define BUFFER_SIZE 2048
int main()
{
int listen_fd, conn_fd;
struct sockaddr_in address;
int addrlen = sizeof(address);
char buffer[BUFFER_SIZE] = { 0 };
char* http_response = "HTTP/1.1 200 OK\r\n"
"Content-Type: text/plain\r\n"
"Content-Length: 12\r\n\r\n"
"Hello World";
if ((listen_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
if (bind(listen_fd, (struct sockaddr*)&address, sizeof(address)) < 0) {
perror("bind failed");
close(listen_fd);
exit(EXIT_FAILURE);
}
if (listen(listen_fd, 3) < 0) {
perror("listen failed");
close(listen_fd);
exit(EXIT_FAILURE);
}
printf("Server listening on port %d\n", PORT);
if ((conn_fd = accept(listen_fd, (struct sockaddr*)&address, (socklen_t*)&addrlen)) < 0) {
perror("accept failed");
close(listen_fd);
exit(EXIT_FAILURE);
}
printf("Client connected\n");
ssize_t valread = read(conn_fd, buffer, BUFFER_SIZE);
printf("Received data: %s\n", buffer);
char tcp_buffer[BUFFER_SIZE];
ssize_t size = recv(conn_fd, tcp_buffer, BUFFER_SIZE, MSG_PEEK);
printf("Recv size: %ld\n", size);
/* if (size == 0) {
printf("Send resp body to client\n");
send(conn_fd, http_response, strlen(http_response), 0);
} */
sleep(30);
printf("Close socket\n");
close(conn_fd);
close(listen_fd);
return 0;
}
保留注释代码: 通过抓包可以发现四次挥手#
注意观察时间,我们在
- 编号 54 报文是一个空的 ACK = 178 回复的是编号 51,Seq = 177 的 FIN 报文。代表 Server 端确认了 Proxy => Server 流向的关闭
- 30 秒之后,编号 178 报文是 Server 发送的 FIN + ACK。它的 ACK 同样为 178。代表 Server 想要关闭 Server => Proxy 流向数据,这个对应代码是
close(conn_fd)
- 编号 179 报文是 Proxy 对于 Server 的回复,表示确认关闭
取消注释代码: 产生 RST#
编号 68 是我们发送会去的响应。观察时间,在 30 秒后没有任何报文,这是因为在收到 RST 之后 socket 在协议栈被自动关闭。我们调用 close(conn_fd)
后只是释放了描述符
小结#
如果遇到 499 可以看一下响应时间,如果过短那么需要看一下 Client 到 Proxy 的问题;如果过长,那么需要排查一下 Server 的超时问题