feat: validate extraction load workloads
Some checks are pending
ci / proto (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
ci / race (push) Waiting to run
ci / integration (push) Waiting to run

This commit is contained in:
youfak 2026-08-02 09:42:34 +08:00
parent d04b7413eb
commit 97a515ddf0
8 changed files with 358 additions and 74 deletions

View File

@ -198,7 +198,8 @@ go run ./cmd/proxy-loadgen `
```
使用 `-duration 30s -rate 5000` 可运行限速场景;省略 `-rate` 时固定数量 worker
会饱和发送。`-method`、重复的 `-header``-body` 可组合用于 Distribution 的提取接口:
会饱和发送。普通 HTTP 场景中,`-method`、重复的 `-header``-body` 可组合用于
Distribution 的提取接口:
```powershell
go run ./cmd/proxy-loadgen `
@ -208,9 +209,22 @@ go run ./cmd/proxy-loadgen `
-requests 1000 -concurrency 32 -timeout 10s
```
命令输出 JSON 报告,包含成功/失败分类、`tunnelsEstablished`、固定内存的连接握手
延迟分位上界、吞吐和 Go 运行时内存/GC 快照。它尚不包含 Extract 的专用数据准备/
结果校验,也不构成 100,000 QPS 证明。
`extract` 场景则自动构造 POST 请求、每请求独立的 `X-Request-ID`
`Idempotency-Key`,并只校验响应的 `requestId`、数量和同响应内代理 ID 唯一性:
```powershell
go run ./cmd/proxy-loadgen `
-scenario extract `
-target http://CONTROLLER_HOST:8081/api/v1/proxies/extract `
-header "X-API-Key: DISTRIBUTION_API_KEY" `
-requests 1000 -concurrency 32 -timeout 10s `
-extract-count 1 -extract-fulfillment partial
```
命令输出 JSON 报告,包含成功/失败分类、`TunnelsEstablished`、
`ExtractResponsesValidated`、`ExtractReturned`、`ExtractValidationFailures`、固定内存的
连接握手延迟分位上界、吞吐和 Go 运行时内存/GC 快照。它不会输出提取响应中的地址或
凭据,也不构成 100,000 QPS 证明。
## 关键配置与入口

View File

@ -30,8 +30,8 @@ func execute(ctx context.Context, args []string, run loadRun, stdout, stderr io.
flags.SetOutput(stderr)
targetURL := flags.String("target", "", "HTTP or HTTPS target URL")
proxyURL := flags.String("proxy", "", "optional HTTP or HTTPS forward proxy URL; connect requires HTTP")
scenario := flags.String("scenario", string(loadgen.ScenarioHTTP), "workload scenario: http or connect")
method := flags.String("method", http.MethodGet, "HTTP method")
scenario := flags.String("scenario", string(loadgen.ScenarioHTTP), "workload scenario: http, connect, or extract")
method := flags.String("method", "", "HTTP method; defaults to GET or POST for extract")
body := flags.String("body", "", "UTF-8 request body")
requests := flags.Int("requests", 0, "fixed request count; mutually exclusive with -duration")
duration := flags.Duration("duration", 0, "time-boxed workload duration")
@ -39,6 +39,8 @@ func execute(ctx context.Context, args []string, run loadRun, stdout, stderr io.
concurrency := flags.Int("concurrency", 64, "maximum concurrent requests")
timeout := flags.Duration("timeout", 10*time.Second, "HTTP request or CONNECT establishment timeout")
hold := flags.Duration("hold", 0, "CONNECT tunnel hold duration; required by -scenario connect")
extractCount := flags.Int("extract-count", 1, "proxies requested by each extract scenario request")
extractFulfillment := flags.String("extract-fulfillment", "partial", "extract fulfillment: partial or allOrNothing")
var headers headerValues
flags.Var(&headers, "header", "repeatable HTTP header in Name: Value form")
if err := flags.Parse(args); err != nil {
@ -60,6 +62,7 @@ func execute(ctx context.Context, args []string, run loadRun, stdout, stderr io.
Scenario: loadgen.Scenario(*scenario), TargetURL: *targetURL, ProxyURL: *proxyURL, Method: *method, Headers: parsedHeaders, RequestBody: []byte(*body),
Requests: *requests, Duration: *duration, Rate: *rate, Concurrency: *concurrency, RequestTimeout: *timeout,
TunnelHold: *hold,
ExtractCount: *extractCount, ExtractFulfillment: *extractFulfillment,
})
if err != nil {
if errors.Is(err, loadgen.ErrInvalidOptions) {

View File

@ -54,3 +54,19 @@ func TestExecutePassesCONNECTScenario(t *testing.T) {
t.Fatalf("execute() = %d; options=%+v stderr=%q", code, received, stderr.String())
}
}
func TestExecutePassesExtractScenario(t *testing.T) {
var received loadgen.Options
var stdout, stderr bytes.Buffer
code := execute(context.Background(), []string{
"-scenario", "extract", "-target", "http://controller.example:8081/api/v1/proxies/extract",
"-requests", "2", "-concurrency", "1", "-timeout", "2s", "-extract-count", "3", "-extract-fulfillment", "allOrNothing",
}, func(_ context.Context, options loadgen.Options) (loadgen.Report, error) {
received = options
return loadgen.Report{Requests: 2, Completed: 2, Succeeded: 2, Duration: time.Second}, nil
}, &stdout, &stderr)
if code != 0 || received.Scenario != loadgen.ScenarioExtract || received.Method != "" || received.ExtractCount != 3 ||
received.ExtractFulfillment != "allOrNothing" || stderr.Len() != 0 {
t.Fatalf("execute() = %d; options=%+v stderr=%q", code, received, stderr.String())
}
}

View File

@ -299,10 +299,10 @@ Profile 在启用 Routing 与 Upstream 的组合上才进入调度。
补充进度2026-08-02已新增 `proxy-loadgen` HTTP 与 CONNECT 长连接场景。固定请求数
和固定时长两种模式均通过固定 worker 数与有界派发通道执行,可选 QPS 限速;报告使用固定大小
延迟直方图输出状态分类、CONNECT 建立数、吞吐和 Go 内存/GC 快照。CONNECT 以原始 TCP
握手连接 HTTP Gateway建连成功后按 `hold` 保持,且不透明读取隧道内容。通用
`method/header/body` 参数可覆盖 Distribution 提取 HTTP 请求Extract 的专用数据准备与结果
校验、故障注入以及代表性集群报告仍未实现。
延迟直方图输出状态分类、CONNECT 建立数、Extract 校验数、吞吐和 Go 内存/GC 快照。CONNECT
以原始 TCP 握手连接 HTTP Gateway建连成功后按 `hold` 保持,且不透明读取隧道内容。`extract`
场景会自动生成独立 Request/Idempotency 标识,校验返回数量与单响应 ID 唯一性,且不记录地址或
凭据。故障注入以及代表性集群报告仍未实现。
## Task 12: Machine-readable Contracts

View File

@ -100,7 +100,8 @@ Controller/Gateway 入口,完整 mTLS 运行时拓扑仍只有静态验证。
以下已有设计、接口或部署位置,但尚无端到端生产实现:
1. `proxy-loadgen` 已提供有界 HTTP 与 HTTP Gateway CONNECT 长连接进程;`proxy-checker` 的 BASIC 任务进程已经完成,
1. `proxy-loadgen` 已提供有界 HTTP、HTTP Gateway CONNECT 长连接和 Distribution Extract
响应校验场景;`proxy-checker` 的 BASIC 任务进程已经完成,
`proxy-controller` 已完成
Admin/Distribution/Metrics 与 PostgreSQL/Redis 启动装配,`proxy-gateway` 已完成
HTTP/Metrics 与控制面 Session 装配,但 Provider 和业务指标链未闭环。
@ -119,7 +120,7 @@ Controller/Gateway 入口,完整 mTLS 运行时拓扑仍只有静态验证。
原子归并、Controller Reducer 和 Observation 上报 RPC 已完成EGRESS、TARGET
生产任务调度与 REMOVE 编排仍待实现。
8. Admin/Distribution 细粒度授权和审计查询Distribution 分布式限流已完成。
9. Extract 专用场景、真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。
9. 真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。
10. 将 reject/wait/direct 接入 Distribution 运行链,补齐 Sequential 持久化恢复、跨实例 CAS
和 disabled candidate 语义。
11. 补齐 Proxy Capacity 动态降容契约、Reservation 全生命周期观测;短 TTL

View File

@ -7,7 +7,7 @@
| ID | 最终需求 | 来源 | 验证证据 |
|---|---|---|---|
| ARCH-001 | 数据面 Worker 与控制面 Controller 分离 | 1-70 | 包、协议和部署拓扑已分离Controller 已运行 Worker Register/Watch/ACK/Runtime/Outcome 与 Checker Observation gRPC并发布 Proxy/Gateway Routing/按引用去重凭据完整快照Checker 任务流已具备有界领取、租约栅栏和任务期凭据契约。Gateway 已将快照编译为同版本动态 View并由独立进程维护控制面会话。`proxy-checker` 与 Redis BASIC/EGRESS/TARGET 共享任务运行态、`proxy-loadgen` 有界 HTTP/CONNECT 长连接场景均已实现 |
| ARCH-001 | 数据面 Worker 与控制面 Controller 分离 | 1-70 | 包、协议和部署拓扑已分离Controller 已运行 Worker Register/Watch/ACK/Runtime/Outcome 与 Checker Observation gRPC并发布 Proxy/Gateway Routing/按引用去重凭据完整快照Checker 任务流已具备有界领取、租约栅栏和任务期凭据契约。Gateway 已将快照编译为同版本动态 View并由独立进程维护控制面会话。`proxy-checker` 与 Redis BASIC/EGRESS/TARGET 共享任务运行态、`proxy-loadgen` 有界 HTTP/CONNECT/Extract 校验场景均已实现 |
| ARCH-002 | 热路径只做认证、本地路由和网络转发 | 1-70, 380-430 | Gateway bootstrap 集成测试验证启动期控制面会话与快照就绪HTTP 请求只走本地 Snapshot/DispatchOutcome 仅写入有界非阻塞本地队列,代表性性能剖析待完成 |
| ARCH-003 | Gateway、Distribution、Admin、Metrics 独立入口 | 8904-8958 | Controller 命令已装配 Distribution/Admin/Metrics 三个独立监听及联动停机Gateway 命令已装配代理与 Metrics 监听,运行时 mTLS 部署 Overlay 待完成 |
| ARCH-004 | Controller 集中 Provider 获取与切换 | 1403-1580 | Redis Leader、动态 Provider Supervisor 与 Bootstrap 生产装配已完成Admin disable/reload 驱动取消替换,多副本按权威 HMAC 指纹和 revision 栅栏收敛并拒绝旧配置换主Routing 切换到 Drain 的编排待完成 |

View File

@ -6,7 +6,10 @@ import (
"bufio"
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"io"
"math/bits"
@ -14,6 +17,7 @@ import (
"net/http"
"net/url"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
@ -22,13 +26,17 @@ import (
var ErrInvalidOptions = errors.New("invalid load generator options")
const maximumResponseDrainBytes = 64 << 10
const (
maximumResponseDrainBytes = 64 << 10
maximumExtractResponseBytes = 256 << 10
)
type Scenario string
const (
ScenarioHTTP Scenario = "http"
ScenarioConnect Scenario = "connect"
ScenarioExtract Scenario = "extract"
)
// Options bounds one HTTP workload. Requests selects a fixed-size run;
@ -46,6 +54,8 @@ type Options struct {
Concurrency int
RequestTimeout time.Duration
TunnelHold time.Duration
ExtractCount int
ExtractFulfillment string
}
type Report struct {
@ -61,6 +71,9 @@ type Report struct {
TimeoutErrors uint64
RequestErrors uint64
TunnelsEstablished uint64
ExtractResponsesValidated uint64
ExtractReturned uint64
ExtractValidationFailures uint64
Throughput float64
Latency LatencyReport
Runtime RuntimeReport
@ -94,6 +107,9 @@ type counters struct {
timeoutErrors atomic.Uint64
requestErrors atomic.Uint64
tunnelsEstablished atomic.Uint64
extractResponsesValidated atomic.Uint64
extractReturned atomic.Uint64
extractValidationFailures atomic.Uint64
latency latencyHistogram
}
@ -111,13 +127,22 @@ func Run(ctx context.Context, options Options) (Report, error) {
}
stats := &counters{}
var execute func(context.Context)
if normalized.Scenario == ScenarioHTTP {
if normalized.Scenario == ScenarioHTTP || normalized.Scenario == ScenarioExtract {
client, clientErr := newHTTPClient(normalized)
if clientErr != nil {
return Report{}, clientErr
}
defer client.CloseIdleConnections()
if normalized.Scenario == ScenarioHTTP {
execute = func(requestCtx context.Context) { executeHTTP(requestCtx, client, normalized, stats) }
} else {
workloadID, workloadErr := newExtractWorkloadID()
if workloadErr != nil {
return Report{}, workloadErr
}
workload := &extractWorkload{id: workloadID, options: normalized, stats: stats}
execute = func(requestCtx context.Context) { workload.execute(requestCtx, client) }
}
} else {
execute = func(requestCtx context.Context) { executeCONNECT(requestCtx, normalized, stats) }
}
@ -144,8 +169,12 @@ func normalizeOptions(options Options) (Options, error) {
normalized.Scenario = ScenarioHTTP
}
if normalized.Method == "" {
if normalized.Scenario == ScenarioExtract {
normalized.Method = http.MethodPost
} else {
normalized.Method = http.MethodGet
}
}
if strings.TrimSpace(normalized.TargetURL) != normalized.TargetURL || normalized.TargetURL == "" ||
normalized.Concurrency <= 0 || normalized.RequestTimeout <= 0 || normalized.Requests < 0 || normalized.Duration < 0 ||
normalized.Rate < 0 || (normalized.Requests == 0 && normalized.Duration <= 0) ||
@ -156,7 +185,7 @@ func normalizeOptions(options Options) (Options, error) {
if err != nil || target.Scheme == "" || target.Host == "" || (target.Scheme != "http" && target.Scheme != "https") {
return Options{}, ErrInvalidOptions
}
if normalized.Scenario != ScenarioHTTP && normalized.Scenario != ScenarioConnect {
if normalized.Scenario != ScenarioHTTP && normalized.Scenario != ScenarioConnect && normalized.Scenario != ScenarioExtract {
return Options{}, ErrInvalidOptions
}
if normalized.Scenario == ScenarioHTTP {
@ -168,6 +197,20 @@ func normalizeOptions(options Options) (Options, error) {
}
return normalized, nil
}
if normalized.Scenario == ScenarioExtract {
if normalized.ProxyURL != "" || normalized.TunnelHold != 0 || len(normalized.RequestBody) != 0 ||
normalized.Method != http.MethodPost || normalized.ExtractCount <= 0 || normalized.ExtractCount > 64 ||
(normalized.ExtractFulfillment != "" && strings.TrimSpace(normalized.ExtractFulfillment) != normalized.ExtractFulfillment) {
return Options{}, ErrInvalidOptions
}
if normalized.ExtractFulfillment == "" {
normalized.ExtractFulfillment = "partial"
}
if normalized.ExtractFulfillment != "partial" && normalized.ExtractFulfillment != "allOrNothing" {
return Options{}, ErrInvalidOptions
}
return normalized, nil
}
if normalized.ProxyURL == "" || normalized.TunnelHold <= 0 || len(normalized.RequestBody) != 0 || normalized.Method != http.MethodGet {
return Options{}, ErrInvalidOptions
}
@ -307,11 +350,12 @@ func runForDuration(ctx context.Context, options Options, execute func(context.C
}
}
type requestBuilder func(context.Context) (*http.Request, responseValidator, error)
type responseValidator func(*http.Response) error
func executeHTTP(ctx context.Context, client *http.Client, options Options, stats *counters) {
stats.requests.Add(1)
started := time.Now()
requestContext, cancel := context.WithTimeout(ctx, options.RequestTimeout)
defer cancel()
executeHTTPRequest(ctx, client, options.RequestTimeout, stats, func(requestContext context.Context) (*http.Request, responseValidator, error) {
request, err := http.NewRequestWithContext(
requestContext,
options.Method,
@ -319,19 +363,29 @@ func executeHTTP(ctx context.Context, client *http.Client, options Options, stat
bytes.NewReader(options.RequestBody),
)
if err != nil {
stats.failed.Add(1)
stats.requestErrors.Add(1)
stats.completed.Add(1)
stats.latency.Record(time.Since(started))
return
return nil, nil, err
}
request.Header = options.Headers.Clone()
return request, discardResponse, nil
})
}
func executeHTTPRequest(ctx context.Context, client *http.Client, timeout time.Duration, stats *counters, build requestBuilder) {
stats.requests.Add(1)
started := time.Now()
requestContext, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
request, validate, err := build(requestContext)
if err != nil {
recordRequestError(started, stats, false)
return
}
response, err := client.Do(request)
latency := time.Since(started)
stats.latency.Record(latency)
stats.completed.Add(1)
if err != nil {
stats.failed.Add(1)
stats.completed.Add(1)
if errors.Is(err, context.DeadlineExceeded) || errors.Is(requestContext.Err(), context.DeadlineExceeded) {
stats.timeoutErrors.Add(1)
} else {
@ -339,10 +393,18 @@ func executeHTTP(ctx context.Context, client *http.Client, options Options, stat
}
return
}
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maximumResponseDrainBytes))
_ = response.Body.Close()
defer response.Body.Close()
switch {
case response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices:
if validate == nil {
validate = discardResponse
}
if err := validate(response); err != nil {
stats.failed.Add(1)
stats.requestErrors.Add(1)
stats.completed.Add(1)
return
}
stats.succeeded.Add(1)
case response.StatusCode >= http.StatusInternalServerError:
stats.failed.Add(1)
@ -354,6 +416,114 @@ func executeHTTP(ctx context.Context, client *http.Client, options Options, stat
stats.failed.Add(1)
stats.requestErrors.Add(1)
}
stats.completed.Add(1)
}
func discardResponse(response *http.Response) error {
if response == nil || response.Body == nil {
return ErrInvalidOptions
}
_, err := io.Copy(io.Discard, io.LimitReader(response.Body, maximumResponseDrainBytes))
return err
}
type extractWorkload struct {
id string
options Options
stats *counters
next atomic.Uint64
}
func newExtractWorkloadID() (string, error) {
var value [12]byte
if _, err := rand.Read(value[:]); err != nil {
return "", err
}
return "loadgen-" + hex.EncodeToString(value[:]), nil
}
func (workload *extractWorkload) execute(ctx context.Context, client *http.Client) {
sequence := workload.next.Add(1)
requestID := workload.id + "-" + strconv.FormatUint(sequence, 10)
executeHTTPRequest(ctx, client, workload.options.RequestTimeout, workload.stats, func(requestContext context.Context) (*http.Request, responseValidator, error) {
body, err := json.Marshal(struct {
Count int `json:"count"`
Fulfillment string `json:"fulfillment"`
}{Count: workload.options.ExtractCount, Fulfillment: workload.options.ExtractFulfillment})
if err != nil {
return nil, nil, err
}
request, err := http.NewRequestWithContext(requestContext, http.MethodPost, workload.options.TargetURL, bytes.NewReader(body))
if err != nil {
return nil, nil, err
}
request.Header = workload.options.Headers.Clone()
if request.Header == nil {
request.Header = make(http.Header)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Request-ID", requestID)
request.Header.Set("Idempotency-Key", requestID)
return request, func(response *http.Response) error {
return validateExtractResponse(response, requestID, workload.options, workload.stats)
}, nil
})
}
func validateExtractResponse(response *http.Response, requestID string, options Options, stats *counters) error {
if response == nil || response.Body == nil {
stats.extractValidationFailures.Add(1)
return ErrInvalidOptions
}
body, err := io.ReadAll(io.LimitReader(response.Body, maximumExtractResponseBytes+1))
if err != nil || len(body) > maximumExtractResponseBytes {
stats.extractValidationFailures.Add(1)
if err != nil {
return err
}
return ErrInvalidOptions
}
var payload struct {
RequestID string `json:"requestId"`
Requested int `json:"requested"`
Returned int `json:"returned"`
Proxies json.RawMessage `json:"proxies"`
}
if err := json.Unmarshal(body, &payload); err != nil || payload.RequestID != requestID || payload.Requested != options.ExtractCount ||
len(payload.Proxies) == 0 || bytes.Equal(bytes.TrimSpace(payload.Proxies), []byte("null")) ||
(options.ExtractFulfillment == "allOrNothing" && payload.Returned != options.ExtractCount) {
stats.extractValidationFailures.Add(1)
if err != nil {
return err
}
return ErrInvalidOptions
}
var proxies []struct {
ID string `json:"id"`
}
if err := json.Unmarshal(payload.Proxies, &proxies); err != nil || payload.Returned < 0 || payload.Returned > options.ExtractCount ||
payload.Returned != len(proxies) {
stats.extractValidationFailures.Add(1)
if err != nil {
return err
}
return ErrInvalidOptions
}
ids := make(map[string]struct{}, len(proxies))
for _, proxy := range proxies {
if proxy.ID == "" {
stats.extractValidationFailures.Add(1)
return ErrInvalidOptions
}
if _, duplicate := ids[proxy.ID]; duplicate {
stats.extractValidationFailures.Add(1)
return ErrInvalidOptions
}
ids[proxy.ID] = struct{}{}
}
stats.extractResponsesValidated.Add(1)
stats.extractReturned.Add(uint64(payload.Returned))
return nil
}
func executeCONNECT(ctx context.Context, options Options, stats *counters) {
@ -484,7 +654,8 @@ func report(started time.Time, scenario Scenario, stats *counters) Report {
Succeeded: stats.succeeded.Load(), Failed: stats.failed.Load(), Status4xx: stats.status4xx.Load(),
Status5xx: stats.status5xx.Load(), TimeoutErrors: stats.timeoutErrors.Load(), RequestErrors: stats.requestErrors.Load(),
TunnelsEstablished: stats.tunnelsEstablished.Load(),
Latency: stats.latency.Report(completed),
ExtractResponsesValidated: stats.extractResponsesValidated.Load(), ExtractReturned: stats.extractReturned.Load(),
ExtractValidationFailures: stats.extractValidationFailures.Load(), Latency: stats.latency.Report(completed),
Runtime: RuntimeReport{NumCPU: runtime.NumCPU(), Goroutines: runtime.NumGoroutine(), AllocBytes: memory.Alloc, SysBytes: memory.Sys, GCCount: memory.NumGC},
}
if duration > 0 {

View File

@ -3,6 +3,7 @@ package loadgen
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
@ -136,6 +137,74 @@ func TestRunClassifiesCONNECTProxyRejection(t *testing.T) {
}
}
func TestRunExecutesAndValidatesExtractWorkload(t *testing.T) {
var requests atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost || request.Header.Get("Content-Type") != "application/json" ||
request.Header.Get("X-Request-ID") == "" || request.Header.Get("Idempotency-Key") == "" {
t.Errorf("extract request = method=%s content-type=%q request-id=%q idempotency=%q",
request.Method, request.Header.Get("Content-Type"), request.Header.Get("X-Request-ID"), request.Header.Get("Idempotency-Key"))
}
var body struct {
Count int `json:"count"`
Fulfillment string `json:"fulfillment"`
}
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.Count != 2 || body.Fulfillment != "partial" {
t.Errorf("extract body = %+v, error = %v", body, err)
}
requests.Add(1)
response.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(response, `{"requestId":%q,"requested":2,"returned":2,"proxies":[{"id":"proxy-a"},{"id":"proxy-b"}]}`,
request.Header.Get("X-Request-ID"))
}))
defer server.Close()
report, err := Run(context.Background(), Options{
Scenario: ScenarioExtract, TargetURL: server.URL, Requests: 2, Concurrency: 1, RequestTimeout: time.Second,
ExtractCount: 2, ExtractFulfillment: "partial",
})
if err != nil || report.Requests != 2 || report.Completed != 2 || report.Succeeded != 2 || report.Failed != 0 ||
report.ExtractResponsesValidated != 2 || report.ExtractReturned != 4 || report.ExtractValidationFailures != 0 || requests.Load() != 2 {
t.Fatalf("Run() = (%+v, %v); extract requests=%d", report, err, requests.Load())
}
}
func TestRunRejectsInvalidExtractResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
response.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(response, `{"requestId":%q,"requested":2,"returned":2,"proxies":[{"id":"proxy-a"}]}`,
request.Header.Get("X-Request-ID"))
}))
defer server.Close()
report, err := Run(context.Background(), Options{
Scenario: ScenarioExtract, TargetURL: server.URL, Requests: 1, Concurrency: 1, RequestTimeout: time.Second,
ExtractCount: 2, ExtractFulfillment: "partial",
})
if err != nil || report.Succeeded != 0 || report.Failed != 1 || report.ExtractResponsesValidated != 0 ||
report.ExtractValidationFailures != 1 || report.ExtractReturned != 0 {
t.Fatalf("Run() = (%+v, %v)", report, err)
}
}
func TestRunRejectsDuplicateProxyIDsInExtractResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
response.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(response, `{"requestId":%q,"requested":2,"returned":2,"proxies":[{"id":"proxy-a"},{"id":"proxy-a"}]}`,
request.Header.Get("X-Request-ID"))
}))
defer server.Close()
report, err := Run(context.Background(), Options{
Scenario: ScenarioExtract, TargetURL: server.URL, Requests: 1, Concurrency: 1, RequestTimeout: time.Second,
ExtractCount: 2, ExtractFulfillment: "partial",
})
if err != nil || report.Succeeded != 0 || report.Failed != 1 || report.ExtractResponsesValidated != 0 ||
report.ExtractValidationFailures != 1 {
t.Fatalf("Run() = (%+v, %v)", report, err)
}
}
func TestRunRejectsCONNECTWithoutProxyOrHold(t *testing.T) {
_, err := Run(context.Background(), Options{
Scenario: ScenarioConnect, TargetURL: "https://target.example/health", Requests: 1, Concurrency: 1, RequestTimeout: time.Second,
@ -145,6 +214,16 @@ func TestRunRejectsCONNECTWithoutProxyOrHold(t *testing.T) {
}
}
func TestRunRejectsInvalidExtractOptions(t *testing.T) {
_, err := Run(context.Background(), Options{
Scenario: ScenarioExtract, TargetURL: "http://127.0.0.1:8081/api/v1/proxies/extract", Requests: 1, Concurrency: 1,
RequestTimeout: time.Second, ExtractCount: 0,
})
if !errors.Is(err, ErrInvalidOptions) {
t.Fatalf("Run(invalid extract options) error = %v, want ErrInvalidOptions", err)
}
}
func TestRunRejectsUnboundedWorkload(t *testing.T) {
_, err := Run(context.Background(), Options{TargetURL: "http://127.0.0.1:8080", Concurrency: 1})
if !errors.Is(err, ErrInvalidOptions) {