Apache APISIX by default gives inconsistent responses when returning errors. Here are some examples:
404 Not Found
:
{"error_msg":"404 Route Not Found"}
502 Bad Gateway
:
<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>openresty</center>
<p><em>Powered by <a href="https://apisix.apache.org/">APISIX</a>.</em></p></body>
</html>
As widely discussed in GitHub issues, this can be configured via a custom nginx configuration. Below are some references:
Is it possible to override this default behaviour using a custom plugin? How?
As an example, here's APISIX in Standalone Mode, running on Docker:
File structure:
.
├── conf/
│ ├── apisix.yaml
│ └── config.yaml
└── compose.yaml
compose.yaml
:
name: apisix-standalone
services:
apisix:
image: apache/apisix:3.13.0-ubuntu
stdin_open: true
tty: true
volumes:
- ./conf/config.yaml:/usr/local/apisix/conf/config.yaml
- ./conf/apisix.yaml:/usr/local/apisix/conf/apisix.yaml
ports:
- "9080:9080/tcp"
- "9443:9443/tcp"
networks:
apisix:
httpbin:
image: kennethreitz/httpbin:latest
ports:
- "3000:80/tcp"
networks:
apisix:
networks:
apisix:
driver: bridge
config.yaml
:
deployment:
role: data_plane
role_data_plane:
config_provider: yaml
apisix.yaml
:
upstreams:
- id: httpbin_internal
nodes:
"httpbin:80": 1
type: roundrobin
routes:
- id: base_internal
uri: /anything
upstream_id: httpbin_internal
- id: apisix_status
uri: /apisix_status/*
upstream_id: httpbin_internal
plugins:
serverless-post-function:
phase: access
functions:
- |
return function(conf, ctx)
local core = require("apisix.core")
local status_code = 200
local matched = ngx.re.match(ngx.var.uri, "^/apisix_status/([2-5][0-9]{2})$")
if matched then
status_code = tonumber(matched[1])
end
core.response.exit(status_code)
end
Run services:
docker compose up
Test APISIX default responses for some status codes:
Status code 404:
$ curl localhost:9080 -i
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
...
Server: APISIX/3.13.0
{"error_msg":"404 Route Not Found"}
Status code 500:
$ curl localhost:9080/apisix_status/500 -i
HTTP/1.1 500 Internal Server Error
Content-Type: text/html; charset=utf-8
...
Server: APISIX/3.13.0
<html>
<head><title>500 Internal Server Error</title></head>
<body>
<center><h1>500 Internal Server Error</h1></center>
<hr><center>openresty</center>
<p><em>Powered by <a href="https://apisix.apache.org/">APISIX</a>.</em></p></body>
</html>
Status code 502:
$ curl localhost:9080/apisix_status/502 -i
HTTP/1.1 502 Bad Gateway
Content-Type: text/html; charset=utf-8
...
Server: APISIX/3.13.0
<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>openresty</center>
<p><em>Powered by <a href="https://apisix.apache.org/">APISIX</a>.</em></p></body>
</html>
As we can notice, the responses are not normalized:
404
, with Content-Type: text/plain
500
, 502
(and many other status codes) with Content-Type: text/html
Yes, it's possible to override APISIX default responses via a custom plugin.
custom-response
File custom-response.lua
:
local plugin_name = "custom-response"
local ngx = ngx
local core = require("apisix.core")
local apisix_plugin = require("apisix.plugin")
local apisix_utils = require("apisix.core.utils")
local response_schema = {
description = "Custom response to return when APISIX responds a specific status codes.",
type = "object",
additionalProperties = false,
required = { "body" },
properties = {
body = {
description = "Response body.",
type = "string"
},
["content-type"] = {
description = "Response content type.",
type = "string",
},
},
}
local metadata_schema = {
type = "object",
additionalProperties = false,
patternProperties = {
["^error_[2-5]{1}[0-9]{2}$"] = response_schema,
},
properties = {
id = plugin_name,
enable = {
description = "Wheter the plugin is enabled or not.",
type = "boolean",
default = true,
},
set_content_length = {
description = "If true automatically set the Content-Length header. If set to false, removes the header.",
type = "boolean",
default = true,
},
},
}
local schema = {
type = "object",
properties = {},
}
local _M = {
version = 1.0,
priority = 0,
name = plugin_name,
schema = schema,
metadata_schema = metadata_schema,
}
-- Reference: https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
local status_texts = {
["100"] = "Continue",
["101"] = "Switching Protocols",
["102"] = "Processing",
["103"] = "Early Hints",
["200"] = "OK",
["201"] = "Created",
["202"] = "Accepted",
["203"] = "Non-Authoritative Information",
["204"] = "No Content",
["205"] = "Reset Content",
["206"] = "Partial Content",
["207"] = "Multi-Status",
["208"] = "Already Reported",
["226"] = "IM Used",
["300"] = "Multiple Choices",
["301"] = "Moved Permanently",
["302"] = "Found",
["303"] = "See Other",
["304"] = "Not Modified",
["305"] = "Use Proxy",
["307"] = "Temporary Redirect",
["308"] = "Permanent Redirect",
["400"] = "Bad Request",
["401"] = "Unauthorized",
["402"] = "Payment Required",
["403"] = "Forbidden",
["404"] = "Not Found",
["405"] = "Method Not Allowed",
["406"] = "Not Acceptable",
["407"] = "Proxy Authentication Required",
["408"] = "Request Timeout",
["409"] = "Conflict",
["410"] = "Gone",
["411"] = "Length Required",
["412"] = "Precondition Failed",
["413"] = "Content Too Large",
["414"] = "URI Too Long",
["415"] = "Unsupported Media Type",
["416"] = "Range Not Satisfiable",
["417"] = "Expectation Failed",
["418"] = "I'm a teapot",
["421"] = "Misdirected Request",
["422"] = "Unprocessable Content",
["423"] = "Locked",
["424"] = "Failed Dependency",
["425"] = "Too Early",
["426"] = "Upgrade Required",
["428"] = "Precondition Required",
["429"] = "Too Many Requests",
["431"] = "Request Header Fields Too Large",
["451"] = "Unavailable For Legal Reasons",
["500"] = "Internal Server Error",
["501"] = "Not Implemented",
["502"] = "Bad Gateway",
["503"] = "Service Unavailable",
["504"] = "Gateway Timeout",
["505"] = "HTTP Version Not Supported",
["506"] = "Variant Also Negotiates",
["507"] = "Insufficient Storage",
["508"] = "Loop Detected",
["510"] = "Not Extended",
["511"] = "Network Authentication Required",
default = "Something is wrong"
}
-- Reference: https://apisix.apache.org/docs/apisix/plugin-develop/#register-custom-variable
core.ctx.register_var("status_text", function(ctx)
return status_texts[ngx.var.status] or status_texts.default
end)
local function make_response(error)
local response = {}
response.body = error.body
response.headers = { ["Content-Type"] = error["content-type"] }
return response
end
function _M.check_schema(conf, schema_type)
if schema_type == core.schema.TYPE_METADATA then
return core.schema.check(metadata_schema, conf)
end
return true
end
function _M.header_filter(_, ctx)
local custom_response
local metadata = apisix_plugin.plugin_metadata(plugin_name)
if not metadata or not metadata.value.enable then
return
end
-- Return custom error page only if upstream didn't respond
if ngx.var.upstream_status then
return
end
for key, value in pairs(metadata.value) do
if not string.match(key, '^error_') then
goto continue
end
local error_code = string.gsub(key, "error_", "")
if ngx.status ~= tonumber(error_code) then
goto continue
end
if value.body then
custom_response = make_response(value)
break
end
::continue::
end
-- This means a condition was triggered and we set a custom page
if custom_response then
-- header manipulation must be performed in header_filter phase
if custom_response.headers then
for key, value in pairs(custom_response.headers) do
ngx.header[key] = value
end
end
-- Parse NGiNX variables
custom_response.body = apisix_utils.resolve_var(custom_response.body, ctx.var)
-- Set Content-Length header before body_phase
ngx.header['Content-Length'] = #(custom_response.body)
if not metadata.value.set_content_length then
ngx.header['Content-Length'] = nil
end
ctx.custom_response_body = custom_response.body
end
end
function _M.body_filter(_, ctx)
if ctx.custom_response_body then
local body = core.response.hold_body_chunk(ctx)
-- Don't send a response until we've read all chunks
if ngx.arg[2] == false and not body then
return
end
-- Last chunk was read, so we can return the response
ngx.arg[1] = ctx.custom_response_body
ctx.custom_response_body = nil
end
end
return _M
File structure:
.
├── conf/
│ ├── apisix.yaml
│ └── config.yaml
├── apisix/
│ └── plugins/
│ └── custom-response.lua
└── compose.yaml
File compose.yaml
:
name: apisix-standalone
services:
apisix:
image: apache/apisix:3.13.0-ubuntu
stdin_open: true
tty: true
volumes:
- ./conf/config.yaml:/usr/local/apisix/conf/config.yaml
- ./conf/apisix.yaml:/usr/local/apisix/conf/apisix.yaml
- ./apisix/plugins/:/usr/local/apisix/apisix/plugins/custom/apisix/plugins
ports:
- "9080:9080/tcp"
- "9443:9443/tcp"
networks:
apisix:
httpbin:
image: kennethreitz/httpbin:latest
ports:
- "3000:80/tcp"
networks:
apisix:
networks:
apisix:
driver: bridge
File config.yaml
:
apisix:
extra_lua_path: "/usr/local/apisix/apisix/plugins/custom/?.lua"
deployment:
role: data_plane
role_data_plane:
config_provider: yaml
plugins:
- custom-response # priority: 0
- serverless-post-function # priority: -2000
nginx_config:
http_server_configuration_snippet: |
set $custom_var "Hello World!";
File apisix.yaml
:
upstreams:
- id: httpbin_internal
nodes:
"httpbin:80": 1
type: roundrobin
global_rules:
- id: custom_response
plugins:
custom-response: {}
routes:
- id: base_internal
uri: /anything
upstream_id: httpbin_internal
- id: apisix_status
uri: /apisix_status/*
upstream_id: httpbin_internal
plugins:
serverless-post-function:
phase: access
functions:
- |
return function(conf, ctx)
local core = require("apisix.core")
local status_code = 200
local matched = ngx.re.match(ngx.var.uri, "^/apisix_status/([2-5][0-9]{2})$")
if matched then
status_code = tonumber(matched[1])
end
core.response.exit(status_code)
end
plugin_metadata:
- id: custom-response
enable: true
# Override default 404 response
error_404:
body: |
{
"status_code": $status,
"error_msg": "$status_text"
}
content-type: "application/json"
# Use a custom variable, set in config.yaml
error_500:
body: |
{
"status_code": $status,
"error_msg": "$status_text",
"msg": "$custom_var"
}
content-type: "application/json"
# Remove "Powered by APISIX"
error_502:
body: |
<html>
<head><title>$status $status_text</title></head>
<body>
<center><h1>$status $status_text</h1></center>
<hr><center>openresty</center>
</html>
content-type: "text/html; charset=utf-8"
#END
Run services:
docker compose up
Test APISIX default responses for some status codes:
status code 404:
$ curl localhost:9080 -i
HTTP/1.1 404 Not Found
Content-Type: application/json
Content-Length: 49
...
Server: APISIX/3.13.0
{
"status_code": 404,
"error_msg": "Not Found"
}
status code 500:
$ curl localhost:9080/apisix_status/500 -i
HTTP/1.1 500 Internal Server Error
Content-Type: application/json
Content-Length: 85
...
Server: APISIX/3.13.0
{
"status_code": 500,
"error_msg": "Internal Server Error",
"msg": "Hello World!"
}
status code 502:
$ curl localhost:9080/apisix_status/502 -i
HTTP/1.1 502 Bad Gateway
Content-Type: text/html; charset=utf-8
Content-Length: 139
...
Server: APISIX/3.13.0
<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>openresty</center>
</html>