This is an automated email from the ASF dual-hosted git repository.
AlinsRan 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 c57ee9cac perf(core): cache parsed JSON request body to avoid
redundant decoding (#13377)
c57ee9cac is described below
commit c57ee9cac01a01ca635e56ea141eab5f2c711de8
Author: AlinsRan <[email protected]>
AuthorDate: Mon May 18 11:50:52 2026 +0800
perf(core): cache parsed JSON request body to avoid redundant decoding
(#13377)
---
apisix/core/ctx.lua | 60 +-----------------------------
apisix/core/request.lua | 86 +++++++++++++++++++++++++++++++++++++++----
apisix/patch.lua | 11 +++---
t/admin/routes_request_body.t | 2 +-
t/core/request.t | 52 ++++++++++++++++++++++++++
5 files changed, 139 insertions(+), 72 deletions(-)
diff --git a/apisix/core/ctx.lua b/apisix/core/ctx.lua
index dda4ccddc..92a57629d 100644
--- a/apisix/core/ctx.lua
+++ b/apisix/core/ctx.lua
@@ -29,7 +29,6 @@ local tablepool = require("tablepool")
local get_var = require("resty.ngxvar").fetch
local get_request = require("resty.ngxvar").request
local ck = require "resty.cookie"
-local multipart = require("multipart")
local util = require("apisix.cli.util")
local gq_parse = require("graphql").parse
local jp = require("jsonpath")
@@ -170,63 +169,6 @@ local function get_parsed_graphql()
end
-local CONTENT_TYPE_JSON = "application/json"
-local CONTENT_TYPE_FORM_URLENCODED = "application/x-www-form-urlencoded"
-local CONTENT_TYPE_MULTIPART_FORM = "multipart/form-data"
-
-local PARSED_BODY_CACHE_KEY = "_post_arg_request_body"
-
-local function _get_parsed_request_body(ctx)
- local ct_header = request.header(ctx, "Content-Type") or ""
-
- if core_str.find(ct_header, CONTENT_TYPE_JSON) then
- local request_table, err = request.get_json_request_body_table()
- if not request_table then
- return nil, "failed to parse JSON body: " .. err
- end
- return request_table
- end
-
- if core_str.find(ct_header, CONTENT_TYPE_FORM_URLENCODED) then
- local args, err = request.get_post_args()
- if not args then
- return nil, "failed to parse form data: " .. (err or "unknown
error")
- end
- return args
- end
-
- if core_str.find(ct_header, CONTENT_TYPE_MULTIPART_FORM) then
- local body = request.get_body()
- local res = multipart(body, ct_header)
- if not res then
- return nil, "failed to parse multipart form data"
- end
- return res:get_all()
- end
-
- local err = "unsupported content-type in header: " .. ct_header ..
- ", supported types are: " ..
- CONTENT_TYPE_JSON .. ", " ..
- CONTENT_TYPE_FORM_URLENCODED .. ", " ..
- CONTENT_TYPE_MULTIPART_FORM
- return nil, err
-end
-
--- Wrapper that caches the parsed body in ctx for the lifetime of the request.
--- Errors are intentionally not cached: plugins may call
ngx.req.set_body_data()
--- in a later phase, so a transient read failure should not be frozen.
-local function get_parsed_request_body(ctx)
- if ctx[PARSED_BODY_CACHE_KEY] ~= nil then
- log.debug("reuse parsed request body from ctx cache")
- return ctx[PARSED_BODY_CACHE_KEY]
- end
-
- local result, err = _get_parsed_request_body(ctx)
- if result then
- ctx[PARSED_BODY_CACHE_KEY] = result
- end
- return result, err
-end
do
@@ -370,7 +312,7 @@ do
elseif core_str.has_prefix(key, "post_arg.") then
-- trim the "post_arg." prefix (10 characters)
local arg_key = sub_str(key, 10)
- local parsed_body, err = get_parsed_request_body(t._ctx)
+ local parsed_body, err = request.get_request_body_table(t._ctx)
if not parsed_body then
log.warn("failed to fetch post args value by key: ",
arg_key, " error: ", err)
return nil
diff --git a/apisix/core/request.lua b/apisix/core/request.lua
index f49f648ab..ef5bcb6bb 100644
--- a/apisix/core/request.lua
+++ b/apisix/core/request.lua
@@ -23,6 +23,8 @@ local lfs = require("lfs")
local log = require("apisix.core.log")
local json = require("apisix.core.json")
local io = require("apisix.core.io")
+local multipart = require("multipart")
+local core_str = require("apisix.core.string")
local req_add_header
if ngx.config.subsystem == "http" then
local ngx_req = require "ngx.req"
@@ -46,6 +48,10 @@ local req_set_uri_args = ngx.req.set_uri_args
local table_insert = table.insert
local req_set_header = ngx.req.set_header
+local CONTENT_TYPE_JSON = "application/json"
+local CONTENT_TYPE_FORM_URLENCODED = "application/x-www-form-urlencoded"
+local CONTENT_TYPE_MULTIPART_FORM = "multipart/form-data"
+
local _M = {}
@@ -335,17 +341,83 @@ function _M.get_body(max_size, ctx)
end
-function _M.get_json_request_body_table()
- local body, err = _M.get_body()
- if not body then
- return nil, { message = "could not get body: " .. (err or "request
body is empty") }
+-- get_request_body_table parses the request body according to its Content-Type
+-- and caches the result in ctx._request_body_table for the lifetime of the
request.
+-- Supported types: application/json, application/x-www-form-urlencoded,
+-- multipart/form-data.
+--
+-- When content_type is given (e.g. CONTENT_TYPE_JSON), it takes precedence
over
+-- the request Content-Type header. A cache hit is only reused when
+-- ctx._request_body_type matches, preventing type confusion between callers.
+local function get_request_body_table(ctx, content_type)
+ if not ctx then
+ ctx = ngx.ctx.api_ctx
+ end
+ if ctx._request_body_table ~= nil then
+ if content_type and ctx._request_body_type ~= content_type then
+ return nil, "request body type mismatch: cached type is " ..
+ (ctx._request_body_type or "unknown") ..
+ ", expected " .. content_type
+ end
+ log.debug("reuse parsed request body from ctx cache")
+ return ctx._request_body_table
end
- local body_tab, err = json.decode(body)
- if not body_tab then
- return nil, { message = "could not parse JSON request body: " .. (err
or "invalid JSON") }
+ local ct = content_type or _M.header(ctx, "Content-Type") or ""
+ local result, err, detected_type
+
+ if core_str.find(ct, CONTENT_TYPE_JSON) then
+ local body, body_err = _M.get_body()
+ if not body then
+ return nil, "could not get body: " .. (body_err or "request body
is empty")
+ end
+ result, err = json.decode(body)
+ if not result then
+ return nil, "could not parse JSON request body: " .. (err or
"invalid JSON")
+ end
+ detected_type = CONTENT_TYPE_JSON
+
+ elseif core_str.find(ct, CONTENT_TYPE_FORM_URLENCODED) then
+ result, err = _M.get_post_args()
+ if not result then
+ return nil, "failed to parse form data: " .. (err or "unknown
error")
+ end
+ detected_type = CONTENT_TYPE_FORM_URLENCODED
+
+ elseif core_str.find(ct, CONTENT_TYPE_MULTIPART_FORM) then
+ local body, body_err = _M.get_body()
+ if not body then
+ return nil, "could not get body: " .. (body_err or "request body
is empty")
+ end
+
+ local res = multipart(body, ct)
+ if not res then
+ return nil, "failed to parse multipart form data"
+ end
+ result = res:get_all()
+ detected_type = CONTENT_TYPE_MULTIPART_FORM
+
+ else
+ return nil, "unsupported content-type: " .. ct ..
+ ", supported types are: " ..
+ CONTENT_TYPE_JSON .. ", " ..
+ CONTENT_TYPE_FORM_URLENCODED .. ", " ..
+ CONTENT_TYPE_MULTIPART_FORM
end
+ ctx._request_body_table = result
+ ctx._request_body_type = detected_type
+ return result
+end
+_M.get_request_body_table = get_request_body_table
+
+
+function _M.get_json_request_body_table()
+ local ctx = ngx.ctx.api_ctx
+ local body_tab, err = get_request_body_table(ctx, CONTENT_TYPE_JSON)
+ if not body_tab then
+ return nil, { message = err }
+ end
return body_tab
end
diff --git a/apisix/patch.lua b/apisix/patch.lua
index 1e2303c64..f8d504b74 100644
--- a/apisix/patch.lua
+++ b/apisix/patch.lua
@@ -380,14 +380,15 @@ function _M.patch()
return patch_udp_socket(original_udp())
end
- -- Patch ngx.req.set_body_data to invalidate the parsed post_arg request
body
- -- cache in api_ctx. This ensures that post_arg.* variable lookups after a
body
- -- rewrite always reflect the new body content.
+ -- Patch ngx.req.set_body_data to invalidate the parsed request body cache
+ -- in api_ctx. This ensures that body lookups and post_arg.* variable
+ -- lookups after a body rewrite always reflect the new body content.
local _orig_set_body_data = ngx.req.set_body_data
ngx.req.set_body_data = function(data)
local api_ctx = ngx.ctx.api_ctx
- if api_ctx and api_ctx._post_arg_request_body then
- api_ctx._post_arg_request_body = nil
+ if api_ctx and api_ctx._request_body_table then
+ api_ctx._request_body_table = nil
+ api_ctx._request_body_type = nil
local var = api_ctx.var
local cache = var and var._cache
if cache then
diff --git a/t/admin/routes_request_body.t b/t/admin/routes_request_body.t
index beec99fa3..afb17396e 100644
--- a/t/admin/routes_request_body.t
+++ b/t/admin/routes_request_body.t
@@ -138,7 +138,7 @@ openai
--testboundary--
--- error_code: 404
--- error_log
-unsupported content-type in header:
+unsupported content-type:
diff --git a/t/core/request.t b/t/core/request.t
index 322050830..14c2e901e 100644
--- a/t/core/request.t
+++ b/t/core/request.t
@@ -490,3 +490,55 @@ DEPRECATED: use add_header(ctx, header_name, header_value)
instead
ngx
test
apisix
+
+
+
+=== TEST 17: get_json_request_body_table caches result and re-decodes after
set_body_data
+--- config
+ location /t {
+ content_by_lua_block {
+ local core = require("apisix.core")
+ local json = require("apisix.core.json")
+
+ ngx.ctx.api_ctx = {}
+
+ local decode_count = 0
+ local orig_decode = json.decode
+ json.decode = function(str)
+ decode_count = decode_count + 1
+ return orig_decode(str)
+ end
+
+ -- first call: populates cache
+ local t1 = core.request.get_json_request_body_table()
+ -- second and third calls: hit cache, no extra decode
+ local t2 = core.request.get_json_request_body_table()
+ local t3 = core.request.get_json_request_body_table()
+
+ ngx.say("model: ", t1 and t1.model)
+ ngx.say("same table: ", t1 == t2 and t2 == t3)
+ ngx.say("decode_count: ", decode_count)
+
+ -- invalidate cache by replacing body
+ ngx.req.set_body_data('{"model":"claude"}')
+
+ -- cache cleared, must re-decode
+ local t4 = core.request.get_json_request_body_table()
+
+ json.decode = orig_decode
+
+ ngx.say("after set_body model: ", t4 and t4.model)
+ ngx.say("decode_count: ", decode_count)
+ }
+ }
+--- request
+POST /t
+{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}
+--- more_headers
+Content-Type: application/json
+--- response_body
+model: gpt-4
+same table: true
+decode_count: 1
+after set_body model: claude
+decode_count: 2