Skip to content

Commit e3aa656

Browse files
committed
perf(response_writer): minimize socket:send() calls
1 parent 17f4a54 commit e3aa656

File tree

2 files changed

+263
-15
lines changed

2 files changed

+263
-15
lines changed

gateway/src/resty/http/response_writer.lua

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
local fmt = string.format
22
local str_lower = string.lower
3+
local insert = table.insert
4+
local concat = table.concat
35

46
local _M = {
57
}
@@ -32,7 +34,6 @@ end
3234
-- write_response writes response body reader to sock in the HTTP/1.x server response format,
3335
-- The connection is closed if send() fails or when returning a non-zero
3436
function _M.send_response(sock, response, chunksize)
35-
local bytes, err
3637
chunksize = chunksize or 65536
3738

3839
if not response then
@@ -44,33 +45,32 @@ function _M.send_response(sock, response, chunksize)
4445
return nil, "socket not initialized yet"
4546
end
4647

47-
-- Status line
48-
-- TODO: get HTTP version from request
49-
local status = fmt("HTTP/%d.%d %03d %s\r\n", 1, 1, response.status, response.reason)
50-
bytes, err = send(sock, status)
51-
if not bytes then
52-
return nil, "failed to send status line, err: " .. (err or "unknown")
53-
end
48+
-- Build status line + headers into a single buffer to minimize send() calls
49+
local buf = {
50+
fmt("HTTP/1.1 %03d %s\r\n", response.status, response.reason)
51+
}
5452

5553
-- Filter out hop-by-hop headeres
5654
for k, v in pairs(response.headers) do
5755
if not HOP_BY_HOP_HEADERS[str_lower(k)] then
58-
local header = fmt("%s: %s\r\n", k, v)
59-
bytes, err = sock:send(header)
60-
if not bytes then
61-
return nil, "failed to send status line, err: " .. (err or "unknown")
62-
end
56+
insert(buf, k .. ": " .. v .. cr_lf)
6357
end
6458
end
6559

6660
-- End-of-header
67-
bytes, err = send(sock, cr_lf)
61+
insert(buf, cr_lf)
62+
63+
local bytes, err = sock:send(concat(buf))
6864
if not bytes then
69-
return nil, "failed to send status line, err: " .. (err or "unknown")
65+
return nil, "failed to send headers, err: " .. (err or "unknown")
7066
end
7167

7268
-- Write body
7369
local reader = response.body_reader
70+
if not reader then
71+
return nil, "no body reader"
72+
end
73+
7474
repeat
7575
local chunk, read_err
7676

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
local response_writer = require('resty.http.response_writer')
2+
3+
4+
describe('resty.http.response_writer', function()
5+
6+
local mock_sock
7+
local sent_data
8+
9+
local function make_response(opts)
10+
opts = opts or {}
11+
local chunks = opts.chunks or { "hello" }
12+
local idx = 0
13+
return {
14+
status = opts.status or 200,
15+
reason = opts.reason or "OK",
16+
headers = opts.headers or {},
17+
body_reader = function()
18+
idx = idx + 1
19+
return chunks[idx]
20+
end
21+
}
22+
end
23+
24+
before_each(function()
25+
sent_data = {}
26+
mock_sock = {
27+
send = function(_, data)
28+
table.insert(sent_data, data)
29+
return #data, nil
30+
end
31+
}
32+
33+
stub(ngx, 'log')
34+
end)
35+
36+
describe('.send_response', function()
37+
38+
it('returns nil when no response is provided', function()
39+
local ok, err = response_writer.send_response(mock_sock, nil)
40+
assert.is_nil(ok)
41+
assert.is_nil(err)
42+
end)
43+
44+
it('returns error when no socket is provided', function()
45+
local ok, err = response_writer.send_response(nil, make_response())
46+
assert.is_nil(ok)
47+
assert.equal("socket not initialized yet", err)
48+
end)
49+
50+
it('sends status line in first send', function()
51+
local response = make_response({ status = 200, reason = "OK" })
52+
response_writer.send_response(mock_sock, response)
53+
54+
assert.truthy(string.find(sent_data[1], "^HTTP/1.1 200 OK\r\n"))
55+
end)
56+
57+
it('formats different status codes', function()
58+
local response = make_response({ status = 404, reason = "Not Found" })
59+
response_writer.send_response(mock_sock, response)
60+
61+
assert.truthy(string.find(sent_data[1], "^HTTP/1.1 404 Not Found\r\n"))
62+
end)
63+
64+
it('batches status line and headers in a single send', function()
65+
local response = make_response({
66+
headers = { ["Content-Type"] = "text/plain", ["X-Custom"] = "value" }
67+
})
68+
response_writer.send_response(mock_sock, response)
69+
70+
-- First send contains status line + headers + end-of-header CRLF
71+
local header_block = sent_data[1]
72+
assert.truthy(string.find(header_block, "^HTTP/1.1"))
73+
assert.truthy(string.find(header_block, "Content%-Type: text/plain\r\n"))
74+
assert.truthy(string.find(header_block, "X%-Custom: value\r\n"))
75+
assert.truthy(string.find(header_block, "\r\n\r\n$"))
76+
end)
77+
78+
it('filters hop-by-hop headers', function()
79+
local response = make_response({
80+
headers = {
81+
["Connection"] = "keep-alive",
82+
["Keep-Alive"] = "timeout=5",
83+
["Transfer-Encoding"] = "chunked",
84+
["Proxy-Authenticate"] = "Basic",
85+
["Proxy-Authorization"] = "Basic abc",
86+
["TE"] = "trailers",
87+
["Trailers"] = "Expires",
88+
["Upgrade"] = "websocket",
89+
["Content-Length"] = "5",
90+
["X-Custom"] = "value",
91+
}
92+
})
93+
response_writer.send_response(mock_sock, response)
94+
95+
local all_sent = table.concat(sent_data)
96+
assert.falsy(string.find(all_sent, "Connection:"))
97+
assert.falsy(string.find(all_sent, "Keep%-Alive:"))
98+
assert.falsy(string.find(all_sent, "Transfer%-Encoding:"))
99+
assert.falsy(string.find(all_sent, "Proxy%-Authenticate:"))
100+
assert.falsy(string.find(all_sent, "Proxy%-Authorization:"))
101+
assert.falsy(string.find(all_sent, "TE:"))
102+
assert.falsy(string.find(all_sent, "Trailers:"))
103+
assert.falsy(string.find(all_sent, "Upgrade:"))
104+
assert.falsy(string.find(all_sent, "Content%-Length:"))
105+
assert.truthy(string.find(all_sent, "X%-Custom: value"))
106+
end)
107+
108+
it('filters hop-by-hop headers case-insensitively', function()
109+
local response = make_response({
110+
headers = {
111+
["CONNECTION"] = "close",
112+
["KEEP-ALIVE"] = "timeout=5",
113+
}
114+
})
115+
response_writer.send_response(mock_sock, response)
116+
117+
local all_sent = table.concat(sent_data)
118+
assert.falsy(string.find(all_sent, "CONNECTION:"))
119+
assert.falsy(string.find(all_sent, "KEEP%-ALIVE:"))
120+
end)
121+
122+
it('sends end-of-header marker', function()
123+
local response = make_response({ headers = {} })
124+
response_writer.send_response(mock_sock, response)
125+
126+
assert.truthy(string.find(sent_data[1], "\r\n\r\n$"))
127+
end)
128+
129+
it('sends body chunks after headers', function()
130+
local response = make_response({ chunks = { "hello world" } })
131+
response_writer.send_response(mock_sock, response)
132+
133+
-- sent_data[1] is headers, sent_data[2] is body chunk
134+
assert.equal("hello world", sent_data[2])
135+
end)
136+
137+
it('sends multi-chunk body', function()
138+
local response = make_response({ chunks = { "chunk1", "chunk2", "chunk3" } })
139+
response_writer.send_response(mock_sock, response)
140+
141+
assert.equal("chunk1", sent_data[2])
142+
assert.equal("chunk2", sent_data[3])
143+
assert.equal("chunk3", sent_data[4])
144+
end)
145+
146+
it('returns true on success', function()
147+
local response = make_response()
148+
local ok, err = response_writer.send_response(mock_sock, response)
149+
150+
assert.is_true(ok)
151+
assert.is_nil(err)
152+
end)
153+
154+
it('returns error when headers send fails', function()
155+
mock_sock.send = function() return nil, "closed" end
156+
local response = make_response()
157+
local ok, err = response_writer.send_response(mock_sock, response)
158+
159+
assert.is_nil(ok)
160+
assert.truthy(string.find(err, "failed to send headers"))
161+
end)
162+
163+
it('returns error when body send fails', function()
164+
local call_count = 0
165+
mock_sock.send = function(_, data)
166+
call_count = call_count + 1
167+
if call_count == 1 then
168+
return #data, nil -- headers succeed
169+
end
170+
return nil, "closed" -- body chunk fails
171+
end
172+
173+
local response = make_response({ chunks = { "hello" } })
174+
local ok, err = response_writer.send_response(mock_sock, response)
175+
176+
assert.is_nil(ok)
177+
assert.truthy(string.find(err, "failed to send response body"))
178+
end)
179+
180+
it('returns error when body reader fails', function()
181+
local response = {
182+
status = 200,
183+
reason = "OK",
184+
headers = {},
185+
body_reader = function()
186+
return nil, "read error"
187+
end
188+
}
189+
local ok, err = response_writer.send_response(mock_sock, response)
190+
191+
assert.is_nil(ok)
192+
assert.truthy(string.find(err, "failed to read response body"))
193+
end)
194+
195+
it('returns error when response has no body_reader', function()
196+
local response = {
197+
status = 200,
198+
reason = "OK",
199+
headers = {},
200+
}
201+
local ok, err = response_writer.send_response(mock_sock, response)
202+
203+
assert.is_nil(ok)
204+
assert.equal("no body reader", err)
205+
end)
206+
207+
it('passes chunksize to body_reader', function()
208+
local received_chunksize
209+
local response = {
210+
status = 200,
211+
reason = "OK",
212+
headers = {},
213+
body_reader = function(size)
214+
received_chunksize = size
215+
return nil
216+
end
217+
}
218+
response_writer.send_response(mock_sock, response, 1024)
219+
220+
assert.equal(1024, received_chunksize)
221+
end)
222+
223+
it('uses default chunksize of 65536', function()
224+
local received_chunksize
225+
local response = {
226+
status = 200,
227+
reason = "OK",
228+
headers = {},
229+
body_reader = function(size)
230+
received_chunksize = size
231+
return nil
232+
end
233+
}
234+
response_writer.send_response(mock_sock, response)
235+
236+
assert.equal(65536, received_chunksize)
237+
end)
238+
239+
it('handles empty body', function()
240+
local response = make_response({ chunks = {} })
241+
local ok, err = response_writer.send_response(mock_sock, response)
242+
243+
assert.is_true(ok)
244+
assert.is_nil(err)
245+
end)
246+
end)
247+
end)
248+

0 commit comments

Comments
 (0)