Copilot commented on code in PR #12765:
URL: https://github.com/apache/apisix/pull/12765#discussion_r2752396145
##########
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")
Review Comment:
OPEN→HALF_OPEN transition is not concurrency-safe: multiple simultaneous
requests can observe `state == OPEN`, each transition to HALF_OPEN and reset
the half-open counters, effectively allowing more than `half_open_max_calls`.
Consider guarding the transition/reset with an atomic shared-dict operation
(e.g., `add`/CAS-style lock key) or a `resty.lock` so only one request performs
the state change and counter initialization.
##########
apisix/plugins/api-breaker.lua:
##########
@@ -133,13 +267,70 @@ local _M = {
schema = schema,
}
-
function _M.check_schema(conf)
return core.schema.check(schema, conf)
end
+-- Circuit breaker state management functions
+local function get_circuit_breaker_state(ctx)
+ local state_key = gen_state_key(ctx)
+ local state, err = shared_buffer:get(state_key)
+ if err then
+ core.log.warn("failed to get circuit breaker state: ", err)
+ return CLOSED
+ end
+ return state or CLOSED
+end
+
+local function set_circuit_breaker_state(ctx, state)
+ local state_key = gen_state_key(ctx)
+ local last_change_key = gen_last_state_change_key(ctx)
+ local current_time = ngx.time()
-function _M.access(conf, ctx)
+ shared_buffer:set(state_key, state)
+ shared_buffer:set(last_change_key, current_time)
+
+ core.log.info("Circuit breaker state changed to: ", state, " at: ",
current_time)
+end
+
+-- Sliding window management
+local function reset_sliding_window(ctx, current_time, window_size)
+ local window_start_key = gen_window_start_time_key(ctx)
+ local total_requests_key = gen_total_requests_key(ctx)
+ local unhealthy_key = gen_unhealthy_key(ctx)
+
+ shared_buffer:set(window_start_key, current_time)
+ shared_buffer:set(total_requests_key, 0)
+ shared_buffer:set(unhealthy_key, 0)
+
+ -- Reset circuit breaker state to CLOSED when sliding window resets
+ shared_buffer:delete(gen_state_key(ctx))
+ shared_buffer:delete(gen_last_state_change_key(ctx))
+ shared_buffer:delete(gen_half_open_calls_key(ctx))
+ shared_buffer:delete(gen_half_open_success_key(ctx))
+
+ core.log.info("Sliding window reset at: ", current_time, " window size: ",
window_size, "s")
+end
+
+local function check_and_reset_window(ctx, conf)
+ local current_time = ngx.time()
+ local window_start_key = gen_window_start_time_key(ctx)
+ local window_start_time, err = shared_buffer:get(window_start_key)
+
+ if err then
+ core.log.warn("failed to get window start time: ", err)
+ return
+ end
+
+ local window_size = conf.unhealthy.sliding_window_size or 300
+
+ if not window_start_time or (current_time - window_start_time) >=
window_size then
+ reset_sliding_window(ctx, current_time, window_size)
+ end
Review Comment:
The implementation resets counters when `(now - window_start) >=
window_size`, which is a fixed/tumbling window, not a sliding window as
described in the PR text/docs (sliding would continuously age out old
requests). Either update the docs/terminology to "fixed window" or implement a
true sliding window (e.g., ring buffer of buckets) so error ratios reflect the
last N seconds at any point in time.
##########
apisix/plugins/api-breaker.lua:
##########
@@ -133,13 +267,70 @@ local _M = {
schema = schema,
}
-
function _M.check_schema(conf)
return core.schema.check(schema, conf)
end
+-- Circuit breaker state management functions
+local function get_circuit_breaker_state(ctx)
+ local state_key = gen_state_key(ctx)
+ local state, err = shared_buffer:get(state_key)
+ if err then
+ core.log.warn("failed to get circuit breaker state: ", err)
+ return CLOSED
+ end
+ return state or CLOSED
+end
+
+local function set_circuit_breaker_state(ctx, state)
+ local state_key = gen_state_key(ctx)
+ local last_change_key = gen_last_state_change_key(ctx)
+ local current_time = ngx.time()
-function _M.access(conf, ctx)
+ shared_buffer:set(state_key, state)
+ shared_buffer:set(last_change_key, current_time)
+
+ core.log.info("Circuit breaker state changed to: ", state, " at: ",
current_time)
+end
+
+-- Sliding window management
+local function reset_sliding_window(ctx, current_time, window_size)
+ local window_start_key = gen_window_start_time_key(ctx)
+ local total_requests_key = gen_total_requests_key(ctx)
+ local unhealthy_key = gen_unhealthy_key(ctx)
+
+ shared_buffer:set(window_start_key, current_time)
+ shared_buffer:set(total_requests_key, 0)
+ shared_buffer:set(unhealthy_key, 0)
+
+ -- Reset circuit breaker state to CLOSED when sliding window resets
+ shared_buffer:delete(gen_state_key(ctx))
+ shared_buffer:delete(gen_last_state_change_key(ctx))
+ shared_buffer:delete(gen_half_open_calls_key(ctx))
+ shared_buffer:delete(gen_half_open_success_key(ctx))
Review Comment:
`reset_sliding_window` deletes the circuit breaker state/transition
timestamps. This means an OPEN breaker can be implicitly reset to CLOSED just
because the sliding window expired, bypassing `max_breaker_sec` and breaking
expected OPEN/HALF_OPEN behavior. Keep the breaker state independent from the
request/error statistics reset (only reset counters/time window), or only reset
state when explicitly transitioning due to policy logic.
```suggestion
-- Only reset sliding window statistics; circuit breaker state is
managed separately
```
##########
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
Review Comment:
`wait_duration` falls back to `60` when `conf.max_breaker_sec` is nil, but
the schema default is 300 and the rest of the plugin consistently uses
`conf.max_breaker_sec`. This fallback can cause unexpected behavior if defaults
aren't applied for some reason. Prefer using `conf.max_breaker_sec` directly
(or the same 300 default) for consistency.
```suggestion
local wait_duration = conf.max_breaker_sec or 300
```
##########
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
Review Comment:
In OPEN state, the ratio-policy path adds `break_response_headers` even when
`break_response_body` is not configured, and it returns a default body string
(`"Service temporarily unavailable"`) via `conf.break_response_body or ...`.
This diverges from the existing count-policy behavior and the documentation
note that headers only take effect when `break_response_body` is set. Align
behavior by only attaching headers when a body is configured and returning only
the status code when `break_response_body` is nil (or update docs/schema
accordingly).
##########
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:
In the `break_response_headers` row, the valid-values example says `can
contain Nginx$var` (missing space) while the English docs and earlier text use
`Nginx $var`. This looks like a typo and can be confusing when copying examples.
##########
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"}`。 |
|
+| max_breaker_sec | integer | 否 | 300 | >=3
| 上游服务熔断的最大持续时间,以秒为单位。适用于两种熔断策略。
|
+| policy | string | 否 | "unhealthy-count" |
["unhealthy-count", "unhealthy-ratio"]
| 熔断策略。`unhealthy-count`
为按错误次数熔断,`unhealthy-ratio` 为按错误比例熔断。 |
+
+### 按错误次数熔断(policy = "unhealthy-count")
+
+| 名称 | 类型 | 必选项 | 默认值 | 有效值 | 描述
|
+| ----------------------- | -------------- | ------ | ------ | ---------------
| -------------------------------------------------- |
+| 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
| 上游服务触发健康状态的连续正常请求次数。 |
+
+### 按错误比例熔断(policy = "unhealthy-ratio")
+
+| 名称 | 类型 | 必选项 |
默认值 | 有效值 | 描述
|
+| ------------------------------------------------------ | -------------- |
------ | ------ | --------------- |
----------------------------------------------------------------------------------------
|
+| unhealthy.http_statuses | array[integer] | 否
| [500] | [500, ..., 599] | 上游服务处于不健康状态时的 HTTP 状态码。
|
+| unhealthy.error_ratio | number | 否
| 0.5 | [0, 1] | 触发熔断的错误率阈值。例如 0.5 表示错误率达到 50% 时触发熔断。
|
+| unhealthy.min_request_threshold | integer | 否
| 10 | >=1 | 在滑动时间窗口内触发熔断所需的最小请求数。只有请求数达到此阈值时才会评估错误率。
|
+| unhealthy.sliding_window_size | integer | 否
| 300 | [10, 3600] | 滑动时间窗口大小,以秒为单位。用于统计错误率的时间范围。
|
+| unhealthy.half_open_max_calls | integer | 否 | 3 | [1, 20]
| 在半开启状态下允许通过的请求数量。用于测试服务是否恢复正常。 |
+| healthy.http_statuses | array[integer] | 否
| [200] | [200, ..., 499] | 上游服务处于健康状态时的 HTTP 状态码。
|
+| healthy.successes | integer | 否
| 3 | >=1 | 上游服务触发健康状态的连续正常请求次数。
|
Review Comment:
The Chinese docs list `healthy.successes` under the unhealthy-ratio policy,
but the ratio-based policy uses `healthy.success_ratio` (and does not define
`successes`). This is inconsistent with the plugin schema and can confuse
users. Remove/relocate `healthy.successes` from the ratio section and document
`healthy.success_ratio` instead (keep `successes` only for unhealthy-count).
```suggestion
```
##########
docs/en/latest/plugins/api-breaker.md:
##########
@@ -95,6 +133,47 @@ curl "http://127.0.0.1:9180/apisix/admin/routes/1" \
In this configuration, a response code of `500` or `503` three times within a
certain period of time triggers the unhealthy status of the Upstream service. A
response code of `200` restores its healthy status.
+### Error ratio-based circuit breaking example
+
+The example below shows how to enable error ratio-based circuit breaking
policy. This configuration triggers circuit breaker when the request count
reaches 10 and error rate exceeds 50% within a 5-minute sliding window:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes/2" \
+-H "X-API-KEY: $admin_key" -X PUT -d '
+{
+ "plugins": {
+ "api-breaker": {
+ "break_response_code": 503,
+ "break_response_body": "Service temporarily unavailable due to
high error rate",
+ "break_response_headers": [
+ {"key": "X-Circuit-Breaker", "value": "open"},
+ {"key": "Retry-After", "value": "60"}
+ ],
+ "policy": "unhealthy-ratio",
+ "max_breaker_sec": 60,
+ "unhealthy": {
+ "http_statuses": [500, 502, 503, 504],
+ "error_ratio": 0.5,
+ "min_request_threshold": 10,
+ "sliding_window_size": 300,
+ "half_open_max_calls": 3
+ },
+ "healthy": {
+ "http_statuses": [200, 201, 202],
+ "successes": 3
Review Comment:
The unhealthy-ratio example config uses `healthy.successes`, but the
ratio-based policy schema uses `healthy.success_ratio` (success rate threshold)
instead. As written, the example is likely ignored/misleading for users. Update
the example to use `"success_ratio": ...` (and keep `successes` only for the
unhealthy-count policy).
```suggestion
"success_ratio": 0.5
```
##########
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
Review Comment:
The unhealthy-ratio configuration in this test uses `healthy.successes`, but
the ratio-policy schema/plugin logic expects `healthy.success_ratio` (and does
not define `successes`). This makes the test config misleading and may leave
behavior dependent on defaults. Update the test to use `healthy.success_ratio`
(or adjust schema/plugin to accept `successes` if that's intended).
```suggestion
"success_ratio": 2
```
##########
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
+--- request eval
+[
+ "GET /api_breaker?code=200",
+ "GET /api_breaker?code=500",
+ "GET /api_breaker?code=500",
+ "GET /api_breaker?code=500",
+ "GET /api_breaker?code=200"
+]
+--- error_code eval
+[200, 500, 500, 502, 502]
+--- response_body eval
+[
+ "hello world",
+ "fault injection!",
+ "fault injection!",
+ "Upstream failure",
+ "Upstream failure"
+]
+
+
+
+=== TEST $((${1}+1)): wait for circuit breaker to enter half-open state
+--- config
+ location /t {
+ content_by_lua_block {
+ ngx.sleep(11) -- wait longer than max_breaker_sec
+ ngx.say("waited")
+ }
+ }
+--- request
+GET /t
+--- response_body
+waited
+--- timeout: 15
+
+
+
+=== TEST 11: test half-open state functionality
+--- request eval
+[
+ "GET /api_breaker?code=200",
+ "GET /api_breaker?code=200",
+ "GET /api_breaker?code=200"
+]
+--- error_code eval
+[200, 200, 200]
+--- response_body eval
+[
+ "hello world",
+ "hello world",
+ "hello world"
+]
+
+
+
+=== TEST 12: verify circuit breaker works with custom break_response_headers
+--- 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": 503,
+ "break_response_body": "Service temporarily
unavailable",
+ "break_response_headers": [
+ {"key": "X-Circuit-Breaker", "value":
"open"},
+ {"key": "Retry-After", "value": "30"}
+ ],
+ "policy": "unhealthy-ratio",
+ "max_breaker_sec": 5,
+ "unhealthy": {
+ "http_statuses": [500],
+ "error_ratio": 0.5,
+ "min_request_threshold": 2,
+ "sliding_window_size": 30
+ }
+ }
+ },
+ "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 13: trigger circuit breaker with custom headers (combined)
+--- request eval
+[
+ "GET /api_breaker?code=500",
+ "GET /api_breaker?code=500",
+ "GET /api_breaker?code=500"
+]
+--- error_code eval
+[500, 500, 503]
+
+
+
+=== TEST 14: setup route for sliding window expiration test
+--- 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],
+ "error_ratio": 0.5,
+ "min_request_threshold": 3,
+ "sliding_window_size": 10,
+ "half_open_max_calls": 2
+ },
+ "healthy": {
+ "http_statuses": [200],
+ "success_ratio": 0.6
+ }
+ }
+ },
+ "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 15: test sliding window statistics reset after expiration
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+
+ -- First, make some requests to accumulate statistics
+ ngx.say("Phase 1: Accumulate statistics ===")
+ local code1 = t('/api_breaker?code=200', ngx.HTTP_GET)
+ ngx.say("Request 1 (200): ", code1)
+
+ local code2 = t('/api_breaker?code=500', ngx.HTTP_GET)
+ ngx.say("Request 2 (500): ", code2)
+
+ local code3 = t('/api_breaker?code=500', ngx.HTTP_GET)
+ ngx.say("Request 3 (500): ", code3)
+
+ -- At this point: 3 total requests, 2 failures, failure rate = 2/3
= 0.67 > 0.5
+ -- Should trigger circuit breaker
+ local code4 = t('/api_breaker?code=200', ngx.HTTP_GET)
+ ngx.say("Request 4 (should be 502): ", code4)
+
+ ngx.say("Phase 2: Wait for sliding window to expire ===")
+ -- Wait for sliding window to expire (sliding_window_size = 10
seconds)
+ ngx.sleep(11)
+
+ ngx.say("Phase 3: Test after window expiration ===")
+ -- After window expiration, statistics should be reset
+ -- New requests should not trigger circuit breaker immediately
+ local code5 = t('/api_breaker?code=200', ngx.HTTP_GET)
+ ngx.say("Request 5 after expiration (should be 200): ", code5)
+
+ local code6 = t('/api_breaker?code=200', ngx.HTTP_GET)
+ ngx.say("Request 6 after expiration (should be 200): ", code6)
+ }
+ }
+--- request
+GET /t
+--- response_body
+Phase 1: Accumulate statistics ===
+Request 1 (200): 200
+Request 2 (500): 500
+Request 3 (500): 500
+Request 4 (should be 502): 502
+Phase 2: Wait for sliding window to expire ===
+Phase 3: Test after window expiration ===
+Request 5 after expiration (should be 200): 200
+Request 6 after expiration (should be 200): 200
+--- timeout: 60
+
+
+
+=== TEST 16: setup route for half-open failure fallback test
+--- 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": 5,
+ "unhealthy": {
+ "http_statuses": [500],
+ "error_ratio": 0.5,
+ "min_request_threshold": 2,
+ "sliding_window_size": 60,
+ "half_open_max_calls": 3
+ },
+ "healthy": {
+ "http_statuses": [200],
+ "success_ratio": 0.7
+ }
+ }
+ },
+ "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 17: test half-open state failure fallback to open state
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+
+ ngx.say("Phase 1: Trigger circuit breaker ===")
+ -- First trigger circuit breaker to OPEN state
+ local code1 = t('/api_breaker?code=500', ngx.HTTP_GET)
+ ngx.say("Request 1 (500): ", code1)
+
+ local code2 = t('/api_breaker?code=500', ngx.HTTP_GET)
+ ngx.say("Request 2 (500): ", code2)
+
+ -- Should trigger circuit breaker (2 failures,
min_request_threshold=2, error_ratio=1.0 > 0.5)
+ local code3 = t('/api_breaker?code=200', ngx.HTTP_GET)
+ ngx.say("Request 3 (should be 502): ", code3)
+
+ ngx.say("Phase 2: Wait for half-open state ===")
+ -- Wait for circuit breaker to enter half-open state
+ ngx.sleep(6)
+
+ ngx.say("Phase 3: Test half-open failure fallback ===")
+ -- In half-open state, first request should be allowed
+ local code4 = t('/api_breaker?code=200', ngx.HTTP_GET)
+ ngx.say("Request 4 in half-open (should be 200): ", code4)
+
+ -- Second request fails - should cause fallback to OPEN state
+ local code5 = t('/api_breaker?code=500', ngx.HTTP_GET)
+ ngx.say("Request 5 in half-open (500 - should trigger fallback):
", code5)
+
+ -- Subsequent requests should be rejected (circuit breaker back to
OPEN)
+ local code6 = t('/api_breaker?code=200', ngx.HTTP_GET)
+ ngx.say("Request 6 after fallback (should be 502): ", code6)
+
+ local code7 = t('/api_breaker?code=200', ngx.HTTP_GET)
+ ngx.say("Request 7 after fallback (should be 502): ", code7)
+ }
+ }
+--- request
+GET /t
+--- response_body
+Phase 1: Trigger circuit breaker ===
+Request 1 (500): 500
+Request 2 (500): 500
+Request 3 (should be 502): 502
+Phase 2: Wait for half-open state ===
+Phase 3: Test half-open failure fallback ===
+Request 4 in half-open (should be 200): 200
+Request 5 in half-open (500 - should trigger fallback): 500
+Request 6 after fallback (should be 502): 502
+Request 7 after fallback (should be 502): 502
+--- timeout: 15
+
+
+
+=== TEST 18: setup route for half-open request limit test
+--- 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": 3,
+ "unhealthy": {
+ "http_statuses": [500],
+ "error_ratio": 0.5,
+ "min_request_threshold": 2,
+ "sliding_window_size": 60,
+ "half_open_max_calls": 2
+ },
+ "healthy": {
+ "http_statuses": [200],
+ "success_ratio": 1
+ }
+ }
+ },
+ "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 19: test half-open state request limit enforcement and header check
+--- config
+ location /t {
+ content_by_lua_block {
+ local function run_req(uri)
+ local sock = ngx.socket.tcp()
+ sock:settimeout(2000)
+ local ok, err = sock:connect("127.0.0.1", 1984)
+ if not ok then
+ ngx.say("failed to connect: ", err)
+ return nil
+ end
+
+ local req = "GET " .. uri .. " HTTP/1.1\r\nHost:
localhost\r\nConnection: close\r\n\r\n"
+ local bytes, err = sock:send(req)
+ if not bytes then
+ ngx.say("failed to send: ", err)
+ return nil
+ end
+
+ local reader = sock:receiveuntil("\r\n")
+ local line, err = reader()
+ if not line then
+ ngx.say("failed to read status: ", err)
+ return nil
+ end
+
+ local status = tonumber(string.match(line, "HTTP/%d%.%d
(%d+)"))
+
+ -- check for headers in the response
+ local headers = {}
+ while true do
+ local h_line, err = reader()
+ if not h_line or h_line == "" then break end
+ local k, v = string.match(h_line, "([^:]+):%s*(.+)")
+ if k then headers[k] = v end
+ end
+
+ sock:receive("*a") -- read body to close cleanly
+ sock:close()
+ return status, headers
+ end
+
+ ngx.say("Phase 1: Trigger circuit breaker")
+ -- First trigger circuit breaker to OPEN state
+ run_req('/api_breaker?code=500')
+ run_req('/api_breaker?code=500')
+ local code3 = run_req('/api_breaker?code=200')
+ ngx.say("Trigger req status: ", code3)
+
+ ngx.say("Phase 2: Wait for half-open state")
+ ngx.sleep(3.2)
+
+ ngx.say("Phase 3: Test half-open request limit")
+
+ local threads = {}
+ -- Fire 3 requests concurrently. Limit is 2.
+ for i = 1, 3 do
+ threads[i] = ngx.thread.spawn(function()
+ local code, err = run_req('/api_breaker?code=200')
+ if not code then
+ return "err: " .. (err or "unknown")
+ end
+ return code
+ end)
+ end
+
+ local results = {}
+ for i = 1, 3 do
+ local ok, res = ngx.thread.wait(threads[i])
+ if ok then
+ table.insert(results, res)
+ else
+ table.insert(results, "thread_err")
+ end
+ end
+ table.sort(results)
+ ngx.say("Results: ", table.concat(results, ", "))
Review Comment:
This test is meant to validate `half_open_max_calls` enforcement (limit is
2), but the expected output allows all 3 concurrent requests (`Results: 200,
200, 200`). That expectation will not catch violations and currently masks the
OPEN→HALF_OPEN race/reset issue. Update the assertion to require only 2
successes and 1 breaker response (e.g., 502/503 depending on config) and/or
explicitly assert the circuit-breaker headers if that's part of the test intent.
##########
t/plugin/api-breaker.t:
##########
@@ -295,7 +331,7 @@ GET /t
GET /t
--- error_code: 400
--- response_body
-{"error_msg":"failed to check the configuration of plugin api-breaker err:
property \"healthy\" validation failed: property \"http_statuses\" validation
failed: expected unique items but items 1 and 2 are equal"}
+{"error_msg":"failed to check the configuration of plugin api-breaker err:
then clause did not match"}
Review Comment:
This test now asserts a generic schema failure message (`then clause did not
match`). That message is an artifact of the conditional schema and is much less
specific than the previous unique-items validation error, making the test less
effective at catching regressions. Prefer asserting the actual validation cause
(e.g., via `response_body_like` matching `expected unique items`), or otherwise
adjust the schema so duplicate `healthy.http_statuses` produces a stable/clear
error message.
--
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]