Skip to content

bug: Two requests returned differently #12752

@mushenglon-sudo

Description

@mushenglon-sudo

Current Behavior

-- Licensed to the Apache Software Foundation (ASF) under one or more
-- contributor license agreements.  See the NOTICE file distributed with
-- this work for additional information regarding copyright ownership.
-- The ASF licenses this file to You under the Apache License, Version 2.0
-- (the "License"); you may not use this file except in compliance with
-- the License.  You may obtain a copy of the License at
--
--     http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
--
local core = require("apisix.core")
local helper = require("apisix.plugins.ext-plugin.helper")
local constants = require("apisix.constants")
local http = require("resty.http")
local cjson    = require("cjson.safe") 

local ngx       = ngx
local ngx_print = ngx.print
local ngx_flush = ngx.flush
local string    = string
local str_sub   = string.sub


local schema = {
    type = "object",
    properties = {
        xCeaiServiceId = {type = "string"},
        allow_degradation = {type = "boolean", default = false},
        status_on_error = {type = "integer", minimum = 200, maximum = 599, default = 403},
        ssl_verify = {
            type = "boolean",
            default = true,
        },
        request_method = {
            type = "string",
            default = "POST",
            enum = {"GET", "POST"},
            description = "the method for client to request the authorization service"
        },
        request_headers = {
            type = "array",
            default = {},
            items = {type = "string"},
            description = "client request header that will be sent to the authorization service"
        },
        extra_headers = {
            type = "object",
            minProperties = 1,
            patternProperties = {
                ["^[^:]+$"] = {
                    type = "string",
                    description = "header value as a string; may contain variables"
                                  .. "like $remote_addr, $request_uri"
                }
            },
            description = "extra headers sent to the authorization service; "
                        .. "values must be strings and can include variables"
                        .. "like $remote_addr, $request_uri."
        },
        upstream_headers = {
            type = "array",
            default = {},
            items = {type = "string"},
            description = "authorization response header that will be sent to the upstream"
        },
        client_headers = {
            type = "array",
            default = {},
            items = {type = "string"},
            description = "authorization response header that will be sent to"
                           .. "the client when authorizing failed"
        },
        timeout = {
            type = "integer",
            minimum = 1,
            maximum = 60000,
            default = 3000,
            description = "timeout in milliseconds",
        },
        keepalive = {type = "boolean", default = true},
        keepalive_timeout = {type = "integer", minimum = 1000, default = 60000},
        keepalive_pool = {type = "integer", minimum = 1, default = 5},
    },
    required = {"xCeaiServiceId"}
}
local name = "custom-output-check"
local _M = {
    version = 0.1,
    priority = 2006,
    name = name,
    schema = schema,
}

function _M.check_schema(conf)
    core.utils.check_tls_bool({"ssl_verify"}, conf, _M.name)
    return core.schema.check(_M.schema, conf)
end


local function include_req_headers(ctx)
    return core.request.headers(ctx)
end


local function close(http_obj)
    local ok, err = http_obj:close()
    if not ok and err ~= "closed" then
        core.log.error("close http object failed: ", err)
    end
end


local function get_response(ctx,conf)
    local http_obj = http.new()
    local ok, err = http_obj:connect({
        scheme = ctx.upstream_scheme,
        host = ctx.picked_server.host,
        port = ctx.picked_server.port,
    })
    if not ok then
        return nil, err
    end
    http_obj:set_timeout(conf.timeout)
    
    local uri = ctx.var.uri
    local args = ctx.var.args or ""
    
    -- 生成唯一请求标识
    local request_id = ngx.md5(ngx.now() .. math.random(10000, 99999))
    
    -- 构建防缓存参数
    local cache_buster = "&_nc=" .. request_id
    local new_args = args .. cache_buster
    
    local params = {
        path = uri,
        query = new_args,
        headers = include_req_headers(ctx),
        method = core.request.get_method(),
    }
    
    -- 强制设置防缓存头
    local headers = params.headers
    headers["X-Request-ID"] = request_id
    headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
    headers["Pragma"] = "no-cache"
    headers["Expires"] = "0"
    headers["X-No-Cache"] = "1"
    
    -- 如果是POST请求,添加时间戳到body(如果body是JSON)
    local body, err = core.request.get_body()
    local modified_body = body
    
    if body and not err then
        local content_type = headers["Content-Type"] or headers["content-type"] or ""
        if content_type:find("application/json") then
            local ok, json_data = pcall(cjson.decode, body)
            if ok and type(json_data) == "table" then
                json_data._request_id = request_id
                json_data._timestamp = math.floor(ngx.now() * 1000)
                modified_body = cjson.encode(json_data)
                headers["Content-Length"] = tostring(#modified_body)
            end
        end
    end

    if modified_body then
        params["body"] = modified_body
    end
    
    core.log.error("=====================防缓存请求=====================")
    core.log.error("Request ID: ", request_id)
    core.log.error("URL: ", uri .. "?" .. new_args)
    
    local res, err = http_obj:request(params)
    local body_content = ""
    
    if res and res.body_reader then
        local chunk, err
        while true do
            chunk, err = res.body_reader()
            if err then
                core.log.error("read response body error: ", err)
                break
            end
            if not chunk then
                break
            end
            body_content = body_content .. chunk
        end
    end
    
    core.log.error("=====================响应信息=====================")
    core.log.error("Status: ", res and res.status or "no response")
    core.log.error("Request ID: ", request_id)
    core.log.error("=====================认证服务响应=====================")
    core.log.error("认证服务状态码: ", res.status)
    core.log.error("认证服务响应体: ", body_content)
    
    if not res then
        close(http_obj)
        return nil, err
    end
    
    close(http_obj)
    return res, body_content, nil
end

local function send_chunk(body_content)
    local ok, print_err = ngx_print(body_content)
    if not ok then
        return "output response failed: ".. (print_err or "")
    end
    local ok, flush_err = ngx_flush(true)
    if not ok then
        core.log.warn("flush response failed: ", flush_err)
    end
    return nil
end


local function send_response(ctx, code, body_content)
    ngx.status = code or 500
    -- 发送响应体
    local err = send_chunk(body_content)
    if err then
        return err
    end
    return nil
end

function _M.before_proxy(conf, ctx)
    local res, body_content, err = get_response(ctx,conf)
    if not res or err then
        return 502
    end
    local code = (res and res.status) or 500
    if res and res.status == 200 then
        -- 业务逻辑
        local auth_headers = {
            ["X-Forwarded-Proto"] = core.request.get_scheme(ctx),
            ["X-Forwarded-Method"] = core.request.get_method(),
            ["X-Forwarded-Host"] = core.request.get_host(ctx),
            ["X-Ceai-Service-Id"] = conf.xCeaiServiceId,  -- 添加模型ID到请求头
            ["X-Forwarded-Uri"] = ctx.var.request_uri,
            ["X-Forwarded-For"] = core.request.get_remote_client_ip(ctx),
        }
        if conf.request_method == "POST" then
            auth_headers["Content-Length"] = core.request.header(ctx, "content-length")
            auth_headers["Expect"] = core.request.header(ctx, "expect")
            auth_headers["Transfer-Encoding"] = core.request.header(ctx, "transfer-encoding")
            auth_headers["Content-Encoding"] = core.request.header(ctx, "content-encoding")
        end
        if conf.extra_headers then
            for header, value in pairs(conf.extra_headers) do
                if type(value) == "number" then
                    value = tostring(value)
                end
                local resolve_value, err = core.utils.resolve_var(value, ctx.var)
                if not err then
                    auth_headers[header] = resolve_value
                end
                if err then
                    core.log.error("failed to resolve variable in extra header '",
                                    header, "': ",value,": ",err)
                end
            end
        end

        -- append headers that need to be get from the client request header
        if #conf.request_headers > 0 then
            for _, header in ipairs(conf.request_headers) do
                if not auth_headers[header] then
                    auth_headers[header] = core.request.header(ctx, header)
                end
            end
        end

        local params = {
            headers = auth_headers,
            keepalive = conf.keepalive,
            ssl_verify = conf.ssl_verify,
            method = "POST",  -- 固定使用POST方法
            body = body_content,
        }
        if conf.keepalive then
            params.keepalive_timeout = conf.keepalive_timeout
            params.keepalive_pool = conf.keepalive_pool
        end

        local httpc = http.new()
        httpc:set_timeout(conf.timeout)
        local res, err = httpc:request_uri(conf.uri, params)
        close(httpc)
        if not res and conf.allow_degradation then
            return
        elseif not res then
            core.log.warn("failed to process forward auth, err: ", err)
            err = send_response(ctx,conf.status_on_error,"")
            return 
        end
        -- 修改:处理鉴权响应的逻辑
        if res.status >= 300 then
            local client_headers = {}
            if #conf.client_headers > 0 then
                for _, header in ipairs(conf.client_headers) do
                    client_headers[header] = res.headers[header]
                end
            end
            core.response.set_header(client_headers)
            err = send_response(ctx,  res.status, res.body)
            return 
        end
        -- 2. 解析响应体判断鉴权结果
        local auth_body, parse_err = cjson.decode(res.body)
        if not auth_body then
            -- 记录详细错误:原始响应体+解析错误
            core.log.error("failed to parse authorization response: ", 
                            "body=", res.body, ", error=", parse_err)

            err = send_response(ctx,  500, parse_err)
        return 
        end
        
        if not auth_body.statusCode then
            core.log.error("statusCode is nil auth_body=", auth_body)
            err = send_response(ctx,  500, "statusCode is nil")
            return 
        end
        -- 3. 检查statusCode字段,statusCode不等于0表示鉴权失败
        if auth_body.statusCode and auth_body.statusCode ~= 0 then
            local client_headers = {}
            if #conf.client_headers > 0 then
                for _, header in ipairs(conf.client_headers) do
                    client_headers[header] = res.headers[header]
                end
            end
            core.response.set_header(client_headers)
            err = send_response(ctx, res.status, res.body)
            return 
        end
        -- 4. 鉴权成功,传递指定的响应头给上游服务
        for _, header in ipairs(conf.upstream_headers) do
            local header_value = res.headers[header]
            if header_value then
                core.request.set_header(ctx, header, header_value)
            end
        end
    end
    -- send origin response, status maybe changed.
    err = send_response(ctx, code, body_content)
    if err then
        core.log.error("send response error: ", tostring(err))
        return not ngx.headers_sent and 502 or nil
    end
    return 
end
return _M

[{"finish_reason":"length","index":0,"logprobs":null,"prompt_logprobs":null,"prompt_token_ids":null,"stop_reason":null,"text":"M78星云与成都有什么关联吗?M78星云与成都有什么关联吗?M78星云与成都有什么关联吗?M78星云与成都有什么关联吗?M78星云与成都有什么关联吗?M78星云与成都有什么关联吗?M78星","token_ids":null}],"created":1763014030,"id":"cmpl-ad0ecc9b60954d059f77aef4c5f62c3a","kv_transfer_params":null,"model":"model","object":"text_completion","service_tier":null,"system_fingerprint":null,"usage":{"completion_tokens":70,"prompt_tokens":13,"prompt_tokens_details":null,"total_tokens":83}}}, client: 192.168.65.1, server: _, request: "POST /protected-word-manager/api/v1/Content HTTP/1.1", host: "127.0.0.1:9080"

192.168.65.1 - - [16/Nov/2025:15:22:26 +0000] 127.0.0.1:9080 "POST /protected-word-manager/api/v1/Content HTTP/1.1" 400 63 0.015 "-" "PostmanRuntime/7.50.0" - - - "http://127.0.0.1:9080"

2025/11/16 15:22:29 [error] 49#49: *954 [lua] custom-output-check.lua:176: get_response(): =====================防缓存请求=====================, client: 192.168.65.1, server: _, request: "POST /protected-word-manager/api/v1/Content HTTP/1.1", host: "127.0.0.1:9080"

2025/11/16 15:22:29 [error] 49#49: *954 [lua] custom-output-check.lua:177: get_response(): Request ID: 081dfff041ec1be194c82307cd1f3014, client: 192.168.65.1, server: _, request: "POST /protected-word-manager/api/v1/Content HTTP/1.1", host: "127.0.0.1:9080"

2025/11/16 15:22:29 [error] 49#49: *954 [lua] custom-output-check.lua:178: get_response(): URL: /protected-word-manager/api/v1/Content?&_nc=081dfff041ec1be194c82307cd1f3014, client: 192.168.65.1, server: _, request: "POST /protected-word-manager/api/v1/Content HTTP/1.1", host: "127.0.0.1:9080"

2025/11/16 15:22:29 [error] 49#49: *954 [lua] custom-output-check.lua:198: get_response(): =====================响应信息=====================, client: 192.168.65.1, server: _, request: "POST /protected-word-manager/api/v1/Content HTTP/1.1", host: "127.0.0.1:9080"

2025/11/16 15:22:29 [error] 49#49: *954 [lua] custom-output-check.lua:199: get_response(): Status: 400, client: 192.168.65.1, server: _, request: "POST /protected-word-manager/api/v1/Content HTTP/1.1", host: "127.0.0.1:9080"

2025/11/16 15:22:29 [error] 49#49: *954 [lua] custom-output-check.lua:200: get_response(): Request ID: 081dfff041ec1be194c82307cd1f3014, client: 192.168.65.1, server: _, request: "POST /protected-word-manager/api/v1/Content HTTP/1.1", host: "127.0.0.1:9080"

2025/11/16 15:22:29 [error] 49#49: *954 [lua] custom-output-check.lua:201: get_response(): =====================认证服务响应=====================, client: 192.168.65.1, server: _, request: "POST /protected-word-manager/api/v1/Content HTTP/1.1", host: "127.0.0.1:9080"

2025/11/16 15:22:29 [error] 49#49: *954 [lua] custom-output-check.lua:202: get_response(): 认证服务状态码: 400, client: 192.168.65.1, server: _, request: "POST /protected-word-manager/api/v1/Content HTTP/1.1", host: "127.0.0.1:9080"

2025/11/16 15:22:29 [error] 49#49: *954 [lua] custom-output-check.lua:203: get_response(): 认证服务响应体: 400 Bad Request, client: 192.168.65.1, server: _, request: "POST /protected-word-manager/api/v1/Content HTTP/1.1", host: "127.0.0.1:9080"

192.168.65.1 - - [16/Nov/2025:15:22:29 +0000] 127.0.0.1:9080 "POST /protected-word-manager/api/v1/Content HTTP/1.1" 400 25 0.003 "-" "PostmanRuntime/7.50.0" - - - "http://127.0.0.1:9080"

Expected Behavior

No response

Error Logs

No response

Steps to Reproduce

Run APISIX via the Docker image

Environment

  • APISIX version (run apisix version):
  • Operating system (run uname -a):
  • OpenResty / Nginx version (run openresty -V or nginx -V):
  • etcd version, if relevant (run curl http://127.0.0.1:9090/v1/server_info):
  • APISIX Dashboard version, if relevant:
  • Plugin runner version, for issues related to plugin runners:
  • LuaRocks version, for installation issues (run luarocks --version):

Metadata

Metadata

Assignees

No one assigned

    Labels

    questionlabel for questions asked by users

    Type

    No type

    Projects

    Status

    📋 Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions