This is an automated email from the ASF dual-hosted git repository.
nic-6443 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 aa4472cab feat(ai-proxy-multi): let configured HTTP statuses trigger a
fallback (#13852)
aa4472cab is described below
commit aa4472cab41b98636dde4aa4bb75418bcc1470d4
Author: Nic <[email protected]>
AuthorDate: Thu Aug 20 11:13:16 2026 +0800
feat(ai-proxy-multi): let configured HTTP statuses trigger a fallback
(#13852)
---
apisix/plugins/ai-proxy-multi.lua | 3 +-
apisix/plugins/ai-proxy/base.lua | 24 +++-
apisix/plugins/ai-proxy/schema.lua | 21 ++++
docs/en/latest/plugins/ai-proxy-multi.md | 9 +-
docs/zh/latest/plugins/ai-proxy-multi.md | 5 +-
t/plugin/ai-proxy-multi-retry.t | 193 +++++++++++++++++++++++++++++++
6 files changed, 247 insertions(+), 8 deletions(-)
diff --git a/apisix/plugins/ai-proxy-multi.lua
b/apisix/plugins/ai-proxy-multi.lua
index ca92b2ab8..6ce28e793 100644
--- a/apisix/plugins/ai-proxy-multi.lua
+++ b/apisix/plugins/ai-proxy-multi.lua
@@ -934,7 +934,8 @@ local function retry_on_error(ctx, conf, code, body)
ctx.server_picker.after_balance(ctx, true)
if (code == 429 and fallback_strategy_has(conf.fallback_strategy,
"http_429")) or
(code >= 500 and code < 600 and
- fallback_strategy_has(conf.fallback_strategy, "http_5xx")) then
+ fallback_strategy_has(conf.fallback_strategy, "http_5xx")) or
+ base.is_fallback_http_status(conf, code) then
-- Slow-failure guard: only retry when the failed attempt finished
within
-- retry_on_failure_within_ms. A slow failure (e.g. a 5xx returned
after
-- minutes) is given back to the client directly, so fallback never
doubles
diff --git a/apisix/plugins/ai-proxy/base.lua b/apisix/plugins/ai-proxy/base.lua
index 5c9e9a5b3..db94afcda 100644
--- a/apisix/plugins/ai-proxy/base.lua
+++ b/apisix/plugins/ai-proxy/base.lua
@@ -21,6 +21,7 @@ local core = require("apisix.core")
local require = require
local pcall = pcall
local pairs = pairs
+local ipairs = ipairs
local type = type
local table = table
local math_floor = math.floor
@@ -48,6 +49,26 @@ local function count_request_tools(body)
end
+-- Statuses the user opted into retrying through fallback_http_statuses.
+-- Both the error path below and ai-proxy-multi's retry decision consult this,
+-- and they have to agree: a status that is not diverted to the error path has
+-- its response streamed to the client and never reaches the retry callback.
+function _M.is_fallback_http_status(conf, status)
+ local statuses = conf.fallback_http_statuses
+ if not statuses then
+ return false
+ end
+
+ for _, s in ipairs(statuses) do
+ if s == status then
+ return true
+ end
+ end
+
+ return false
+end
+
+
local function resolve_cap(cap_entry, key, conf, ctx)
local val = cap_entry and cap_entry[key]
if type(val) == "function" then
@@ -387,7 +408,8 @@ function _M.before_proxy(conf, ctx, on_error)
-- Upstream responded — mark source before any early returns
core.response.set_response_source(ctx, "upstream")
- if res.status == 429 or (res.status >= 500 and res.status < 600)
then
+ if res.status == 429 or (res.status >= 500 and res.status < 600)
+ or _M.is_fallback_http_status(conf, res.status) then
-- Read the upstream error body before closing so the
provider's
-- error details survive: logged on fallback (see
retry_on_error)
-- and returned to the client when no retry happens.
diff --git a/apisix/plugins/ai-proxy/schema.lua
b/apisix/plugins/ai-proxy/schema.lua
index ada2ab425..78787a77e 100644
--- a/apisix/plugins/ai-proxy/schema.lua
+++ b/apisix/plugins/ai-proxy/schema.lua
@@ -453,6 +453,27 @@ _M.ai_proxy_multi_schema = {
}
}
},
+ fallback_http_statuses = {
+ type = "array",
+ minItems = 1,
+ uniqueItems = true,
+ items = {
+ type = "integer",
+ minimum = 400,
+ maximum = 599,
+ },
+ description = "Additional upstream HTTP status codes that make the
"
+ .. "request fall back to another instance, on top of the "
+ .. "http_429 / http_5xx entries of fallback_strategy. Use it "
+ .. "for statuses that mean the instance's credential is "
+ .. "unusable rather than the request being wrong, e.g. [401, "
+ .. "402] when an API key is expired or out of quota. It is "
+ .. "opt-in per status because most 4xx responses are caused by
"
+ .. "the request itself and retrying them on another instance "
+ .. "would only burn quota. max_retries and "
+ .. "retry_on_failure_within_ms bound these retries the same "
+ .. "way they bound the fallback_strategy ones.",
+ },
max_retries = {
type = "integer",
minimum = 0,
diff --git a/docs/en/latest/plugins/ai-proxy-multi.md
b/docs/en/latest/plugins/ai-proxy-multi.md
index 4c5d77482..e04b327b4 100644
--- a/docs/en/latest/plugins/ai-proxy-multi.md
+++ b/docs/en/latest/plugins/ai-proxy-multi.md
@@ -68,8 +68,9 @@ When an instance's `provider` is set to `bedrock`, the Plugin
expects requests i
| Name | Type | Required | Default
| Valid values | Description |
|------------------------------------|----------------|----------|-----------------------------------|--------------|-------------|
| fallback_strategy | string or array | False | |
string: "instance_health_and_rate_limiting", "http_429", "http_5xx"<br />array:
["rate_limiting", "http_429", "http_5xx"] | Fallback strategy. When set, the
Plugin will check whether the specified instance's token has been exhausted
when a request is forwarded. If so, forward the request to the next instance
regardless of the instance priority. When not set, the Plugin will not forward
the request to low prior [...]
-| max_retries | integer | False |
| greater or equal to 0 | Maximum number of fallback
retries after the initial request fails. Bounds how many additional instances a
single request tries, so it does not exhaust every configured instance. Only
takes effect together with `fallback_strategy`. When unset, the Plugin retries
until an instance succeeds or all are tried. |
-| retry_on_failure_within_ms | integer | False |
| greater or equal to 1 | Only fall back to another
instance when the upstream fails within this many milliseconds. Fast failures
(such as connection errors or quick `429`/`5xx`) are retried, while a slow
failure that takes longer than this is returned to the client directly to avoid
doubling the wait time. Only takes effect together with `fallback_strategy`.
When unset, the Plugin retrie [...]
+| fallback_http_statuses | array[integer] | False |
| between 400 and 599 | Additional upstream HTTP status
codes that make the request fall back to another instance, on top of the
`http_429` and `http_5xx` entries of `fallback_strategy`. Use it for statuses
that mean the instance's credential is unusable rather than the request being
wrong, such as `[401, 402]` when an API key is expired or out of quota. It is
opt-in per status because mo [...]
+| max_retries | integer | False |
| greater or equal to 0 | Maximum number of fallback
retries after the initial request fails. Bounds how many additional instances a
single request tries, so it does not exhaust every configured instance. Only
takes effect together with `fallback_strategy` or `fallback_http_statuses`.
When unset, the Plugin retries until an instance succeeds or all are tried. |
+| retry_on_failure_within_ms | integer | False |
| greater or equal to 1 | Only fall back to another
instance when the upstream fails within this many milliseconds. Fast failures
(such as connection errors or quick `429`/`5xx`) are retried, while a slow
failure that takes longer than this is returned to the client directly to avoid
doubling the wait time. Only takes effect together with `fallback_strategy` or
`fallback_http_statuses`. W [...]
| balancer | object | False |
| | Load balancing configurations. |
| balancer.algorithm | string | False | roundrobin
| [roundrobin, chash, semantic] | Load balancing algorithm.
When set to `roundrobin`, weighted round robin algorithm is used. When set to
`chash`, consistent hashing algorithm is used. When set to `semantic`, the
Plugin picks an instance by the semantic similarity between the request prompt
and each instance's `examples`, and its options are configured under
`semantic_opts`. Note that `seman [...]
| balancer.hash_on | string | False |
| [vars, headers, cookie, consumer, vars_combinations] |
Used when `type` is `chash`. Support hashing on [NGINX
variables](https://nginx.org/en/docs/varindex.html), headers, cookie, consumer,
or a combination of [NGINX variables](https://nginx.org/en/docs/varindex.html).
|
@@ -173,8 +174,8 @@ The setting covers `ai-proxy`, `ai-proxy-multi`, and
`ai-request-rewrite`, which
When the selected LLM upstream returns a `429` or `5xx` status,
`ai-proxy-multi` reads the upstream error body before deciding whether to fall
back:
-- If the request is retried on another instance (per `fallback_strategy`,
`max_retries`, and `retry_on_failure_within_ms`), the failed instance's error
body is recorded in the error log for diagnostics, since a later attempt's
response is sent to the client instead.
-- If the request is not retried (no matching `fallback_strategy`, retries
exhausted, or the failure took longer than `retry_on_failure_within_ms`), the
upstream status code and error body are returned to the client, preserving the
upstream `Content-Type`.
+- If the request is retried on another instance (per `fallback_strategy`,
`fallback_http_statuses`, `max_retries`, and `retry_on_failure_within_ms`), the
failed instance's error body is recorded in the error log for diagnostics,
since a later attempt's response is sent to the client instead.
+- If the request is not retried (no matching `fallback_strategy` or
`fallback_http_statuses`, retries exhausted, or the failure took longer than
`retry_on_failure_within_ms`), the upstream status code and error body are
returned to the client, preserving the upstream `Content-Type`.
## Examples
diff --git a/docs/zh/latest/plugins/ai-proxy-multi.md
b/docs/zh/latest/plugins/ai-proxy-multi.md
index d056497bb..e5141d016 100644
--- a/docs/zh/latest/plugins/ai-proxy-multi.md
+++ b/docs/zh/latest/plugins/ai-proxy-multi.md
@@ -68,8 +68,9 @@ import TabItem from '@theme/TabItem';
| 名称 | 类型 | 必选项 | 默认值
| 有效值 | 描述 |
|------------------------------------|----------------|----------|-----------------------------------|--------------|-------------|
| fallback_strategy | string 或 array | 否 | |
string: "instance_health_and_rate_limiting", "http_429", "http_5xx"<br />array:
["rate_limiting", "http_429", "http_5xx"] |
故障转移策略。设置后,插件将在转发请求时检查指定实例的令牌是否已耗尽。如果是,则无论实例优先级如何,都将请求转发到下一个实例。未设置时,当高优先级实例的令牌耗尽时,插件不会将请求转发到低优先级实例。
|
-| max_retries | integer | 否 |
| 大于或等于 0 |
初始请求失败后允许的最大故障转移重试次数。用于限制单个请求最多尝试多少个额外实例,避免穷举所有已配置的实例。仅在配置 `fallback_strategy`
时生效。未设置时,插件会持续重试直到某个实例成功或所有实例都已尝试。 |
-| retry_on_failure_within_ms | integer | 否 |
| 大于或等于 1 | 仅当上游在指定毫秒数内失败时才故障转移到其他实例。快速失败(如连接错误、快速返回的
`429`/`5xx`)会触发重试,而耗时超过该值的慢失败会直接将错误返回给客户端,避免客户端等待时间翻倍。仅在配置 `fallback_strategy`
时生效。未设置时,插件无论失败请求耗时多久都会重试。 |
+| fallback_http_statuses | array[integer] | 否 |
| 400 到 599 之间 | 除 `fallback_strategy` 中的 `http_429` 和
`http_5xx` 之外,额外触发故障转移到其他实例的上游 HTTP 状态码。适用于表示该实例凭证不可用(而非请求本身有问题)的状态码,例如 API Key
过期或额度耗尽时的 `[401, 402]`。该能力按状态码显式启用,因为大多数 `4xx`
响应由请求本身导致,在其他实例上重试只会白白消耗额度。`max_retries` 和 `retry_on_failure_within_ms`
对这些重试的约束方式与对 `fallback_strategy` 的重试一致。 |
+| max_retries | integer | 否 |
| 大于或等于 0 |
初始请求失败后允许的最大故障转移重试次数。用于限制单个请求最多尝试多少个额外实例,避免穷举所有已配置的实例。仅在配置 `fallback_strategy`
或 `fallback_http_statuses` 时生效。未设置时,插件会持续重试直到某个实例成功或所有实例都已尝试。 |
+| retry_on_failure_within_ms | integer | 否 |
| 大于或等于 1 | 仅当上游在指定毫秒数内失败时才故障转移到其他实例。快速失败(如连接错误、快速返回的
`429`/`5xx`)会触发重试,而耗时超过该值的慢失败会直接将错误返回给客户端,避免客户端等待时间翻倍。仅在配置 `fallback_strategy`
或 `fallback_http_statuses` 时生效。未设置时,插件无论失败请求耗时多久都会重试。 |
| balancer | object | 否 |
| | 负载均衡配置。 |
| balancer.algorithm | string | 否 | roundrobin
| [roundrobin, chash, semantic] | 负载均衡算法。设置为 `roundrobin`
时,使用加权轮询算法。设置为 `chash` 时,使用一致性哈希算法。设置为 `semantic` 时,插件根据请求提示词与各实例 `examples`
之间的语义相似度选择实例,其相关选项配置在 `semantic_opts` 下。注意:`semantic` 不参与健康检查,也不参与
`fallback_strategy` / 重试——被选中实例的上游失败会直接返回给客户端;只有当没有实例达到其阈值或嵌入请求失败时,才会回退(回退到
`semantic_opts.fallback` 实例,若未配置则回退到第一个实例)。 |
| balancer.hash_on | string | 否 |
| [vars, headers, cookie, consumer, vars_combinations] | 当
`type` 为 `chash` 时使用。支持基于 [NGINX
变量](https://nginx.org/en/docs/varindex.html)、标头、cookie、消费者或 [NGINX
变量](https://nginx.org/en/docs/varindex.html)组合进行哈希。 |
diff --git a/t/plugin/ai-proxy-multi-retry.t b/t/plugin/ai-proxy-multi-retry.t
index a5f8d7cb2..845337e29 100644
--- a/t/plugin/ai-proxy-multi-retry.t
+++ b/t/plugin/ai-proxy-multi-retry.t
@@ -64,6 +64,32 @@ _EOC_
}
}
}
+ # Instances whose API key is rejected or out of quota. Neither status
+ # takes the 429/5xx error path, so they exercise
fallback_http_statuses.
+ server {
+ server_name unauthorized_instance;
+ default_type 'application/json';
+ listen 6735;
+ location / {
+ content_by_lua_block {
+ ngx.status = 401
+ ngx.say([[{ "error": {"message":"invalid api key"}}]])
+ return
+ }
+ }
+ }
+ server {
+ server_name payment_required_instance;
+ default_type 'application/json';
+ listen 6736;
+ location / {
+ content_by_lua_block {
+ ngx.status = 402
+ ngx.say([[{ "error": {"message":"insufficient balance"}}]])
+ return
+ }
+ }
+ }
server {
server_name success_instance;
default_type 'application/json';
@@ -307,3 +333,170 @@ model=upstream-model-B
no temperature leak
--- error_log
FALLBACKVARS request_llm_model=client-model llm_model=upstream-model-B
+
+
+
+=== TEST 9: fallback_http_statuses makes 401 and 402 fall back to another
instance
+--- 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,
+ [[{
+ "uri": "/anything",
+ "plugins": {
+ "ai-proxy-multi": {
+ "fallback_http_statuses": [401, 402],
+ "instances": [
+
{"name":"expired-key","provider":"openai-compatible","weight":1,"priority":20,"auth":{"header":{"Authorization":"Bearer
token"}},"options":{"model":"gpt-4"},"override":{"endpoint":"http://127.0.0.1:6735"}},
+
{"name":"drained-key","provider":"openai-compatible","weight":1,"priority":10,"auth":{"header":{"Authorization":"Bearer
token"}},"options":{"model":"gpt-4"},"override":{"endpoint":"http://127.0.0.1:6736"}},
+
{"name":"good-key","provider":"openai-compatible","weight":1,"priority":0,"auth":{"header":{"Authorization":"Bearer
token"}},"options":{"model":"gpt-4"},"override":{"endpoint":"http://127.0.0.1:6733"}}
+ ],
+ "ssl_verify": false
+ }
+ }
+ }]]
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 10: the request is served by the instance with a working key
+--- request
+POST /anything
+{ "messages": [ { "role": "user", "content": "What is 1+1?"} ] }
+--- response_body chomp
+success
+--- error_code: 200
+--- error_log
+returned status 401, falling back to
+returned status 402, falling back to
+
+
+
+=== TEST 11: max_retries also bounds the fallback_http_statuses retries
+--- 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,
+ [[{
+ "uri": "/anything",
+ "plugins": {
+ "ai-proxy-multi": {
+ "fallback_http_statuses": [401, 402],
+ "max_retries": 0,
+ "instances": [
+
{"name":"expired-key","provider":"openai-compatible","weight":1,"priority":20,"auth":{"header":{"Authorization":"Bearer
token"}},"options":{"model":"gpt-4"},"override":{"endpoint":"http://127.0.0.1:6735"}},
+
{"name":"good-key","provider":"openai-compatible","weight":1,"priority":0,"auth":{"header":{"Authorization":"Bearer
token"}},"options":{"model":"gpt-4"},"override":{"endpoint":"http://127.0.0.1:6733"}}
+ ],
+ "ssl_verify": false
+ }
+ }
+ }]]
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 12: the upstream status and error body reach the client once retries
are capped
+--- request
+POST /anything
+{ "messages": [ { "role": "user", "content": "What is 1+1?"} ] }
+--- error_code: 401
+--- response_body eval
+qr/invalid api key/
+--- error_log
+reached max_retries 0
+
+
+
+=== TEST 13: without fallback_http_statuses a 401 is still returned as is
+--- 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,
+ [[{
+ "uri": "/anything",
+ "plugins": {
+ "ai-proxy-multi": {
+ "fallback_strategy": ["http_429", "http_5xx"],
+ "instances": [
+
{"name":"expired-key","provider":"openai-compatible","weight":1,"priority":20,"auth":{"header":{"Authorization":"Bearer
token"}},"options":{"model":"gpt-4"},"override":{"endpoint":"http://127.0.0.1:6735"}},
+
{"name":"good-key","provider":"openai-compatible","weight":1,"priority":0,"auth":{"header":{"Authorization":"Bearer
token"}},"options":{"model":"gpt-4"},"override":{"endpoint":"http://127.0.0.1:6733"}}
+ ],
+ "ssl_verify": false
+ }
+ }
+ }]]
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 14: the 401 is not retried and reaches the client
+--- request
+POST /anything
+{ "messages": [ { "role": "user", "content": "What is 1+1?"} ] }
+--- error_code: 401
+--- response_body eval
+qr/invalid api key/
+--- no_error_log
+falling back to
+
+
+
+=== TEST 15: fallback_http_statuses only accepts error statuses
+--- 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,
+ [[{
+ "uri": "/anything",
+ "plugins": {
+ "ai-proxy-multi": {
+ "fallback_http_statuses": [200],
+ "instances": [
+
{"name":"good-key","provider":"openai-compatible","weight":1,"auth":{"header":{"Authorization":"Bearer
token"}},"options":{"model":"gpt-4"},"override":{"endpoint":"http://127.0.0.1:6733"}}
+ ],
+ "ssl_verify": false
+ }
+ }
+ }]]
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- error_code: 400
+--- response_body eval
+qr/expected 200 to be at least 400/