This is an automated email from the ASF dual-hosted git repository.

AlinsRan pushed a commit to branch feat/error-page-plugin
in repository https://gitbox.apache.org/repos/asf/apisix.git

commit 38331df1451daa8f3cfd7b5a3e3fe734d9e39575
Author: AlinsRan <[email protected]>
AuthorDate: Mon May 18 03:26:55 2026 +0800

    feat(plugin): add error-page plugin
    
    The error-page plugin customizes the HTTP error response body and
    content type for APISIX-generated error responses. It uses plugin
    metadata for global configuration and intercepts error responses
    generated by APISIX itself (e.g., no matching route, unreachable
    upstream), leaving upstream-generated responses unaffected.
    
    Key features:
    - Global on/off switch via `enable` in plugin metadata
    - Supports any HTTP status code via patternProperties (100-599)
    - Configurable response body and content-type per status code
    - Falls back to default nginx/APISIX error page when not configured
    
    Co-authored-by: Copilot <[email protected]>
---
 apisix/plugins/error-page.lua        | 112 ++++++
 conf/config.yaml.example             |   1 +
 conf2/config-default.yaml            | 648 +++++++++++++++++++++++++++++++++++
 docs/en/latest/config.json           |   1 +
 docs/en/latest/plugins/error-page.md | 160 +++++++++
 docs/zh/latest/plugins/error-page.md | 162 +++++++++
 t/admin/plugins.t                    |   1 +
 t/plugin/error-page.t                | 340 ++++++++++++++++++
 8 files changed, 1425 insertions(+)

diff --git a/apisix/plugins/error-page.lua b/apisix/plugins/error-page.lua
new file mode 100644
index 000000000..5e6bada0c
--- /dev/null
+++ b/apisix/plugins/error-page.lua
@@ -0,0 +1,112 @@
+--
+-- 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 plugin      = require("apisix.plugin")
+local plugin_name = "error-page"
+local ngx         = ngx
+
+
+local metadata_schema = {
+    type = "object",
+    properties = {
+        enable = {type = "boolean", default = false},
+    },
+    patternProperties = {
+        ["^error_[1-5][0-9][0-9]$"] = {
+            type = "object",
+            properties = {
+                body = {type = "string", minLength = 1},
+                content_type = {type = "string", default = "text/html"},
+            },
+        },
+    },
+}
+
+local schema = {}
+
+local _M = {
+    version         = 0.1,
+    priority        = 450,
+    name            = plugin_name,
+    schema          = schema,
+    metadata_schema = metadata_schema,
+}
+
+
+function _M.check_schema(conf, schema_type)
+    if schema_type == core.schema.TYPE_METADATA then
+        return core.schema.check(metadata_schema, conf)
+    end
+    return core.schema.check(schema, conf)
+end
+
+
+-- return metadata only if the response should be modified
+local function get_metadata(ctx)
+    local status = ngx.status
+    if ctx.var.upstream_status then
+        return nil
+    end
+
+    if status < 400 then
+        return nil
+    end
+
+    local metadata = plugin.plugin_metadata(plugin_name)
+    if not metadata then
+        core.log.info("failed to read metadata for ", plugin_name)
+        return nil
+    end
+    core.log.info(plugin_name, " metadata: ", core.json.delay_encode(metadata))
+    metadata = metadata.value
+    if not metadata.enable then
+        return nil
+    end
+
+    local err_page = metadata["error_" .. status]
+    if not err_page or not (err_page.body and #err_page.body > 0) then
+        core.log.info("error page for error_", status, " not defined, default 
will be used.")
+        return nil
+    end
+
+    return metadata
+end
+
+
+function _M.header_filter(conf, ctx)
+    ctx.plugin_error_page_meta = get_metadata(ctx)
+    if not ctx.plugin_error_page_meta then
+        return
+    end
+    local status = ngx.status
+    local err_page = ctx.plugin_error_page_meta["error_" .. status]
+    core.response.set_header("content-type", err_page.content_type)
+    core.response.set_header("content-length", #err_page.body)
+end
+
+
+function _M.body_filter(conf, ctx)
+    if not ctx.plugin_error_page_meta then
+        return
+    end
+
+    ngx.arg[1] = ctx.plugin_error_page_meta["error_" .. ngx.status].body
+    ngx.arg[2] = true
+end
+
+
+return _M
diff --git a/conf/config.yaml.example b/conf/config.yaml.example
index 6023c83bc..b6c20f986 100644
--- a/conf/config.yaml.example
+++ b/conf/config.yaml.example
@@ -547,6 +547,7 @@ plugins:                           # plugin list (sorted by 
priority)
   - public-api                     # priority: 501
   - prometheus                     # priority: 500
   - datadog                        # priority: 495
+  - error-page                     # priority: 450
   - lago                           # priority: 415
   - loki-logger                    # priority: 414
   - elasticsearch-logger           # priority: 413
diff --git a/conf2/config-default.yaml b/conf2/config-default.yaml
new file mode 100644
index 000000000..11b1f79ac
--- /dev/null
+++ b/conf2/config-default.yaml
@@ -0,0 +1,648 @@
+#
+# 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.
+#
+# PLEASE DO NOT UPDATE THIS FILE!
+# If you want to set the specified configuration value, you can set the new
+# value in the conf/config.yaml file.
+#
+
+apisix:
+  # node_listen: 9080               # APISIX listening port
+  node_listen:                      # This style support multiple ports
+    - 9080
+  #   - port: 9081
+  #     enable_http2: true          # If not set, the default value is `false`.
+  #   - ip: 127.0.0.2               # Specific IP, If not set, the default 
value is `0.0.0.0`.
+  #     port: 9082
+  #     enable_http2: true
+  enable_admin: true
+  enable_dev_mode: false            # Sets nginx worker_processes to 1 if set 
to true
+  enable_reuseport: true            # Enable nginx SO_REUSEPORT switch if set 
to true.
+  show_upstream_status_in_response_header: false # when true all upstream 
status write to `X-APISIX-Upstream-Status` otherwise only 5xx code
+  enable_ipv6: true
+
+  #proxy_protocol:                  # Proxy Protocol configuration
+  #  listen_http_port: 9181         # The port with proxy protocol for http, 
it differs from node_listen and admin_listen.
+                                    # This port can only receive http request 
with proxy protocol, but node_listen & admin_listen
+                                    # can only receive http request. If you 
enable proxy protocol, you must use this port to
+                                    # receive http request with proxy protocol
+  #  listen_https_port: 9182        # The port with proxy protocol for https
+  #  enable_tcp_pp: true            # Enable the proxy protocol for tcp proxy, 
it works for stream_proxy.tcp option
+  #  enable_tcp_pp_to_upstream: true # Enables the proxy protocol to the 
upstream server
+  enable_server_tokens: true        # Whether the APISIX version number should 
be shown in Server header.
+                                    # It's enabled by default.
+
+  # configurations to load third party code and/or override the builtin one.
+  extra_lua_path: ""                # extend lua_package_path to load third 
party code
+  extra_lua_cpath: ""               # extend lua_package_cpath to load third 
party code
+  #lua_module_hook: "my_project.my_hook"  # the hook module which will be used 
to inject third party code into APISIX
+
+  proxy_cache:                      # Proxy Caching configuration
+    cache_ttl: 10s                  # The default caching time in disk if the 
upstream does not specify the cache time
+    zones:                          # The parameters of a cache
+      - name: disk_cache_one        # The name of the cache, administrator can 
specify
+                                    # which cache to use by name in the admin 
api (disk|memory)
+        memory_size: 50m            # The size of shared memory, it's used to 
store the cache index for
+                                    # disk strategy, store cache content for 
memory strategy (disk|memory)
+        disk_size: 1G               # The size of disk, it's used to store the 
cache data (disk)
+        disk_path: /tmp/disk_cache_one  # The path to store the cache data 
(disk)
+        cache_levels: 1:2           # The hierarchy levels of a cache (disk)
+      #- name: disk_cache_two
+      #  memory_size: 50m
+      #  disk_size: 1G
+      #  disk_path: "/tmp/disk_cache_two"
+      #  cache_levels: "1:2"
+      - name: memory_cache
+        memory_size: 50m
+
+  delete_uri_tail_slash: false    # delete the '/' at the end of the URI
+  # The URI normalization in servlet is a little different from the RFC's.
+  # See 
https://github.com/jakartaee/servlet/blob/master/spec/src/main/asciidoc/servlet-spec-body.adoc#352-uri-path-canonicalization,
+  # which is used under Tomcat.
+  # Turn this option on if you want to be compatible with servlet when 
matching URI path.
+  normalize_uri_like_servlet: false
+  router:
+    http: radixtree_uri         # radixtree_uri: match route by uri(base on 
radixtree)
+                                  # radixtree_host_uri: match route by host + 
uri(base on radixtree)
+                                  # radixtree_uri_with_parameter: like 
radixtree_uri but match uri with parameters,
+                                  #   see 
https://github.com/api7/lua-resty-radixtree/#parameters-in-path for
+                                  #   more details.
+    ssl: radixtree_sni          # radixtree_sni: match route by SNI(base on 
radixtree)
+  #stream_proxy:                  # TCP/UDP proxy
+  #  only: true                   # use stream proxy only, don't enable HTTP 
stuff
+  #  tcp:                         # TCP proxy port list
+  #    - addr: 9100
+  #      tls: true
+  #    - addr: "127.0.0.1:9101"
+  #  udp:                         # UDP proxy port list
+  #    - 9200
+  #    - "127.0.0.1:9201"
+  #dns_resolver:                  # If not set, read from `/etc/resolv.conf`
+  #  - 1.1.1.1
+  #  - 8.8.8.8
+  #dns_resolver_valid: 30         # if given, override the TTL of the valid 
records. The unit is second.
+  resolver_timeout: 5             # resolver timeout
+  enable_resolv_search_opt: true  # enable search option in resolv.conf
+  ssl:
+    enable: true
+    listen:                       # APISIX listening port in https.
+      - port: 9443
+        enable_http2: true
+    #   - ip: 127.0.0.3           # Specific IP, If not set, the default value 
is `0.0.0.0`.
+    #     port: 9445
+    #     enable_http2: true
+    #ssl_trusted_certificate: /path/to/ca-cert  # Specifies a file path with 
trusted CA certificates in the PEM format
+                                                # used to verify the 
certificate when APISIX needs to do SSL/TLS handshaking
+                                                # with external services (e.g. 
etcd)
+    ssl_protocols: TLSv1.2 TLSv1.3
+    ssl_ciphers: 
ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
+    ssl_session_tickets: false              #  disable ssl_session_tickets by 
default for 'ssl_session_tickets' would make Perfect Forward Secrecy useless.
+                                            #  ref: 
https://github.com/mozilla/server-side-tls/issues/135
+
+    key_encrypt_salt:             #  If not set, will save origin ssl key into 
etcd.
+      - edd1c9f0985e76a2          #  If set this, the key_encrypt_salt should 
be an array whose elements are string, and the size is also 16, and it will 
encrypt ssl key with AES-128-CBC
+                                  #  !!! So do not change it after saving your 
ssl, it can't decrypt the ssl keys have be saved if you change !!
+                                  #  Only use the first key to encrypt, and 
decrypt in the order of the array.
+
+    #fallback_sni: "my.default.domain"      # If set this, when the client 
doesn't send SNI during handshake, the fallback SNI will be used instead
+  enable_control: true
+  #control:
+  #  ip: 127.0.0.1
+  #  port: 9090
+  disable_sync_configuration_during_start: false  # safe exit. Remove this 
once the feature is stable
+  data_encryption:                # add `encrypt_fields = { $field },` in 
plugin schema to enable encryption
+    enable: false                 # if not set, the default value is `false`.
+    keyring:
+      - qeddd145sfvddff3          # If not set, will save origin value into 
etcd.
+                                  # If set this, the keyring should be an 
array whose elements are string, and the size is also 16, and it will encrypt 
fields with AES-128-CBC
+                                  # !!! So do not change it after encryption, 
it can't decrypt the fields have be saved if you change !!
+                                  # Only use the first key to encrypt, and 
decrypt in the order of the array.
+
+nginx_config:                     # config for render the template to generate 
nginx.conf
+  #user: root                     # specifies the execution user of the worker 
process.
+                                  # the "user" directive makes sense only if 
the master process runs with super-user privileges.
+                                  # if you're not root user,the default is 
current user.
+  error_log: logs/error.log
+  error_log_level:  warn          # warn,error
+  worker_processes: 1          # if you want use multiple cores in container, 
you can inject the number of cpu as environment variable 
"APISIX_WORKER_PROCESSES"
+  enable_cpu_affinity: false      # disable CPU affinity by default, if APISIX 
is deployed on a physical machine, it can be enabled and work well.
+  worker_rlimit_nofile: 20480     # the number of files a worker process can 
open, should be larger than worker_connections
+  worker_shutdown_timeout: 240s   # timeout for a graceful shutdown of worker 
processes
+
+  max_pending_timers: 16384       # increase it if you see "too many pending 
timers" error
+  max_running_timers: 4096        # increase it if you see 
"lua_max_running_timers are not enough" error
+
+  event:
+    worker_connections: 10620
+  #envs:                          # allow to get a list of environment 
variables
+  #  - TEST_ENV
+
+  meta:
+    lua_shared_dict:
+      prometheus-metrics: 15m
+
+  stream:
+    enable_access_log: false         # enable access log or not, default false
+    access_log: logs/access_stream.log
+    access_log_format: "$remote_addr [$time_local] $protocol $status 
$bytes_sent $bytes_received $session_time"
+                                            # create your custom log format by 
visiting http://nginx.org/en/docs/varindex.html
+    access_log_format_escape: default       # allows setting json or default 
characters escaping in variables
+    lua_shared_dict:
+      etcd-cluster-health-check-stream: 10m
+      lrucache-lock-stream: 10m
+      plugin-limit-conn-stream: 10m
+      worker-events-stream: 10m
+      tars-stream: 1m
+
+  # As user can add arbitrary configurations in the snippet,
+  # it is user's responsibility to check the configurations
+  # don't conflict with APISIX.
+  main_configuration_snippet: |
+    # Add custom Nginx main configuration to nginx.conf.
+    # The configuration should be well indented!
+  http_configuration_snippet: |
+    # Add custom Nginx http configuration to nginx.conf.
+    # The configuration should be well indented!
+  http_server_configuration_snippet: |
+    # Add custom Nginx http server configuration to nginx.conf.
+    # The configuration should be well indented!
+  http_server_location_configuration_snippet: |
+    # Add custom Nginx http server location configuration to nginx.conf.
+    # The configuration should be well indented!
+  http_admin_configuration_snippet: |
+    # Add custom Nginx admin server configuration to nginx.conf.
+    # The configuration should be well indented!
+  http_end_configuration_snippet: |
+    # Add custom Nginx http end configuration to nginx.conf.
+    # The configuration should be well indented!
+  stream_configuration_snippet: |
+    # Add custom Nginx stream configuration to nginx.conf.
+    # The configuration should be well indented!
+
+  http:
+    enable_access_log: true         # enable access log or not, default true
+    access_log: logs/access.log
+    access_log_format: "$remote_addr - $remote_user [$time_local] $http_host 
\"$request\" $status $body_bytes_sent $request_time \"$http_referer\" 
\"$http_user_agent\" $upstream_addr $upstream_status $upstream_response_time 
\"$upstream_scheme://$upstream_host$upstream_uri\""
+    access_log_format_escape: default       # allows setting json or default 
characters escaping in variables
+    keepalive_timeout: 60s          # timeout during which a keep-alive client 
connection will stay open on the server side.
+    client_header_timeout: 60s      # timeout for reading client request 
header, then 408 (Request Time-out) error is returned to the client
+    client_body_timeout: 60s        # timeout for reading client request body, 
then 408 (Request Time-out) error is returned to the client
+    client_max_body_size: 0         # The maximum allowed size of the client 
request body.
+                                    # If exceeded, the 413 (Request Entity Too 
Large) error is returned to the client.
+                                    # Note that unlike Nginx, we don't limit 
the body size by default.
+
+    send_timeout: 10s              # timeout for transmitting a response to 
the client.then the connection is closed
+    underscores_in_headers: "on"   # default enables the use of underscores in 
client request header fields
+    real_ip_header: X-Real-IP      # 
http://nginx.org/en/docs/http/ngx_http_realip_module.html#real_ip_header
+    real_ip_recursive: "off"       # 
http://nginx.org/en/docs/http/ngx_http_realip_module.html#real_ip_recursive
+    real_ip_from:                  # 
http://nginx.org/en/docs/http/ngx_http_realip_module.html#set_real_ip_from
+      - 127.0.0.1
+      - "unix:"
+    #custom_lua_shared_dict:       # add custom shared cache to nginx.conf
+    #  ipc_shared_dict: 100m       # custom shared cache, format: `cache-key: 
cache-size`
+
+    # Enables or disables passing of the server name through TLS Server Name 
Indication extension (SNI, RFC 6066)
+    # when establishing a connection with the proxied HTTPS server.
+    proxy_ssl_server_name: true
+    upstream:
+      keepalive: 320                # Sets the maximum number of idle 
keepalive connections to upstream servers that are preserved in the cache of 
each worker process.
+                                    # When this number is exceeded, the least 
recently used connections are closed.
+      keepalive_requests: 1000      # Sets the maximum number of requests that 
can be served through one keepalive connection.
+                                    # After the maximum number of requests is 
made, the connection is closed.
+      keepalive_timeout: 60s        # Sets a timeout during which an idle 
keepalive connection to an upstream server will stay open.
+    charset: utf-8                  # Adds the specified charset to the 
"Content-Type" response header field, see
+                                    # 
http://nginx.org/en/docs/http/ngx_http_charset_module.html#charset
+    variables_hash_max_size: 2048   # Sets the maximum size of the variables 
hash table.
+
+    lua_shared_dict:
+      internal-status: 10m
+      plugin-limit-req: 10m
+      plugin-limit-count: 10m
+      prometheus-metrics: 10m
+      plugin-limit-conn: 10m
+      upstream-healthcheck: 10m
+      worker-events: 10m
+      lrucache-lock: 10m
+      balancer-ewma: 10m
+      balancer-ewma-locks: 10m
+      balancer-ewma-last-touched-at: 10m
+      plugin-limit-count-redis-cluster-slot-lock: 1m
+      plugin-graphql-limit-count: 10m
+      plugin-graphql-limit-count-reset-header: 10m
+      tracing_buffer: 10m
+      plugin-api-breaker: 10m
+      etcd-cluster-health-check: 10m
+      discovery: 1m
+      jwks: 1m
+      introspection: 10m
+      access-tokens: 1m
+      ext-plugin: 1m
+      tars: 1m
+      cas-auth: 10m
+
+#discovery:                       # service discovery center
+#  dns:
+#    servers:
+#      - "127.0.0.1:8600"         # use the real address of your dns server
+#    order:                       # order in which to try different dns record 
types when resolving
+#      - last                     # "last" will try the last previously 
successful type for a hostname.
+#      - SRV
+#      - A
+#      - AAAA
+#      - CNAME
+#  eureka:
+#    host:                        # it's possible to define multiple eureka 
hosts addresses of the same eureka cluster.
+#      - "http://127.0.0.1:8761";
+#    prefix: /eureka/
+#    fetch_interval: 30           # default 30s
+#    weight: 100                  # default weight for node
+#    timeout:
+#      connect: 2000              # default 2000ms
+#      send: 2000                 # default 2000ms
+#      read: 5000                 # default 5000ms
+#  nacos:
+#    host:
+#      - "http://${username}:${password}@${host1}:${port1}";
+#    prefix: "/nacos/v1/"
+#    fetch_interval: 30    # default 30 sec
+#    weight: 100           # default 100
+#    timeout:
+#      connect: 2000       # default 2000 ms
+#      send: 2000          # default 2000 ms
+#      read: 5000          # default 5000 ms
+#  consul_kv:
+#    servers:
+#      - "http://127.0.0.1:8500";
+#      - "http://127.0.0.1:8600";
+#    prefix: "upstreams"
+#    skip_keys:                    # if you need to skip special keys
+#      - "upstreams/unused_api/"
+#    timeout:
+#      connect: 2000               # default 2000 ms
+#      read: 2000                  # default 2000 ms
+#      wait: 60                    # default 60 sec
+#    weight: 1                     # default 1
+#    fetch_interval: 3             # default 3 sec, only take effect for 
keepalive: false way
+#    keepalive: true               # default true, use the long pull way to 
query consul servers
+#    default_server:               # you can define default server when 
missing hit
+#      host: "127.0.0.1"
+#      port: 20999
+#      metadata:
+#        fail_timeout: 1           # default 1 ms
+#        weight: 1                 # default 1
+#        max_fails: 1              # default 1
+#    dump:                         # if you need, when registered nodes 
updated can dump into file
+#       path: "logs/consul_kv.dump"
+#       expire: 2592000            # unit sec, here is 30 day
+#  consul:
+#    servers:                          # make sure service name is unique in 
these consul servers
+#      - "http://127.0.0.1:8500";       # `http://127.0.0.1:8500` and 
`http://127.0.0.1:8600` are different clusters
+#      - "http://127.0.0.1:8600";
+#    skip_services:                    # if you need to skip special services
+#      - "service_a"                   # `consul` service is default skip 
service
+#    timeout:
+#      connect: 2000               # default 2000 ms
+#      read: 2000                  # default 2000 ms
+#      wait: 60                    # default 60 sec
+#    weight: 1                     # default 1
+#    fetch_interval: 3             # default 3 sec, only take effect for 
keepalive: false way
+#    keepalive: true               # default true, use the long pull way to 
query consul servers
+#    default_service:               # you can define default server when 
missing hit
+#      host: "127.0.0.1"
+#      port: 20999
+#      metadata:
+#        fail_timeout: 1           # default 1 ms
+#        weight: 1                 # default 1
+#        max_fails: 1              # default 1
+#    dump:                         # if you need, when registered nodes 
updated can dump into file
+#       path: "logs/consul.dump"
+#       expire: 2592000            # unit sec, here is 30 day
+#       load_on_init: true         # default true, load the consul dump file 
on init
+#  kubernetes:
+#    ### kubernetes service discovery both support single-cluster and 
multi-cluster mode
+#    ### applicable to the case where the service is distributed in a single 
or multiple kubernetes clusters.
+#
+#    ### single-cluster mode ###
+#    service:
+#      schema: https                     #apiserver schema, options [http, 
https], default https
+#      host: ${KUBERNETES_SERVICE_HOST}  #apiserver host, options [ipv4, ipv6, 
domain, environment variable], default ${KUBERNETES_SERVICE_HOST}
+#      port: ${KUBERNETES_SERVICE_PORT}  #apiserver port, options [port 
number, environment variable], default ${KUBERNETES_SERVICE_PORT}
+#    client:
+#      # serviceaccount token or path of serviceaccount token_file
+#      token_file: ${KUBERNETES_CLIENT_TOKEN_FILE}
+#      # token: |-
+#       # 
eyJhbGciOiJSUzI1NiIsImtpZCI6Ikx5ME1DNWdnbmhQNkZCNlZYMXBsT3pYU3BBS2swYzBPSkN3ZnBESGpkUEEif
+#       # 
6Ikx5ME1DNWdnbmhQNkZCNlZYMXBsT3pYU3BBS2swYzBPSkN3ZnBESGpkUEEifeyJhbGciOiJSUzI1NiIsImtpZCI
+#    # kubernetes discovery plugin support use namespace_selector
+#    # you can use one of [equal, not_equal, match, not_match] filter namespace
+#    namespace_selector:
+#      # only save endpoints with namespace equal default
+#      equal: default
+#      # only save endpoints with namespace not equal default
+#      #not_equal: default
+#      # only save endpoints with namespace match one of [default, ^my-[a-z]+$]
+#      #match:
+#      #- default
+#      #- ^my-[a-z]+$
+#      # only save endpoints with namespace not match one of [default, 
^my-[a-z]+$ ]
+#      #not_match:
+#      #- default
+#      #- ^my-[a-z]+$
+#    # kubernetes discovery plugin support use label_selector
+#    # for the expression of label_selector, please refer to 
https://kubernetes.io/docs/concepts/overview/working-with-objects/labels
+#    label_selector: |-
+#      first="a",second="b"
+#    # reserved lua shared memory size,1m memory can store about 1000 pieces 
of endpoint
+#    shared_size: 1m #default 1m
+#    ### single-cluster mode ###
+#
+#    ### multi-cluster mode ###
+#  - id: release  # a custom name refer to the cluster, pattern ^[a-z0-9]{1,8}
+#    service:
+#      schema: https                     #apiserver schema, options [http, 
https], default https
+#      host: ${KUBERNETES_SERVICE_HOST}  #apiserver host, options [ipv4, ipv6, 
domain, environment variable]
+#      port: ${KUBERNETES_SERVICE_PORT}  #apiserver port, options [port 
number, environment variable]
+#    client:
+#      # serviceaccount token or path of serviceaccount token_file
+#      token_file: ${KUBERNETES_CLIENT_TOKEN_FILE}
+#      # token: |-
+#       # 
eyJhbGciOiJSUzI1NiIsImtpZCI6Ikx5ME1DNWdnbmhQNkZCNlZYMXBsT3pYU3BBS2swYzBPSkN3ZnBESGpkUEEif
+#       # 
6Ikx5ME1DNWdnbmhQNkZCNlZYMXBsT3pYU3BBS2swYzBPSkN3ZnBESGpkUEEifeyJhbGciOiJSUzI1NiIsImtpZCI
+#    # kubernetes discovery plugin support use namespace_selector
+#    # you can use one of [equal, not_equal, match, not_match] filter namespace
+#    namespace_selector:
+#      # only save endpoints with namespace equal default
+#      equal: default
+#      # only save endpoints with namespace not equal default
+#      #not_equal: default
+#      # only save endpoints with namespace match one of [default, ^my-[a-z]+$]
+#      #match:
+#      #- default
+#      #- ^my-[a-z]+$
+#      # only save endpoints with namespace not match one of [default, 
^my-[a-z]+$ ]
+#      #not_match:
+#      #- default
+#      #- ^my-[a-z]+$
+#    # kubernetes discovery plugin support use label_selector
+#    # for the expression of label_selector, please refer to 
https://kubernetes.io/docs/concepts/overview/working-with-objects/labels
+#    label_selector: |-
+#      first="a",second="b"
+#    # reserved lua shared memory size,1m memory can store about 1000 pieces 
of endpoint
+#    shared_size: 1m #default 1m
+#    ### multi-cluster mode ###
+
+graphql:
+  max_size: 1048576               # the maximum size limitation of graphql in 
bytes, default 1MiB
+
+#ext-plugin:
+  #cmd: ["ls", "-l"]
+
+plugins:                          # plugin list (sorted by priority)
+  - real-ip                        # priority: 23000
+  - ai                             # priority: 22900
+  - client-control                 # priority: 22000
+  - proxy-control                  # priority: 21990
+  - request-id                     # priority: 12015
+  - zipkin                         # priority: 12011
+  #- skywalking                    # priority: 12010
+  #- opentelemetry                 # priority: 12009
+  - ext-plugin-pre-req             # priority: 12000
+  - fault-injection                # priority: 11000
+  - mocking                        # priority: 10900
+  - serverless-pre-function        # priority: 10000
+  #- batch-requests                # priority: 4010
+  - cors                           # priority: 4000
+  - ip-restriction                 # priority: 3000
+  - ua-restriction                 # priority: 2999
+  - referer-restriction            # priority: 2990
+  - csrf                           # priority: 2980
+  - uri-blocker                    # priority: 2900
+  - request-validation             # priority: 2800
+  - openid-connect                 # priority: 2599
+  - cas-auth                       # priority: 2597
+  - authz-casbin                   # priority: 2560
+  - authz-casdoor                  # priority: 2559
+  - wolf-rbac                      # priority: 2555
+  - ldap-auth                      # priority: 2540
+  - hmac-auth                      # priority: 2530
+  - basic-auth                     # priority: 2520
+  - jwt-auth                       # priority: 2510
+  - key-auth                       # priority: 2500
+  - consumer-restriction           # priority: 2400
+  - forward-auth                   # priority: 2002
+  - opa                            # priority: 2001
+  - authz-keycloak                 # priority: 2000
+  #- error-log-logger              # priority: 1091
+  - body-transformer               # priority: 1080
+  - proxy-mirror                   # priority: 1010
+  - proxy-cache                    # priority: 1009
+  - proxy-rewrite                  # priority: 1008
+  - workflow                       # priority: 1006
+  - api-breaker                    # priority: 1005
+  - graphql-limit-count            # priority: 1004
+  - limit-conn                     # priority: 1003
+  - limit-count                    # priority: 1002
+  - limit-req                      # priority: 1001
+  #- node-status                   # priority: 1000
+  - gzip                           # priority: 995
+  - server-info                    # priority: 990
+  - traffic-split                  # priority: 966
+  - redirect                       # priority: 900
+  - response-rewrite               # priority: 899
+  - degraphql                      # priority: 509
+  - kafka-proxy                    # priority: 508
+  #- dubbo-proxy                   # priority: 507
+  - grpc-transcode                 # priority: 506
+  - grpc-web                       # priority: 505
+  - public-api                     # priority: 501
+  - prometheus                     # priority: 500
+  - datadog                        # priority: 495
+  - error-page                     # priority: 450
+  - elasticsearch-logger           # priority: 413
+  - echo                           # priority: 412
+  - loggly                         # priority: 411
+  - http-logger                    # priority: 410
+  - splunk-hec-logging             # priority: 409
+  - skywalking-logger              # priority: 408
+  - google-cloud-logging           # priority: 407
+  - sls-logger                     # priority: 406
+  - tcp-logger                     # priority: 405
+  - kafka-logger                   # priority: 403
+  - rocketmq-logger                # priority: 402
+  - syslog                         # priority: 401
+  - udp-logger                     # priority: 400
+  - file-logger                    # priority: 399
+  - clickhouse-logger              # priority: 398
+  - tencent-cloud-cls              # priority: 397
+  - inspect                        # priority: 200
+  #- log-rotate                    # priority: 100
+  # <- recommend to use priority (0, 100) for your custom plugins
+  - example-plugin                 # priority: 0
+  #- gm                            # priority: -43
+  - aws-lambda                     # priority: -1899
+  - azure-functions                # priority: -1900
+  - openwhisk                      # priority: -1901
+  - openfunction                   # priority: -1902
+  - serverless-post-function       # priority: -2000
+  - ext-plugin-post-req            # priority: -3000
+  - ext-plugin-post-resp           # priority: -4000
+
+stream_plugins: # sorted by priority
+  - ip-restriction                 # priority: 3000
+  - limit-conn                     # priority: 1003
+  - mqtt-proxy                     # priority: 1000
+  #- prometheus                    # priority: 500
+  - syslog                         # priority: 401
+  # <- recommend to use priority (0, 100) for your custom plugins
+
+#wasm:
+  #plugins:
+    #- name: wasm_log
+      #priority: 7999
+      #file: t/wasm/log/main.go.wasm
+
+#xrpc:
+  #protocols:
+    #- name: pingpong
+
+plugin_attr:
+  log-rotate:
+    interval: 3600    # rotate interval (unit: second)
+    max_kept: 168     # max number of log files will be kept
+    max_size: -1      # max size bytes of log files to be rotated, size check 
would be skipped with a value less than 0
+    enable_compression: false    # enable log file compression(gzip) or not, 
default false
+  skywalking:
+    service_name: APISIX
+    service_instance_name: APISIX Instance Name
+    endpoint_addr: http://127.0.0.1:12800
+  opentelemetry:
+    trace_id_source: x-request-id
+    resource:
+      service.name: APISIX
+    collector:
+      address: 127.0.0.1:4318
+      request_timeout: 3
+      request_headers:
+        Authorization: token
+    batch_span_processor:
+      drop_on_queue_full: false
+      max_queue_size: 1024
+      batch_timeout: 2
+      inactive_timeout: 1
+      max_export_batch_size: 16
+  prometheus:
+    export_uri: /apisix/prometheus/metrics
+    metric_prefix: apisix_
+    enable_export_server: true
+    export_addr:
+      ip: 127.0.0.1
+      port: 9091
+    #metrics:
+    #  http_status:
+    #    # extra labels from nginx variables
+    #    extra_labels:
+    #      # the label name doesn't need to be the same as variable name
+    #      # below labels are only examples, you could add any valid variables 
as you need
+    #      - upstream_addr: $upstream_addr
+    #      - upstream_status: $upstream_status
+    #  http_latency:
+    #    extra_labels:
+    #      - upstream_addr: $upstream_addr
+    #  bandwidth:
+    #    extra_labels:
+    #      - upstream_addr: $upstream_addr
+  server-info:
+    report_ttl: 60   # live time for server info in etcd (unit: second)
+  dubbo-proxy:
+    upstream_multiplex_count: 32
+  request-id:
+    snowflake:
+      enable: false
+      snowflake_epoc: 1609459200000   # the starting timestamp is expressed in 
milliseconds
+      data_machine_bits: 12           # data machine bit, maximum 31, because 
Lua cannot do bit operations greater than 31
+      sequence_bits: 10               # each machine generates a maximum of (1 
<< sequence_bits) serial numbers per millisecond
+      data_machine_ttl: 30            # live time for data_machine in etcd 
(unit: second)
+      data_machine_interval: 10       # lease renewal interval in etcd (unit: 
second)
+  proxy-mirror:
+    timeout:                          # proxy timeout in mirrored sub-request
+      connect: 60s
+      read: 60s
+      send: 60s
+#  redirect:
+#    https_port: 8443   # the default port for use by HTTP redirects to HTTPS
+  inspect:
+    delay: 3            # in seconds
+    hooks_file: "/usr/local/apisix/plugin_inspect_hooks.lua"
+
+deployment:
+  role: traditional
+  role_traditional:
+    config_provider: etcd
+  admin:
+    # Default token when use API to call for Admin API.
+    # *NOTE*: Highly recommended to modify this value to protect APISIX's 
Admin API.
+    # Disabling this configuration item means that the Admin API does not
+    # require any authentication.
+    admin_key:
+      -
+        name: admin
+        key: edd1c9f034335f136f87ad84b625c8f1
+        role: admin                 # admin: manage all configuration data
+                                    # viewer: only can view configuration data
+      -
+        name: viewer
+        key: 4054f7cf07e344346cd3f287985e76a2
+        role: viewer
+
+    enable_admin_cors: true         # Admin API support CORS response headers.
+    allow_admin:                    # 
http://nginx.org/en/docs/http/ngx_http_access_module.html#allow
+      - 127.0.0.0/24                # If we don't set any IP list, then any IP 
access is allowed by default.
+      #- "::/64"
+    admin_listen:                 # use a separate port
+      ip: 0.0.0.0                 # Specific IP, if not set, the default value 
is `0.0.0.0`.
+      port: 9180                  # Specific port, which must be different 
from node_listen's port.
+
+    #https_admin: true            # enable HTTPS when use a separate port for 
Admin API.
+                                  # Admin API will use 
conf/apisix_admin_api.crt and conf/apisix_admin_api.key as certificate.
+
+    admin_api_mtls:               # Depends on `admin_listen` and 
`https_admin`.
+      admin_ssl_cert: ""          # Path of your self-signed server side cert.
+      admin_ssl_cert_key: ""      # Path of your self-signed server side key.
+      admin_ssl_ca_cert: ""       # Path of your self-signed ca cert.The CA is 
used to sign all admin api callers' certificates.
+
+    admin_api_version: v3         # The version of admin api, latest version 
is v3.
+
+  etcd:
+    host:                           # it's possible to define multiple etcd 
hosts addresses of the same etcd cluster.
+      - "http://127.0.0.1:2379";     # multiple etcd address, if your etcd 
cluster enables TLS, please use https scheme,
+                                    # e.g. https://127.0.0.1:2379.
+    prefix: /apisix                 # configuration prefix in etcd
+    use_grpc: false                 # enable the experimental configuration 
sync via gRPC
+    timeout: 30                     # 30 seconds. Use a much higher timeout 
(like an hour) if the `use_grpc` is true.
+    #resync_delay: 5                # when sync failed and a rest is needed, 
resync after the configured seconds plus 50% random jitter
+    #health_check_timeout: 10       # etcd retry the unhealthy nodes after the 
configured seconds
+    startup_retry: 2                # the number of retry to etcd during the 
startup, default to 2
+    #user: root                     # root username for etcd
+    #password: 5tHkHhYkjr6cQY       # root password for etcd
+    tls:
+      # To enable etcd client certificate you need to build APISIX-Base, see
+      # 
https://apisix.apache.org/docs/apisix/FAQ#how-do-i-build-the-apisix-base-environment
+      #cert: /path/to/cert          # path of certificate used by the etcd 
client
+      #key: /path/to/key            # path of key used by the etcd client
+
+      verify: true                  # whether to verify the etcd endpoint 
certificate when setup a TLS connection to etcd,
+                                    # the default value is true, e.g. the 
certificate will be verified strictly.
+      #sni:                         # the SNI for etcd TLS requests. If 
missed, the host part of the URL will be used.
diff --git a/docs/en/latest/config.json b/docs/en/latest/config.json
index 115448b95..14ecd1ab7 100644
--- a/docs/en/latest/config.json
+++ b/docs/en/latest/config.json
@@ -106,6 +106,7 @@
           "label": "Transformation",
           "items": [
             "plugins/response-rewrite",
+            "plugins/error-page",
             "plugins/proxy-rewrite",
             "plugins/grpc-transcode",
             "plugins/grpc-web",
diff --git a/docs/en/latest/plugins/error-page.md 
b/docs/en/latest/plugins/error-page.md
new file mode 100644
index 000000000..4a86d554f
--- /dev/null
+++ b/docs/en/latest/plugins/error-page.md
@@ -0,0 +1,160 @@
+---
+title: error-page
+keywords:
+  - Apache APISIX
+  - API Gateway
+  - Plugin
+  - Error page
+description: The error-page Plugin customizes the HTTP error response body and 
content type for APISIX-generated error responses, such as when no route 
matches or when APISIX itself encounters an error.
+---
+
+<!--
+#
+# 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.
+#
+-->
+
+## Description
+
+The `error-page` Plugin customizes the response body and content type for HTTP 
error responses generated by APISIX itself (for example, when no route matches 
or when the upstream is unreachable). Responses from upstream services are not 
affected.
+
+This Plugin uses [Plugin metadata](../terminology/plugin-metadata.md) for 
global configuration and requires no per-route attributes. When enabled, it 
intercepts error responses and replaces their body with the configured content.
+
+## Plugin Metadata
+
+There are no attributes to configure this Plugin on Routes, Services, or other 
resources. All configuration is done through Plugin metadata.
+
+| Name                        | Type    | Required | Default    | Description  
                                                                                
                    |
+| --------------------------- | ------- | -------- | ---------- | 
----------------------------------------------------------------------------------------------------------------
 |
+| enable                      | boolean | False    | false      | When set to 
`true`, the Plugin intercepts error responses and replaces them with the 
configured custom pages.    |
+| error_`{status_code}`       | object  | False    |            | Custom error 
page configuration for the given HTTP status code. For example, `error_404` for 
404 responses. Any HTTP status code in the range 100–599 is supported. |
+| error_`{status_code}`.body  | string  | False    |            | Response 
body to return for the given status code. If empty or not set, the default 
APISIX/nginx error page is used. |
+| error_`{status_code}`.content_type | string | False | text/html | Content 
type of the response body.                                                      
                        |
+
+## Enable Plugin
+
+The `error-page` Plugin is disabled by default. To enable the Plugin, add it 
to your configuration file (`conf/config.yaml`):
+
+```yaml title="conf/config.yaml"
+plugins:
+  - ...
+  - error-page
+```
+
+## Configure Plugin Metadata
+
+:::note
+You can fetch the `admin_key` from `config.yaml` and save to an environment 
variable with the following command:
+
+```bash
+admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 
's/"//g')
+```
+
+:::
+
+Configure the Plugin metadata to enable it and define custom error pages for 
one or more HTTP status codes:
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/plugin_metadata/error-page \
+-H "X-API-KEY: $admin_key" -X PUT -d '
+{
+    "enable": true,
+    "error_404": {
+        "body": "<html><body><h1>404 - Page Not Found</h1></body></html>",
+        "content_type": "text/html"
+    },
+    "error_500": {
+        "body": "<html><body><h1>500 - Internal Server 
Error</h1></body></html>",
+        "content_type": "text/html"
+    },
+    "error_502": {
+        "body": "<html><body><h1>502 - Bad Gateway</h1></body></html>",
+        "content_type": "text/html"
+    },
+    "error_503": {
+        "body": "<html><body><h1>503 - Service Unavailable</h1></body></html>",
+        "content_type": "text/html"
+    }
+}'
+```
+
+You can also return JSON error responses by setting a custom `content_type`:
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/plugin_metadata/error-page \
+-H "X-API-KEY: $admin_key" -X PUT -d '
+{
+    "enable": true,
+    "error_404": {
+        "body": "{\"code\": 404, \"message\": \"Resource not found\"}",
+        "content_type": "application/json"
+    },
+    "error_500": {
+        "body": "{\"code\": 500, \"message\": \"Internal server error\"}",
+        "content_type": "application/json"
+    }
+}'
+```
+
+Since the Plugin uses global metadata, you also need to enable it on the 
routes where you want it to take effect. You can use a [global 
rule](../terminology/global-rule.md) to apply it to all routes:
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/global_rules/1 \
+-H "X-API-KEY: $admin_key" -X PUT -d '
+{
+    "plugins": {
+        "error-page": {}
+    }
+}'
+```
+
+## Example usage
+
+After configuring the Plugin and metadata as shown above, trigger a 404 error 
by accessing a non-existent route:
+
+```shell
+curl -i http://127.0.0.1:9080/non-existent-path
+```
+
+```
+HTTP/1.1 404 Not Found
+Content-Type: text/html
+...
+
+<html><body><h1>404 - Page Not Found</h1></body></html>
+```
+
+## Disable Plugin
+
+To disable the Plugin globally, set `enable` to `false` in the Plugin metadata:
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/plugin_metadata/error-page \
+-H "X-API-KEY: $admin_key" -X PUT -d '
+{
+    "enable": false
+}'
+```
+
+To remove the Plugin entirely from a route, delete it from the route's plugin 
configuration. APISIX will automatically reload and you do not have to restart 
for this to take effect.
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/global_rules/1 \
+-H "X-API-KEY: $admin_key" -X PUT -d '
+{
+    "plugins": {}
+}'
+```
diff --git a/docs/zh/latest/plugins/error-page.md 
b/docs/zh/latest/plugins/error-page.md
new file mode 100644
index 000000000..1b156322c
--- /dev/null
+++ b/docs/zh/latest/plugins/error-page.md
@@ -0,0 +1,162 @@
+---
+title: error-page
+keywords:
+  - Apache APISIX
+  - API 网关
+  - Plugin
+  - Error page
+  - error-page
+description: error-page 插件允许自定义 APISIX 生成的 HTTP 
错误响应的响应体和内容类型,例如路由不匹配或上游不可达时返回的错误页面。
+---
+
+<!--
+#
+# 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.
+#
+-->
+
+## 描述
+
+`error-page` 插件允许自定义 APISIX 本身生成的 HTTP 
错误响应(例如,路由不匹配或上游不可达时)的响应体和内容类型。来自上游服务的响应不会受到影响。
+
+该插件通过[插件元数据](../terminology/plugin-metadata.md)进行全局配置,无需在路由上配置属性。启用后,它会拦截错误响应并将响应体替换为配置的自定义内容。
+
+## 插件元数据
+
+该插件不支持在路由、服务或其他资源上配置属性,所有配置均通过插件元数据完成。
+
+| 名称                               | 类型    | 必选项 | 默认值     | 描述                
                                                                                
           |
+| ---------------------------------- | ------- | ------ | ---------- | 
--------------------------------------------------------------------------------------------------------------
 |
+| enable                             | boolean | 否     | false      | 设置为 
`true` 时,插件拦截错误响应并替换为自定义页面。                                                     
    |
+| error_`{status_code}`              | object  | 否     |            | 指定 HTTP 
状态码的自定义错误页面配置,例如 `error_404` 对应 404 响应。支持 100–599 范围内的任意 HTTP 状态码。  |
+| error_`{status_code}`.body         | string  | 否     |            | 
指定状态码的响应体内容。若为空或未设置,则使用 APISIX/nginx 的默认错误页面。                                   
|
+| error_`{status_code}`.content_type | string  | 否     | text/html  | 
响应体的内容类型。                                                                       
                       |
+
+## 启用插件
+
+`error-page` 插件默认禁用。要启用该插件,请将其添加到配置文件(`conf/config.yaml`)中:
+
+```yaml title="conf/config.yaml"
+plugins:
+  - ...
+  - error-page
+```
+
+## 配置插件元数据
+
+:::note
+
+你可以通过以下命令从 `config.yaml` 中获取 `admin_key` 并保存为环境变量:
+
+```bash
+admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 
's/"//g')
+```
+
+:::
+
+配置插件元数据以启用插件并为一个或多个 HTTP 状态码定义自定义错误页面:
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/plugin_metadata/error-page \
+-H "X-API-KEY: $admin_key" -X PUT -d '
+{
+    "enable": true,
+    "error_404": {
+        "body": "<html><body><h1>404 - 页面未找到</h1></body></html>",
+        "content_type": "text/html"
+    },
+    "error_500": {
+        "body": "<html><body><h1>500 - 服务器内部错误</h1></body></html>",
+        "content_type": "text/html"
+    },
+    "error_502": {
+        "body": "<html><body><h1>502 - 网关错误</h1></body></html>",
+        "content_type": "text/html"
+    },
+    "error_503": {
+        "body": "<html><body><h1>503 - 服务不可用</h1></body></html>",
+        "content_type": "text/html"
+    }
+}'
+```
+
+你也可以通过设置自定义 `content_type` 来返回 JSON 格式的错误响应:
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/plugin_metadata/error-page \
+-H "X-API-KEY: $admin_key" -X PUT -d '
+{
+    "enable": true,
+    "error_404": {
+        "body": "{\"code\": 404, \"message\": \"资源未找到\"}",
+        "content_type": "application/json"
+    },
+    "error_500": {
+        "body": "{\"code\": 500, \"message\": \"服务器内部错误\"}",
+        "content_type": "application/json"
+    }
+}'
+```
+
+由于该插件使用全局元数据,你还需要在路由上启用该插件。可以使用[全局规则](../terminology/global-rule.md)将其应用于所有路由:
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/global_rules/1 \
+-H "X-API-KEY: $admin_key" -X PUT -d '
+{
+    "plugins": {
+        "error-page": {}
+    }
+}'
+```
+
+## 示例
+
+按照上述方式配置插件和元数据后,访问一个不存在的路由触发 404 错误:
+
+```shell
+curl -i http://127.0.0.1:9080/non-existent-path
+```
+
+```
+HTTP/1.1 404 Not Found
+Content-Type: text/html
+...
+
+<html><body><h1>404 - 页面未找到</h1></body></html>
+```
+
+## 禁用插件
+
+若要全局禁用该插件,在插件元数据中将 `enable` 设置为 `false`:
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/plugin_metadata/error-page \
+-H "X-API-KEY: $admin_key" -X PUT -d '
+{
+    "enable": false
+}'
+```
+
+若要从路由中完全移除该插件,删除路由插件配置中对应的 JSON 配置。APISIX 将自动重新加载,无需重启。
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/global_rules/1 \
+-H "X-API-KEY: $admin_key" -X PUT -d '
+{
+    "plugins": {}
+}'
+```
diff --git a/t/admin/plugins.t b/t/admin/plugins.t
index eea7505ca..315c27125 100644
--- a/t/admin/plugins.t
+++ b/t/admin/plugins.t
@@ -127,6 +127,7 @@ http-dubbo
 public-api
 prometheus
 datadog
+error-page
 lago
 loki-logger
 elasticsearch-logger
diff --git a/t/plugin/error-page.t b/t/plugin/error-page.t
new file mode 100644
index 000000000..8728522b2
--- /dev/null
+++ b/t/plugin/error-page.t
@@ -0,0 +1,340 @@
+#
+# 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.
+#
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+no_long_string();
+no_root_location();
+no_shuffle();
+log_level('info');
+
+add_block_preprocessor(sub {
+    my ($block) = @_;
+
+    my $user_yaml_config = <<_EOC_;
+plugins:
+  - error-page
+  - serverless-post-function
+_EOC_
+    $block->set_value("extra_yaml_config", $user_yaml_config);
+
+    if (!defined $block->request) {
+        $block->set_value("request", "GET /t");
+    }
+
+    $block;
+});
+
+run_tests;
+
+__DATA__
+
+=== TEST 1: set global rule to enable plugin
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/global_rules/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "plugins": {
+                        "error-page": {}
+                    }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 2: set route with serverless-post-function plugin to inject error 
status
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                    "plugins": {
+                        "serverless-post-function": {
+                            "functions" : ["return function() if 
ngx.var.http_x_test_status ~= nil 
then;ngx.exit(tonumber(ngx.var.http_x_test_status));end;end"]
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/*"
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 3: without plugin metadata, error response should not be modified
+--- request
+GET /hello
+--- more_headers
+X-Test-Status: 502
+--- error_code: 502
+--- response_headers
+content-type: text/html
+--- response_body_like
+.*openresty.*
+--- error_log
+failed to read metadata for error-page
+
+
+
+=== TEST 4: set plugin metadata with custom error pages
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/plugin_metadata/error-page',
+                ngx.HTTP_PUT,
+                [[{
+                    "enable": true,
+                    "error_500": {"body": "<html><body><h1>500 Internal Server 
Error</h1></body></html>"},
+                    "error_404": {"body": "<html><body><h1>404 Not 
Found</h1></body></html>"},
+                    "error_502": {"body": "<html><body><h1>502 Bad 
Gateway</h1></body></html>"},
+                    "error_503": {"body": "<html><body><h1>503 Service 
Unavailable</h1></body></html>"}
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 5: custom error page for 500
+--- request
+GET /hello
+--- more_headers
+X-Test-Status: 500
+--- error_code: 500
+--- response_headers
+content-type: text/html
+--- response_body_like
+(?=.*500 Internal Server Error)
+
+
+
+=== TEST 6: custom error page for 502
+--- request
+GET /hello
+--- more_headers
+X-Test-Status: 502
+--- error_code: 502
+--- response_headers
+content-type: text/html
+--- response_body_like
+(?=.*502 Bad Gateway)
+
+
+
+=== TEST 7: custom error page for 503
+--- request
+GET /hello
+--- more_headers
+X-Test-Status: 503
+--- error_code: 503
+--- response_headers
+content-type: text/html
+--- response_body_like
+(?=.*503 Service Unavailable)
+
+
+
+=== TEST 8: custom error page for 404
+--- request
+GET /hello
+--- more_headers
+X-Test-Status: 404
+--- error_code: 404
+--- response_headers
+content-type: text/html
+--- response_body_like
+(?=.*404 Not Found)
+
+
+
+=== TEST 9: error page not configured for status 405
+--- request
+GET /hello
+--- more_headers
+X-Test-Status: 405
+--- error_code: 405
+--- error_log
+error page for error_405 not defined
+
+
+
+=== TEST 10: set metadata with empty body for a status code
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/plugin_metadata/error-page',
+                ngx.HTTP_PUT,
+                [[{
+                    "enable": true,
+                    "error_405": {"content_type": "text/html"}
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 11: error page body not set falls back to default
+--- request
+GET /hello
+--- more_headers
+X-Test-Status: 405
+--- error_code: 405
+--- error_log
+error page for error_405 not defined
+
+
+
+=== TEST 12: set metadata with plugin disabled
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/plugin_metadata/error-page',
+                ngx.HTTP_PUT,
+                [[{
+                    "enable": false,
+                    "error_500": {"body": "<html><body><h1>500 Internal Server 
Error</h1></body></html>"},
+                    "error_404": {"body": "<html><body><h1>404 Not 
Found</h1></body></html>"}
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 13: plugin disabled, error response not modified
+--- request
+GET /hello
+--- more_headers
+X-Test-Status: 500
+--- error_code: 500
+--- response_headers
+content-type: text/html
+--- response_body_like
+.*openresty.*
+
+
+
+=== TEST 14: set metadata with custom content-type
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/plugin_metadata/error-page',
+                ngx.HTTP_PUT,
+                [[{
+                    "enable": true,
+                    "error_500": {
+                        "body": "{\"code\": 500, \"message\": \"Internal 
Server Error\"}",
+                        "content_type": "application/json"
+                    }
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed
+
+
+
+=== TEST 15: custom content-type is returned in response
+--- request
+GET /hello
+--- more_headers
+X-Test-Status: 500
+--- error_code: 500
+--- response_headers
+content-type: application/json
+
+
+
+=== TEST 16: upstream errors are not intercepted
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/plugin_metadata/error-page',
+                ngx.HTTP_PUT,
+                [[{
+                    "enable": true,
+                    "error_500": {"body": "<html><body><h1>500 
custom</h1></body></html>"}
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- response_body
+passed

Reply via email to