This is an automated email from the ASF dual-hosted git repository.
shreemaan-abhishek pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git
The following commit(s) were added to refs/heads/master by this push:
new c3141d49c0 fix(batch-requests): bound aggregated response bodies
(#13906)
c3141d49c0 is described below
commit c3141d49c01fc704ba3baba8892427806b636160
Author: Shreemaan Abhishek <[email protected]>
AuthorDate: Fri Sep 11 18:34:28 2026 +0800
fix(batch-requests): bound aggregated response bodies (#13906)
---
apisix-master-0.rockspec | 2 +-
apisix/plugins/batch-requests.lua | 102 ++++++++++++++-
t/plugin/batch-requests.t | 266 ++++++++++++++++++++++++++++++++++++++
3 files changed, 366 insertions(+), 4 deletions(-)
diff --git a/apisix-master-0.rockspec b/apisix-master-0.rockspec
index 064aaea905..9a6a9fa9d9 100644
--- a/apisix-master-0.rockspec
+++ b/apisix-master-0.rockspec
@@ -37,7 +37,7 @@ dependencies = {
"api7-lua-resty-dns-client = 7.1.2-0",
"lua-resty-template = 2.0-1",
"lua-resty-etcd = 1.10.6-0",
- "api7-lua-resty-http = 0.2.3-0",
+ "api7-lua-resty-http = 0.2.4-0",
"lua-resty-balancer = 0.05-0",
"lua-resty-ngxvar = 0.5.2-0",
"lua-resty-jit-uuid = 0.0.7-2",
diff --git a/apisix/plugins/batch-requests.lua
b/apisix/plugins/batch-requests.lua
index 8927b33b5a..d8faa288b6 100644
--- a/apisix/plugins/batch-requests.lua
+++ b/apisix/plugins/batch-requests.lua
@@ -20,6 +20,7 @@ local plugin = require("apisix.plugin")
local ngx = ngx
local ipairs = ipairs
local pairs = pairs
+local tonumber = tonumber
local str_find = core.string.find
local str_lower = string.lower
@@ -45,6 +46,9 @@ local schema = {
local default_max_body_size = 1024 * 1024 -- 1MiB
local default_max_pipeline_items = 1000
+local default_max_response_body_size = 1024 * 1024 -- 1MiB
+local default_max_response_body_size_total = 10 * 1024 * 1024 -- 10MiB
+local response_body_chunk_size = 8192
local metadata_schema = {
type = "object",
properties = {
@@ -60,6 +64,18 @@ local metadata_schema = {
exclusiveMinimum = 0,
default = default_max_pipeline_items,
},
+ max_response_body_size = {
+ description = "max response body size in bytes for each pipeline
request",
+ type = "integer",
+ exclusiveMinimum = 0,
+ default = default_max_response_body_size,
+ },
+ max_response_body_size_total = {
+ description = "max total response body size in bytes for a
pipeline",
+ type = "integer",
+ exclusiveMinimum = 0,
+ default = default_max_response_body_size_total,
+ },
},
}
@@ -224,18 +240,87 @@ local function set_common_query(data)
end
+local function close_http_client(httpc)
+ local ok, err = httpc:close()
+ if not ok then
+ core.log.warn("failed to close batch request connection: ", err)
+ end
+end
+
+
+local function read_response_body(httpc, resp, max_response_body_size,
+ response_body_size_total,
+ max_response_body_size_total)
+ local content_length = tonumber(resp.headers["Content-Length"])
+ local close_delimited = not content_length and
+ not http.transfer_encoding_is_chunked(resp.headers)
+ if content_length then
+ if content_length > max_response_body_size then
+ close_http_client(httpc)
+ return nil, nil, "max_response_body_size"
+ end
+
+ if response_body_size_total + content_length >
max_response_body_size_total then
+ close_http_client(httpc)
+ return nil, nil, "max_response_body_size_total"
+ end
+ end
+
+ local chunks = {}
+ local response_body_size = 0
+ while true do
+ local chunk, err = resp.body_reader(response_body_chunk_size)
+ local valid_eof = close_delimited and err == "closed"
+ if err and not valid_eof then
+ close_http_client(httpc)
+ return nil, err
+ end
+
+ if chunk then
+ response_body_size = response_body_size + #chunk
+ if response_body_size > max_response_body_size then
+ close_http_client(httpc)
+ return nil, nil, "max_response_body_size"
+ end
+
+ if response_body_size_total + response_body_size >
+ max_response_body_size_total then
+ close_http_client(httpc)
+ return nil, nil, "max_response_body_size_total"
+ end
+
+ core.table.insert(chunks, chunk)
+ end
+
+ if valid_eof or not chunk then
+ break
+ end
+ end
+
+ return core.table.concat(chunks), nil, nil, response_body_size
+end
+
+
local function batch_requests(ctx)
local metadata = plugin.plugin_metadata(plugin_name)
core.log.info("metadata: ", core.json.delay_encode(metadata))
local max_body_size
local max_pipeline_items
+ local max_response_body_size
+ local max_response_body_size_total
if metadata then
max_body_size = metadata.value.max_body_size
max_pipeline_items = metadata.value.max_pipeline_items or
default_max_pipeline_items
+ max_response_body_size = metadata.value.max_response_body_size or
+ default_max_response_body_size
+ max_response_body_size_total =
metadata.value.max_response_body_size_total or
+ default_max_response_body_size_total
else
max_body_size = default_max_body_size
max_pipeline_items = default_max_pipeline_items
+ max_response_body_size = default_max_response_body_size
+ max_response_body_size_total = default_max_response_body_size_total
end
local req_body, err = core.request.get_body(max_body_size, ctx)
@@ -286,7 +371,8 @@ local function batch_requests(ctx)
end
local aggregated_resp = {}
- for _, resp in ipairs(responses) do
+ local response_body_size_total = 0
+ for i, resp in ipairs(responses) do
if not resp.status then
core.table.insert(aggregated_resp, {
status = 504,
@@ -300,12 +386,22 @@ local function batch_requests(ctx)
headers = resp.headers,
}
if resp.has_body then
- local err
- sub_resp.body, err = resp:read_body()
+ local err, limit_name, response_body_size
+ sub_resp.body, err, limit_name, response_body_size =
+ read_response_body(httpc, resp, max_response_body_size,
+ response_body_size_total,
+ max_response_body_size_total)
+ if limit_name then
+ return 502, {
+ error_msg = "response body of pipeline request " .. i ..
+ " exceeds " .. limit_name
+ }
+ end
if err then
sub_resp.read_body_err = err
core.log.error("read pipeline response body failed: ", err)
else
+ response_body_size_total = response_body_size_total +
response_body_size
resp:read_trailers()
end
end
diff --git a/t/plugin/batch-requests.t b/t/plugin/batch-requests.t
index 298f9df11e..ed6e1e8f92 100644
--- a/t/plugin/batch-requests.t
+++ b/t/plugin/batch-requests.t
@@ -1194,3 +1194,269 @@ qr/property \\"path\\" is required/
GET /t
--- response_body
passed
+
+
+
+=== TEST 31: configure response body limits
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local code, body =
t('/apisix/admin/plugin_metadata/batch-requests',
+ ngx.HTTP_PUT,
+ [[{
+ "max_response_body_size": 5,
+ "max_response_body_size_total": 8
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 32: reject a subresponse body over its limit
+--- config
+ location = /six-bytes {
+ content_by_lua_block {
+ ngx.header.content_length = 6
+ ngx.print("123456")
+ }
+ }
+--- request
+POST /apisix/batch-requests
+{"pipeline":[{"path":"/six-bytes"}]}
+--- error_code: 502
+--- response_body
+{"error_msg":"response body of pipeline request 1 exceeds
max_response_body_size"}
+
+
+
+=== TEST 33: reject aggregate response bodies over their limit
+--- config
+ location = /five-bytes {
+ content_by_lua_block {
+ ngx.print("12")
+ ngx.flush(true)
+ ngx.print("345")
+ }
+ }
+ location = /four-bytes {
+ content_by_lua_block {
+ ngx.print("12")
+ ngx.flush(true)
+ ngx.print("34")
+ }
+ }
+--- request
+POST /apisix/batch-requests
+{"headers":{"Connection":"keep-alive"},"pipeline":[{"path":"/five-bytes"},{"path":"/four-bytes"}]}
+--- error_code: 502
+--- response_body
+{"error_msg":"response body of pipeline request 2 exceeds
max_response_body_size_total"}
+
+
+
+=== TEST 34: allow response bodies at the per-item and aggregate limits
+--- config
+ location = /five-bytes {
+ content_by_lua_block {
+ ngx.print("12")
+ ngx.flush(true)
+ ngx.print("345")
+ }
+ }
+ location = /three-bytes {
+ content_by_lua_block {
+ ngx.print("1")
+ ngx.flush(true)
+ ngx.print("23")
+ }
+ }
+--- request
+POST /apisix/batch-requests
+{"headers":{"Connection":"keep-alive"},"pipeline":[{"path":"/five-bytes"},{"path":"/three-bytes"}]}
+--- error_code: 200
+--- response_body eval
+qr/^\[.*"body":"12345".*"body":"123".*\]$/
+
+
+
+=== TEST 35: accept a close-delimited response at the per-item limit
+--- config
+ location = /close-delimited-five-bytes {
+ chunked_transfer_encoding off;
+ content_by_lua_block {
+ ngx.print("12345")
+ }
+ }
+--- request
+POST /apisix/batch-requests
+{"pipeline":[{"path":"/close-delimited-five-bytes","headers":{"Connection":"close"}}]}
+--- error_code: 200
+--- response_body eval
+qr/^\[.*"body":"12345".*\]$/
+--- no_error_log
+read pipeline response body failed
+
+
+
+=== TEST 36: reject a close-delimited response over the per-item limit
+--- config
+ location = /close-delimited-six-bytes {
+ chunked_transfer_encoding off;
+ content_by_lua_block {
+ ngx.print("123456")
+ }
+ }
+--- request
+POST /apisix/batch-requests
+{"pipeline":[{"path":"/close-delimited-six-bytes","headers":{"Connection":"close"}}]}
+--- error_code: 502
+--- response_body
+{"error_msg":"response body of pipeline request 1 exceeds
max_response_body_size"}
+
+
+
+=== TEST 37: count a close-delimited response toward the aggregate limit
+--- config
+ location = /four-bytes-with-length {
+ content_by_lua_block {
+ ngx.header.content_length = 4
+ ngx.print("1234")
+ }
+ }
+ location = /close-delimited-five-bytes {
+ chunked_transfer_encoding off;
+ content_by_lua_block {
+ ngx.print("12345")
+ }
+ }
+--- request
+POST /apisix/batch-requests
+{"headers":{"Connection":"keep-alive"},"pipeline":[{"path":"/four-bytes-with-length"},{"path":"/close-delimited-five-bytes","headers":{"Connection":"close"}}]}
+--- error_code: 502
+--- response_body
+{"error_msg":"response body of pipeline request 2 exceeds
max_response_body_size_total"}
+
+
+
+=== TEST 38: report a timeout while reading a close-delimited response
+--- config
+ location = /close-delimited-timeout {
+ chunked_transfer_encoding off;
+ content_by_lua_block {
+ ngx.print("partial")
+ ngx.flush(true)
+ ngx.sleep(0.3)
+ ngx.print("body")
+ }
+ }
+--- request
+POST /apisix/batch-requests
+{"timeout":100,"pipeline":[{"path":"/close-delimited-timeout","headers":{"Connection":"close"}}]}
+--- error_code: 200
+--- response_body_like eval
+qr/"read_body_err":"timeout"/
+--- response_body_unlike eval
+qr/"body":/
+--- error_log
+read pipeline response body failed: timeout
+
+
+
+=== TEST 39: reject an invalid response body limit
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local code, body =
t('/apisix/admin/plugin_metadata/batch-requests',
+ ngx.HTTP_PUT,
+ [[{
+ "max_response_body_size_total": 0
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- request
+GET /t
+--- error_code: 400
+--- response_body eval
+qr/property \\"max_response_body_size_total\\" validation failed/
+
+
+
+=== TEST 40: reset plugin metadata
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local code, body =
t('/apisix/admin/plugin_metadata/batch-requests',
+ ngx.HTTP_PUT,
+ [[{
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 41: reject a response body over the default per-item limit
+--- config
+ location = /over-one-mib {
+ content_by_lua_block {
+ ngx.header.content_length = 1048577
+ ngx.print(("x"):rep(1048577))
+ }
+ }
+--- request
+POST /apisix/batch-requests
+{"pipeline":[{"path":"/over-one-mib"}]}
+--- error_code: 502
+--- response_body
+{"error_msg":"response body of pipeline request 1 exceeds
max_response_body_size"}
+
+
+
+=== TEST 42: accept the default boundaries and reject the next aggregate byte
+--- config
+ location = /one-mib {
+ content_by_lua_block {
+ ngx.header.content_length = 1048576
+ ngx.print(("x"):rep(1048576))
+ }
+ }
+ location = /one-byte {
+ content_by_lua_block {
+ ngx.header.content_length = 1
+ ngx.print("x")
+ }
+ }
+--- request
+POST /apisix/batch-requests
+{"headers":{"Connection":"keep-alive"},"pipeline":[{"path":"/one-mib"},{"path":"/one-mib"},{"path":"/one-mib"},{"path":"/one-mib"},{"path":"/one-mib"},{"path":"/one-mib"},{"path":"/one-mib"},{"path":"/one-mib"},{"path":"/one-mib"},{"path":"/one-mib"},{"path":"/one-byte"}]}
+--- error_code: 502
+--- response_body
+{"error_msg":"response body of pipeline request 11 exceeds
max_response_body_size_total"}