feat(proxy,config): 增强健康检查和缓存配置

健康检查增强:
- 添加 HealthMatch 接口支持自定义健康判断逻辑
- 支持状态码范围、响应体正则、响应头匹配
- 集成 SlowStartManager 实现慢启动

配置增强:
- HealthCheckConfig 新增 Match 和 SlowStart 字段
- ProxyBufferingConfig 支持 Buffers 配置字符串格式
- 新增 ProxyCachePathConfig 磁盘缓存路径配置
- 添加 StaleIfError/StaleIfTimeout 缓存配置

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xfy 2026-04-22 13:15:18 +08:00
parent 26a7a58265
commit 92b7040a5f
5 changed files with 773 additions and 33 deletions

View File

@ -22,6 +22,8 @@ import (
"fmt"
"net"
"os"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
@ -72,17 +74,18 @@ const (
// // 处理每个服务器配置
// }
type Config struct {
Mode ServerMode `yaml:"mode"`
Variables VariablesConfig `yaml:"variables"`
Logging LoggingConfig `yaml:"logging"`
Servers []ServerConfig `yaml:"servers"`
Stream []StreamConfig `yaml:"stream"`
Monitoring MonitoringConfig `yaml:"monitoring"`
HTTP3 HTTP3Config `yaml:"http3"`
Resolver ResolverConfig `yaml:"resolver"`
Performance PerformanceConfig `yaml:"performance"`
Shutdown ShutdownConfig `yaml:"shutdown"`
Include []IncludeConfig `yaml:"include"` // 配置引入,支持从其他文件引入配置片段
Mode ServerMode `yaml:"mode"`
Variables VariablesConfig `yaml:"variables"`
Logging LoggingConfig `yaml:"logging"`
Servers []ServerConfig `yaml:"servers"`
Stream []StreamConfig `yaml:"stream"`
Monitoring MonitoringConfig `yaml:"monitoring"`
HTTP3 HTTP3Config `yaml:"http3"`
Resolver ResolverConfig `yaml:"resolver"`
Performance PerformanceConfig `yaml:"performance"`
Shutdown ShutdownConfig `yaml:"shutdown"`
Include []IncludeConfig `yaml:"include"` // 配置引入,支持从其他文件引入配置片段
CachePath *ProxyCachePathConfig `yaml:"cache_path"` // 缓存路径配置(磁盘持久化)
}
// IncludeConfig 配置引入配置。
@ -390,6 +393,85 @@ type ProxyBufferingConfig struct {
// BufferSize 响应缓冲区大小(字节)
// 0 表示使用默认值
BufferSize int `yaml:"buffer_size"`
// Buffers 多缓冲区配置字符串
// 格式:"数量 大小" 或 "数量1 大小1 数量2 大小2 ..."
// 例如:"8 16k" 表示 8 个 16KB 缓冲区
// 例如:"4 4k 8 16k" 表示 4 个 4KB + 8 个 16KB 缓冲区
Buffers string `yaml:"buffers"`
// BufferCount 缓冲区数量(解析后)
BufferCount int `yaml:"-"`
// BufferSizeEach 每个缓冲区大小(字节,解析后)
BufferSizeEach int `yaml:"-"`
}
// ParseBuffers 解析 Buffers 配置字符串。
//
// 支持格式:
// - "8 16k" → 8 个 16KB 缓冲区
// - "4 4k" → 4 个 4KB 缓冲区
//
// 大小单位:
// - k 或 K: KB (1024 字节)
// - m 或 M: MB (1024 * 1024 字节)
// - 无单位: 字节
func (c *ProxyBufferingConfig) ParseBuffers() {
if c.Buffers == "" {
// 向后兼容:使用 BufferSize
if c.BufferSize > 0 {
c.BufferCount = 1
c.BufferSizeEach = c.BufferSize
}
return
}
parts := strings.Fields(c.Buffers)
if len(parts) < 2 {
return // 无效格式
}
count, err := strconv.Atoi(parts[0])
if err != nil || count <= 0 {
return // 无效数量
}
sizeEach, err := parseSize(parts[1])
if err != nil || sizeEach <= 0 {
return // 无效大小
}
c.BufferCount = count
c.BufferSizeEach = sizeEach
}
// parseSize 解析大小字符串(支持 k, m 单位)。
func parseSize(s string) (int, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, strconv.ErrSyntax
}
// 提取单位
unit := strings.ToLower(s[len(s)-1:])
var multiplier int = 1
numStr := s
if unit == "k" {
multiplier = 1024
numStr = s[:len(s)-1]
} else if unit == "m" {
multiplier = 1024 * 1024
numStr = s[:len(s)-1]
}
value, err := strconv.Atoi(numStr)
if err != nil {
return 0, err
}
return value * multiplier, nil
}
// BalancerByLuaConfig Lua 负载均衡配置
@ -489,9 +571,18 @@ type ProxyTarget struct {
// path: "/health"
// timeout: 5s
type HealthCheckConfig struct {
Path string `yaml:"path"`
Interval time.Duration `yaml:"interval"`
Timeout time.Duration `yaml:"timeout"`
Path string `yaml:"path"`
Interval time.Duration `yaml:"interval"`
Timeout time.Duration `yaml:"timeout"`
Match *HealthMatchConfig `yaml:"match"` // 健康检查匹配配置
SlowStart time.Duration `yaml:"slow_start"` // 慢启动时间
}
// HealthMatchConfig 健康检查匹配配置。
type HealthMatchConfig struct {
Status []string `yaml:"status"` // 状态码范围列表
Body string `yaml:"body"` // 响应体正则表达式
Headers map[string]string `yaml:"headers"` // 响应头匹配
}
// ProxyTimeout 代理超时配置。
@ -576,6 +667,54 @@ type ProxyHeaders struct {
CookiePath string `yaml:"cookie_path"`
}
// ProxyCachePathConfig 缓存路径配置(磁盘持久化)。
//
// 配置磁盘缓存路径和相关参数,支持 L1/L2 分层缓存架构。
// 配置后,代理缓存将持久化到磁盘,服务重启后可恢复。
//
// 注意事项:
// - Path 为必填项,指定缓存根目录
// - Levels 支持最多 3 级目录(如 "1:2:2"
// - MaxSize 为 0 表示不限制大小
// - L1MaxEntries/L1MaxSize 为 0 时使用默认值
//
// 使用示例:
//
// cache_path:
// path: "/var/cache/lolly"
// levels: "1:2"
// max_size: "1GB"
// inactive: "60m"
// l1_max_entries: 10000
type ProxyCachePathConfig struct {
// Path 缓存根目录
Path string `yaml:"path"`
// Levels 目录层级,如 "1:2" 表示两级目录
Levels string `yaml:"levels"`
// MaxSize 最大缓存大小(字节)
MaxSize int64 `yaml:"max_size"`
// Inactive 未访问淘汰时间
Inactive time.Duration `yaml:"inactive"`
// Purger 是否启用后台清理
Purger bool `yaml:"purger"`
// PurgerInterval 清理间隔
PurgerInterval time.Duration `yaml:"purger_interval"`
// L1MaxEntries L1 最大条目数
L1MaxEntries int64 `yaml:"l1_max_entries"`
// L1MaxSize L1 最大内存大小
L1MaxSize int64 `yaml:"l1_max_size"`
// PromoteThreshold 提升到 L1 的访问阈值
PromoteThreshold int `yaml:"promote_threshold"`
}
// ProxyCacheConfig 代理缓存配置。
//
// 缓存后端响应,减少重复请求,提高响应速度。
@ -596,6 +735,8 @@ type ProxyHeaders struct {
type ProxyCacheConfig struct {
MaxAge time.Duration `yaml:"max_age"`
StaleWhileRevalidate time.Duration `yaml:"stale_while_revalidate"`
StaleIfError time.Duration `yaml:"stale_if_error"` // 错误时使用过期缓存
StaleIfTimeout time.Duration `yaml:"stale_if_timeout"` // 超时时使用过期缓存
Enabled bool `yaml:"enabled"`
CacheLock bool `yaml:"cache_lock"`
Methods []string `yaml:"methods"`

View File

@ -414,3 +414,80 @@ func TestConfigMethods(t *testing.T) {
}
})
}
func TestProxyBufferingConfig_ParseBuffers(t *testing.T) {
tests := []struct {
name string
buffers string
bufferSize int
wantCount int
wantSizeEach int
}{
{
name: "empty uses buffer_size",
buffers: "",
bufferSize: 4096,
wantCount: 1,
wantSizeEach: 4096,
},
{
name: "8 16k format",
buffers: "8 16k",
wantCount: 8,
wantSizeEach: 16 * 1024,
},
{
name: "4 4k format",
buffers: "4 4k",
wantCount: 4,
wantSizeEach: 4 * 1024,
},
{
name: "2 1m format",
buffers: "2 1m",
wantCount: 2,
wantSizeEach: 1024 * 1024,
},
{
name: "bytes without unit",
buffers: "4 8192",
wantCount: 4,
wantSizeEach: 8192,
},
{
name: "uppercase K",
buffers: "8 16K",
wantCount: 8,
wantSizeEach: 16 * 1024,
},
{
name: "invalid format",
buffers: "invalid",
wantCount: 0,
wantSizeEach: 0,
},
{
name: "missing size",
buffers: "8",
wantCount: 0,
wantSizeEach: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &ProxyBufferingConfig{
Buffers: tt.buffers,
BufferSize: tt.bufferSize,
}
cfg.ParseBuffers()
if cfg.BufferCount != tt.wantCount {
t.Errorf("BufferCount = %d, want %d", cfg.BufferCount, tt.wantCount)
}
if cfg.BufferSizeEach != tt.wantSizeEach {
t.Errorf("BufferSizeEach = %d, want %d", cfg.BufferSizeEach, tt.wantSizeEach)
}
})
}
}

View File

@ -56,13 +56,15 @@ const healthPath = "/health"
// checker.Start()
// defer checker.Stop()
type HealthChecker struct {
stopCh chan struct{}
client *fasthttp.Client
path string
targets []*loadbalance.Target
interval time.Duration
timeout time.Duration
running atomic.Bool
stopCh chan struct{}
client *fasthttp.Client
path string
targets []*loadbalance.Target
interval time.Duration
timeout time.Duration
running atomic.Bool
matcher HealthMatch // 健康检查匹配器
slowStartManager *loadbalance.SlowStartManager // 慢启动管理器
}
// NewHealthChecker 使用指定的目标和配置创建一个新的 HealthChecker。
@ -90,12 +92,33 @@ func NewHealthChecker(targets []*loadbalance.Target, cfg *config.HealthCheckConf
path = healthPath
}
// 创建健康检查匹配器
var matcher HealthMatch
if cfg.Match != nil {
matcher = NewHealthMatch(&HealthMatchConfig{
Status: cfg.Match.Status,
Body: cfg.Match.Body,
Headers: cfg.Match.Headers,
})
}
if matcher == nil {
matcher = DefaultHealthMatch()
}
// 创建慢启动管理器
var slowStartManager *loadbalance.SlowStartManager
if cfg.SlowStart > 0 {
slowStartManager = loadbalance.NewSlowStartManager(cfg.SlowStart)
}
return &HealthChecker{
targets: targets,
interval: interval,
timeout: timeout,
path: path,
stopCh: make(chan struct{}),
targets: targets,
interval: interval,
timeout: timeout,
path: path,
stopCh: make(chan struct{}),
matcher: matcher,
slowStartManager: slowStartManager,
client: &fasthttp.Client{
ReadTimeout: timeout,
WriteTimeout: timeout,
@ -114,6 +137,9 @@ func (h *HealthChecker) Start() {
}
h.running.Store(true)
if h.slowStartManager != nil {
h.slowStartManager.Start()
}
go h.run()
}
@ -125,6 +151,9 @@ func (h *HealthChecker) Stop() {
if !h.running.CompareAndSwap(true, false) {
return // 已经停止,直接返回
}
if h.slowStartManager != nil {
h.slowStartManager.Stop()
}
close(h.stopCh)
// 重新创建 stopCh 以支持后续 Start
h.stopCh = make(chan struct{})
@ -172,12 +201,12 @@ func (h *HealthChecker) checkAll() {
//
// 目标被认为健康,如果满足以下条件:
// - HTTP 请求成功
// - 响应状态码在 200 到 299 之间
// - matcher.Match 返回 true
//
// 目标被标记为不健康,如果满足以下条件:
// - 连接失败
// - 请求超时
// - 响应状态码不是 2xx
// - matcher.Match 返回 false
func (h *HealthChecker) checkTarget(target *loadbalance.Target) {
// 构建健康检查 URL
url := target.URL + h.path
@ -196,16 +225,23 @@ func (h *HealthChecker) checkTarget(target *loadbalance.Target) {
err := h.client.DoTimeout(req, resp, h.timeout)
if err != nil {
// 连接失败或超时 - 标记为不健康
target.Healthy.Store(false)
h.MarkUnhealthy(target)
return
}
// 检查状态码 - 2xx 为健康
// 提取响应头(小写 key
headers := make(map[string]string)
for key, value := range resp.Header.All() {
headers[string(key)] = string(value)
}
// 使用 matcher 判断健康状态
statusCode := resp.StatusCode()
if statusCode >= 200 && statusCode < 300 {
target.Healthy.Store(true)
body := resp.Body()
if h.matcher.Match(statusCode, body, headers) {
h.MarkHealthy(target)
} else {
target.Healthy.Store(false)
h.MarkUnhealthy(target)
}
}
@ -215,9 +251,13 @@ func (h *HealthChecker) checkTarget(target *loadbalance.Target) {
//
// 同时调用 RecordFailure 记录软失败状态,配合 MaxFails/FailTimeout
// 实现失败计数和冷却机制。
// 同时通知 SlowStartManager 清除慢启动状态。
func (h *HealthChecker) MarkUnhealthy(target *loadbalance.Target) {
target.Healthy.Store(false)
target.RecordFailure()
if h.slowStartManager != nil {
h.slowStartManager.OnTargetUnhealthy(target)
}
}
// MarkHealthy 将目标标记为健康。
@ -225,9 +265,13 @@ func (h *HealthChecker) MarkUnhealthy(target *loadbalance.Target) {
//
// 同时调用 RecordSuccess 重置软失败状态failCount/failedUntil
// 但不修改 Healthy 标志——健康检查器对 Healthy 拥有权威。
// 同时通知 SlowStartManager 开始慢启动。
func (h *HealthChecker) MarkHealthy(target *loadbalance.Target) {
target.Healthy.Store(true)
target.RecordSuccess()
if h.slowStartManager != nil {
h.slowStartManager.OnTargetHealthy(target)
}
}
// IsRunning 如果健康检查器当前正在运行,则返回 true。

View File

@ -0,0 +1,196 @@
// Package proxy 提供 HTTP 代理功能。
//
// 该文件实现 HealthMatch 健康检查匹配接口,支持:
// - 默认 2xx 状态码判断
// - 自定义状态码范围匹配
// - 响应体正则匹配
// - 响应头匹配
//
// 主要用途:
//
// 灵活定义后端服务器健康判断逻辑,替代硬编码的 2xx 判断。
//
// 作者xfy
package proxy
import (
"regexp"
"strconv"
"strings"
)
// HealthMatch 定义健康检查匹配接口。
//
// 用于判断健康检查响应是否表示目标健康。
type HealthMatch interface {
// Match 判断健康检查响应是否表示目标健康。
//
// 参数:
// - status: HTTP 状态码
// - body: 响应体内容
// - headers: 响应头key 为小写)
//
// 返回值:
// - true: 目标健康
// - false: 目标不健康
Match(status int, body []byte, headers map[string]string) bool
}
// defaultHealthMatch 默认健康检查匹配器。
//
// 判断逻辑:状态码为 2xx 即健康。
type defaultHealthMatch struct{}
// Match 实现 HealthMatch 接口。
func (m *defaultHealthMatch) Match(status int, body []byte, headers map[string]string) bool {
return status >= 200 && status < 300
}
// customHealthMatch 自定义健康检查匹配器。
//
// 支持状态码范围、响应体正则、响应头匹配。
type customHealthMatch struct {
statusRanges []statusRange // 状态码范围列表
bodyRegex *regexp.Regexp // 响应体正则(可选)
headerMatches []headerMatch // 响应头匹配列表(可选)
}
// statusRange 表示状态码范围。
type statusRange struct {
min int
max int
}
// headerMatch 表示响应头匹配条件。
type headerMatch struct {
key string
value string
}
// Match 实现 HealthMatch 接口。
func (m *customHealthMatch) Match(status int, body []byte, headers map[string]string) bool {
// 1. 检查状态码
if !m.matchStatus(status) {
return false
}
// 2. 检查响应体正则(如果配置)
if m.bodyRegex != nil && !m.bodyRegex.Match(body) {
return false
}
// 3. 检查响应头(如果配置)
for _, hm := range m.headerMatches {
value, exists := headers[hm.key]
if !exists || value != hm.value {
return false
}
}
return true
}
// matchStatus 检查状态码是否匹配任一范围。
func (m *customHealthMatch) matchStatus(status int) bool {
for _, r := range m.statusRanges {
if status >= r.min && status <= r.max {
return true
}
}
return false
}
// HealthMatchConfig 健康检查匹配配置。
type HealthMatchConfig struct {
// Status 状态码范围列表,如 ["200-299", "301"]
Status []string `yaml:"status"`
// Body 响应体正则表达式
Body string `yaml:"body"`
// Headers 响应头匹配,如 {"Content-Type": "application/json"}
Headers map[string]string `yaml:"headers"`
}
// NewHealthMatch 从配置创建健康检查匹配器。
//
// 如果配置为空或无效返回默认匹配器2xx 判断)。
func NewHealthMatch(cfg *HealthMatchConfig) HealthMatch {
if cfg == nil {
return &defaultHealthMatch{}
}
// 解析状态码范围
var ranges []statusRange
for _, s := range cfg.Status {
r, err := parseStatusRange(s)
if err != nil {
continue // 忽略无效范围
}
ranges = append(ranges, r)
}
// 如果没有有效状态码范围,使用默认 2xx
if len(ranges) == 0 {
ranges = []statusRange{{min: 200, max: 299}}
}
// 解析响应体正则
var bodyRegex *regexp.Regexp
if cfg.Body != "" {
bodyRegex = regexp.MustCompile(cfg.Body) // 配置加载时预编译
}
// 解析响应头匹配
var headerMatches []headerMatch
for k, v := range cfg.Headers {
headerMatches = append(headerMatches, headerMatch{
key: strings.ToLower(k), // 统一小写
value: v,
})
}
return &customHealthMatch{
statusRanges: ranges,
bodyRegex: bodyRegex,
headerMatches: headerMatches,
}
}
// parseStatusRange 解析状态码范围字符串。
//
// 支持格式:
// - "200" → 单个状态码
// - "200-299" → 范围
func parseStatusRange(s string) (statusRange, error) {
s = strings.TrimSpace(s)
// 尝试解析范围
if strings.Contains(s, "-") {
parts := strings.Split(s, "-")
if len(parts) != 2 {
return statusRange{}, strconv.ErrSyntax
}
min, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
max, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
if err1 != nil || err2 != nil {
return statusRange{}, strconv.ErrSyntax
}
return statusRange{min: min, max: max}, nil
}
// 单个状态码
code, err := strconv.Atoi(s)
if err != nil {
return statusRange{}, err
}
return statusRange{min: code, max: code}, nil
}
// DefaultHealthMatch 返回默认健康检查匹配器。
func DefaultHealthMatch() HealthMatch {
return &defaultHealthMatch{}
}

View File

@ -0,0 +1,282 @@
package proxy
import (
"testing"
)
func TestDefaultHealthMatch(t *testing.T) {
m := DefaultHealthMatch()
tests := []struct {
status int
want bool
}{
{200, true},
{201, true},
{299, true},
{300, false},
{400, false},
{500, false},
{199, false},
}
for _, tt := range tests {
t.Run("", func(t *testing.T) {
got := m.Match(tt.status, nil, nil)
if got != tt.want {
t.Errorf("Match(%d) = %v, want %v", tt.status, got, tt.want)
}
})
}
}
func TestCustomHealthMatch_StatusRange(t *testing.T) {
cfg := &HealthMatchConfig{
Status: []string{"200-299", "301", "302"},
}
m := NewHealthMatch(cfg)
tests := []struct {
status int
want bool
}{
{200, true},
{250, true},
{299, true},
{301, true},
{302, true},
{300, false}, // 不在范围内
{303, false},
{400, false},
}
for _, tt := range tests {
t.Run("", func(t *testing.T) {
got := m.Match(tt.status, nil, nil)
if got != tt.want {
t.Errorf("Match(%d) = %v, want %v", tt.status, got, tt.want)
}
})
}
}
func TestCustomHealthMatch_BodyRegex(t *testing.T) {
cfg := &HealthMatchConfig{
Status: []string{"200"},
Body: `"status":"ok"`,
}
m := NewHealthMatch(cfg)
tests := []struct {
name string
status int
body string
want bool
}{
{
name: "matching body",
status: 200,
body: `{"status":"ok","data":{}}`,
want: true,
},
{
name: "non-matching body",
status: 200,
body: `{"status":"error"}`,
want: false,
},
{
name: "wrong status",
status: 500,
body: `{"status":"ok"}`,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := m.Match(tt.status, []byte(tt.body), nil)
if got != tt.want {
t.Errorf("Match() = %v, want %v", got, tt.want)
}
})
}
}
func TestCustomHealthMatch_Headers(t *testing.T) {
cfg := &HealthMatchConfig{
Status: []string{"200"},
Headers: map[string]string{
"X-Health": "ok",
},
}
m := NewHealthMatch(cfg)
tests := []struct {
name string
status int
headers map[string]string
want bool
}{
{
name: "matching header",
status: 200,
headers: map[string]string{
"x-health": "ok",
},
want: true,
},
{
name: "missing header",
status: 200,
headers: map[string]string{
"content-type": "application/json",
},
want: false,
},
{
name: "wrong value",
status: 200,
headers: map[string]string{
"x-health": "error",
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := m.Match(tt.status, nil, tt.headers)
if got != tt.want {
t.Errorf("Match() = %v, want %v", got, tt.want)
}
})
}
}
func TestNewHealthMatch_NilConfig(t *testing.T) {
m := NewHealthMatch(nil)
// 应该返回默认匹配器
if !m.Match(200, nil, nil) {
t.Error("nil config should return default matcher")
}
if m.Match(300, nil, nil) {
t.Error("default matcher should not match 300")
}
}
func TestNewHealthMatch_EmptyStatus(t *testing.T) {
cfg := &HealthMatchConfig{
Status: []string{}, // 空
}
m := NewHealthMatch(cfg)
// 应该使用默认 2xx 范围
if !m.Match(200, nil, nil) {
t.Error("empty status should default to 2xx")
}
if m.Match(300, nil, nil) {
t.Error("empty status should default to 2xx, not match 300")
}
}
func TestParseStatusRange(t *testing.T) {
tests := []struct {
input string
min int
max int
wantErr bool
}{
{"200", 200, 200, false},
{"200-299", 200, 299, false},
{" 200-299 ", 200, 299, false},
{"200 - 299", 200, 299, false},
{"abc", 0, 0, true},
{"200-abc", 0, 0, true},
{"200-300-400", 0, 0, true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
r, err := parseStatusRange(tt.input)
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if r.min != tt.min || r.max != tt.max {
t.Errorf("range = {%d, %d}, want {%d, %d}", r.min, r.max, tt.min, tt.max)
}
})
}
}
func TestCustomHealthMatch_Combined(t *testing.T) {
cfg := &HealthMatchConfig{
Status: []string{"200-299"},
Body: `"healthy":true`,
Headers: map[string]string{
"Content-Type": "application/json",
},
}
m := NewHealthMatch(cfg)
tests := []struct {
name string
status int
body string
headers map[string]string
want bool
}{
{
name: "all match",
status: 200,
body: `{"healthy":true,"status":"ok"}`,
headers: map[string]string{
"content-type": "application/json",
},
want: true,
},
{
name: "status mismatch",
status: 400,
body: `{"healthy":true}`,
headers: map[string]string{
"content-type": "application/json",
},
want: false,
},
{
name: "body mismatch",
status: 200,
body: `{"healthy":false}`,
headers: map[string]string{
"content-type": "application/json",
},
want: false,
},
{
name: "header mismatch",
status: 200,
body: `{"healthy":true}`,
headers: map[string]string{
"content-type": "text/plain",
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := m.Match(tt.status, []byte(tt.body), tt.headers)
if got != tt.want {
t.Errorf("Match() = %v, want %v", got, tt.want)
}
})
}
}