Baoyuantop commented on code in PR #12765:
URL: https://github.com/apache/apisix/pull/12765#discussion_r2752384965
##########
apisix/plugins/api-breaker.lua:
##########
@@ -195,8 +386,121 @@ function _M.access(conf, ctx)
return
end
+-- Ratio-based circuit breaker
+local function ratio_based_access(conf, ctx)
+ -- Check and reset sliding window first to ensure consistent state
+ check_and_reset_window(ctx, conf)
-function _M.log(conf, ctx)
+ local current_state = get_circuit_breaker_state(ctx)
+ local current_time = ngx.time()
+
+ -- Handle OPEN state
+ if current_state == OPEN then
+ local last_change_key = gen_last_state_change_key(ctx)
+ local last_change_time, err = shared_buffer:get(last_change_key)
+ if err then
+ core.log.warn("failed to get last change time: ", err)
+ return conf.break_response_code or 503,
+ conf.break_response_body or "Service temporarily
unavailable"
+ end
+
+ local wait_duration = conf.max_breaker_sec or 60
+ if last_change_time and (current_time - last_change_time) >=
wait_duration then
+ -- Transition to HALF_OPEN
+ set_circuit_breaker_state(ctx, HALF_OPEN)
+ -- Reset half-open counters
+ shared_buffer:set(gen_half_open_calls_key(ctx), 0)
+ shared_buffer:set(gen_half_open_success_key(ctx), 0)
+ core.log.info("Circuit breaker transitioned from OPEN to
HALF_OPEN")
+ return -- Allow this request to pass
+ else
+ -- Still in OPEN state, reject request
+ if conf.break_response_headers then
+ for _, value in ipairs(conf.break_response_headers) do
+ local val = core.utils.resolve_var(value.value, ctx.var)
+ core.response.add_header(value.key, val)
+ end
+ end
+ return conf.break_response_code or 503,
+ conf.break_response_body or "Service temporarily
unavailable"
+ end
+ end
+
+ -- Handle HALF_OPEN state
+ if current_state == HALF_OPEN then
+ local half_open_calls_key = gen_half_open_calls_key(ctx)
+ local half_open_calls, err = shared_buffer:incr(half_open_calls_key,
1, 0)
+ if err then
+ core.log.warn("failed to increment half-open calls: ", err)
+ end
+
+ local permitted_calls = conf.unhealthy.half_open_max_calls or 3
+ if half_open_calls > permitted_calls then
+ -- Too many calls in half-open state, reject
+ return conf.break_response_code or 503,
+ conf.break_response_body or "Service temporarily
unavailable"
+ end
+
+ -- Allow request to pass for evaluation
+ return
+ end
+
+ -- CLOSED state - check if we should transition to OPEN
+ local total_requests_key = gen_total_requests_key(ctx)
+ local unhealthy_key = gen_unhealthy_key(ctx)
+
+ local total_requests, err = shared_buffer:get(total_requests_key)
+ if err then
+ core.log.warn("failed to get total requests: ", err)
+ return
+ end
+
+ local unhealthy_count, err = shared_buffer:get(unhealthy_key)
+ if err then
+ core.log.warn("failed to get unhealthy count: ", err)
+ return
+ end
+
+ if total_requests and unhealthy_count and total_requests > 0 then
+ local minimum_calls = conf.unhealthy.min_request_threshold or 10
+ local failure_threshold = conf.unhealthy.error_ratio or 0.5
+
+ if total_requests >= minimum_calls then
+ local failure_rate = unhealthy_count / total_requests
+ -- Use precise comparison to avoid floating point issues
+ local rounded_failure_rate = math.floor(failure_rate * 10000 +
0.5) / 10000
Review Comment:
Why choose 4 decimal places? This should be explained in the comments.
##########
docs/zh/latest/plugins/api-breaker.md:
##########
@@ -30,34 +29,70 @@ description: 本文介绍了 Apache APISIX api-breaker 插件的相关操作,
`api-breaker` 插件实现了 API 熔断功能,从而帮助我们保护上游业务服务。
+该插件支持两种熔断策略:
+
+- **按错误次数熔断(`unhealthy-count`)**:当连续失败次数达到阈值时触发熔断
+- **按错误比例熔断(`unhealthy-ratio`)**:当在滑动时间窗口内的错误率达到阈值时触发熔断
+
:::note 注意
-关于熔断超时逻辑,由代码逻辑自动按**触发不健康状态**的次数递增运算:
+**按错误次数熔断(`unhealthy-count`)**:
当上游服务返回 `unhealthy.http_statuses` 配置中的状态码(默认为 `500`),并达到 `unhealthy.failures`
预设次数时(默认为 3 次),则认为上游服务处于不健康状态。
第一次触发不健康状态时,熔断 2 秒。超过熔断时间后,将重新开始转发请求到上游服务,如果继续返回 `unhealthy.http_statuses`
状态码,记数再次达到 `unhealthy.failures` 预设次数时,熔断 4 秒。依次类推(2,4,8,16,……),直到达到预设的
`max_breaker_sec`值。
当上游服务处于不健康状态时,如果转发请求到上游服务并返回 `healthy.http_statuses` 配置中的状态码(默认为 `200`),并达到
`healthy.successes` 次时,则认为上游服务恢复至健康状态。
+**按错误比例熔断(`unhealthy-ratio`)**:
+
+该策略基于滑动时间窗口统计错误率。当在 `sliding_window_size` 时间窗口内,请求总数达到 `min_request_threshold`
且错误率超过 `error_ratio` 时,熔断器进入开启状态,持续 `max_breaker_sec` 秒。
+
+熔断器有三种状态:
+
+- **关闭(CLOSED)**:正常转发请求
+- **开启(OPEN)**:直接返回熔断响应,不转发请求
+- **半开启(HALF_OPEN)**:允许少量请求通过以测试服务是否恢复
+
:::
## 属性
-| 名称 | 类型 | 必选项 | 默认值 | 有效值 | 描述
|
-| ----------------------- | -------------- | ------ | ---------- |
--------------- | -------------------------------- |
-| break_response_code | integer | 是 | | [200, ..., 599]
| 当上游服务处于不健康状态时返回的 HTTP 错误码。 |
-| break_response_body | string | 否 | |
| 当上游服务处于不健康状态时返回的 HTTP 响应体信息。 |
-| break_response_headers | array[object] | 否 | |
[{"key":"header_name","value":"can contain Nginx $var"}] | 当上游服务处于不健康状态时返回的
HTTP 响应头信息。该字段仅在配置了 `break_response_body` 属性时生效,并能够以 `$var` 的格式包含 APISIX 变量,比如
`{"key":"X-Client-Addr","value":"$remote_addr:$remote_port"}`。 |
-| max_breaker_sec | integer | 否 | 300 | >=3
| 上游服务熔断的最大持续时间,以秒为单位。 |
-| unhealthy.http_statuses | array[integer] | 否 | [500] | [500, ...,
599] | 上游服务处于不健康状态时的 HTTP 状态码。 |
-| unhealthy.failures | integer | 否 | 3 | >=1
| 上游服务在一定时间内触发不健康状态的异常请求次数。 |
-| healthy.http_statuses | array[integer] | 否 | [200] | [200, ...,
499] | 上游服务处于健康状态时的 HTTP 状态码。 |
-| healthy.successes | integer | 否 | 3 | >=1
| 上游服务触发健康状态的连续正常请求次数。 |
+| 名称 | 类型 | 必选项 | 默认值 | 有效值
| 描述
|
+| ---------------------- | ------------- | ------ | ----------------- |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
--------------------------------------------------------------------------------------
|
+| break_response_code | integer | 是 | | [200,
..., 599]
| 当上游服务处于不健康状态时返回的 HTTP 错误码。
|
+| break_response_body | string | 否 | |
| 当上游服务处于不健康状态时返回的 HTTP 响应体信息。
|
+| break_response_headers | array[object] | 否 | |
[{"key":"header_name","value":"can contain Nginx$var"}] | 当上游服务处于不健康状态时返回的 HTTP
响应头信息。该字段仅在配置了 `break_response_body` 属性时生效,并能够以 `$var` 的格式包含 APISIX
变量,比如`{"key":"X-Client-Addr","value":"$remote_addr:$remote_port"}`。 |
|
Review Comment:
```suggestion
| break_response_headers | array[object] | 否 | |
[{"key":"header_name","value":"can contain Nginx $var"}] | 当上游服务处于不健康状态时返回的
HTTP 响应头信息。该字段仅在配置了 `break_response_body` 属性时生效,并能够以 `$var` 的格式包含 APISIX
变量,比如`{"key":"X-Client-Addr","value":"$remote_addr:$remote_port"}`。 |
|
```
##########
t/plugin/api-breaker2.t:
##########
@@ -0,0 +1,928 @@
+#
+# 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_shuffle();
+no_root_location();
+log_level('info');
+run_tests;
+
+__DATA__
+
+=== TEST 1: sanity check for unhealthy-ratio policy
+--- config
+ location /t {
+ content_by_lua_block {
+ local plugin = require("apisix.plugins.api-breaker")
+ local ok, err = plugin.check_schema({
+ break_response_code = 502,
+ policy = "unhealthy-ratio",
+ unhealthy = {
+ http_statuses = {500},
+ error_ratio = 0.5,
+ min_request_threshold = 10,
+ sliding_window_size = 300,
+ half_open_max_calls = 3
+ },
+ healthy = {
+ http_statuses = {200},
+ success_ratio = 0.6
+ },
+ })
+ if not ok then
+ ngx.say(err)
+ end
+
+ ngx.say("done")
+ }
+ }
+--- request
+GET /t
+--- response_body
+done
+
+
+
+=== TEST 2: default configuration for unhealthy-ratio policy
+--- config
+ location /t {
+ content_by_lua_block {
+ local plugin = require("apisix.plugins.api-breaker")
+ local conf = {
+ break_response_code = 502,
+ policy = "unhealthy-ratio"
+ }
+
+ local ok, err = plugin.check_schema(conf)
+ if not ok then
+ ngx.say(err)
+ end
+
+ local conf_str = require("toolkit.json").encode(conf)
+ if conf.max_breaker_sec == 300
+ and conf.unhealthy.http_statuses[1] == 500
+ and conf.break_response_code == 502 then
+ ngx.say("passed")
+ else
+ ngx.say("failed: " .. conf_str)
+ end
+ }
+ }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 3: bad error_ratio (too high)
+--- 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": {
+ "api-breaker": {
+ "break_response_code": 502,
+ "policy": "unhealthy-ratio",
+ "unhealthy": {
+ "http_statuses": [500],
+ "error_ratio": 1.5,
+ "min_request_threshold": 10
+ }
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ },
+ "uri": "/api_breaker"
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.print(body)
+ }
+ }
+--- request
+GET /t
+--- error_code: 400
+--- response_body
+{"error_msg":"failed to check the configuration of plugin api-breaker err:
else clause did not match"}
+
+
+
+=== TEST 4: bad error_ratio (negative)
+--- 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": {
+ "api-breaker": {
+ "break_response_code": 502,
+ "policy": "unhealthy-ratio",
+ "unhealthy": {
+ "http_statuses": [500],
+ "error_ratio": -0.1,
+ "min_request_threshold": 10
+ }
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ },
+ "uri": "/api_breaker"
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.print(body)
+ }
+ }
+--- request
+GET /t
+--- error_code: 400
+--- response_body
+{"error_msg":"failed to check the configuration of plugin api-breaker err:
else clause did not match"}
+
+
+
+=== TEST 5: bad min_request_threshold (zero)
+--- 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": {
+ "api-breaker": {
+ "break_response_code": 502,
+ "policy": "unhealthy-ratio",
+ "unhealthy": {
+ "http_statuses": [500],
+ "error_ratio": 0.5,
+ "min_request_threshold": 0
+ }
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ },
+ "uri": "/api_breaker"
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.print(body)
+ }
+ }
+--- request
+GET /t
+--- error_code: 400
+--- response_body
+{"error_msg":"failed to check the configuration of plugin api-breaker err:
else clause did not match"}
+
+
+
+=== TEST 6: bad sliding_window_size (too small)
+--- 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": {
+ "api-breaker": {
+ "break_response_code": 502,
+ "policy": "unhealthy-ratio",
+ "unhealthy": {
+ "http_statuses": [500],
+ "error_ratio": 0.5,
+ "min_request_threshold": 10,
+ "sliding_window_size": 5
+ }
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ },
+ "uri": "/api_breaker"
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.print(body)
+ }
+ }
+--- request
+GET /t
+--- error_code: 400
+--- response_body
+{"error_msg":"failed to check the configuration of plugin api-breaker err:
else clause did not match"}
+
+
+
+=== TEST 7: bad sliding_window_size (too large)
+--- 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": {
+ "api-breaker": {
+ "break_response_code": 502,
+ "policy": "unhealthy-ratio",
+ "unhealthy": {
+ "http_statuses": [500],
+ "error_ratio": 0.5,
+ "min_request_threshold": 10,
+ "sliding_window_size": 4000
+ }
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ },
+ "uri": "/api_breaker"
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.print(body)
+ }
+ }
+--- request
+GET /t
+--- error_code: 400
+--- response_body
+{"error_msg":"failed to check the configuration of plugin api-breaker err:
else clause did not match"}
+
+
+
+=== TEST 8: bad success_ratio (too high)
+--- 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": {
+ "api-breaker": {
+ "break_response_code": 502,
+ "policy": "unhealthy-ratio",
+ "unhealthy": {
+ "http_statuses": [500],
+ "error_ratio": 0.5,
+ "min_request_threshold": 10
+ },
+ "healthy": {
+ "http_statuses": [200],
+ "success_ratio": 1.5
+ }
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ },
+ "uri": "/api_breaker"
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.print(body)
+ }
+ }
+--- request
+GET /t
+--- error_code: 400
+--- response_body
+{"error_msg":"failed to check the configuration of plugin api-breaker err:
else clause did not match"}
+
+
+
+=== TEST 9: bad half_open_max_calls (too large)
+--- 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": {
+ "api-breaker": {
+ "break_response_code": 502,
+ "policy": "unhealthy-ratio",
+ "unhealthy": {
+ "http_statuses": [500],
+ "error_ratio": 0.5,
+ "min_request_threshold": 10,
+ "sliding_window_size": 300,
+ "half_open_max_calls": 25
+ }
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ },
+ "uri": "/api_breaker"
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.print(body)
+ }
+ }
+--- request
+GET /t
+--- error_code: 400
+--- response_body
+{"error_msg":"failed to check the configuration of plugin api-breaker err:
else clause did not match"}
+
+
+
+=== TEST 10: set route with unhealthy-ratio policy
+--- 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": {
+ "api-breaker": {
+ "break_response_code": 502,
+ "break_response_body": "Upstream failure",
+ "policy": "unhealthy-ratio",
+ "max_breaker_sec": 10,
+ "unhealthy": {
+ "http_statuses": [500, 503],
+ "error_ratio": 0.6,
+ "min_request_threshold": 3,
+ "sliding_window_size": 60,
+ "half_open_max_calls": 2
+ },
+ "healthy": {
+ "http_statuses": [200],
+ "successes": 2
+ }
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ },
+ "uri": "/api_breaker"
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST $((${1}+1)): test ratio-based circuit breaker functionality
Review Comment:
It is recommended to use fixed test numbers.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]