feat: simplify HTTP client proxy pools and authenticated JSON calls
This commit is contained in:
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.21.0-preview(2026-09-26)
|
||||||
|
|
||||||
|
- httpc 可配置环境/直连/固定代理、常用连接阶段超时和独立空闲池参数。
|
||||||
|
- JSON 新增逐请求 Header 快照与响应大小选项,保持有界读取、关闭和认证隔离。
|
||||||
|
- 成功请求少复制和退避计算,丢弃响应流式处理,错误体有界回收以改善连接复用。
|
||||||
|
- 增加真实本地代理、超时、连接限制/回收、并发、日志脱敏、gzip、fuzz 和修改前后基准。
|
||||||
|
- JSON 自定义方法接口签名需同步 variadic 参数;借用 Transport 的参数混配现在报错。详见 httpc/README.md。
|
||||||
|
|
||||||
|
|
||||||
## v0.20.0-preview(2026-09-26)
|
## v0.20.0-preview(2026-09-26)
|
||||||
|
|
||||||
- 生成项目配置加载入口持续由生成器维护;保留业务字段、默认值、校验与 NewLoader 扩展文件。
|
- 生成项目配置加载入口持续由生成器维护;保留业务字段、默认值、校验与 NewLoader 扩展文件。
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 通用业务开发指南(v0.20.0-preview)
|
# 通用业务开发指南(v0.21.0-preview)
|
||||||
|
|
||||||
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
|
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
|
||||||
|
|
||||||
|
|||||||
28
MODULES.md
28
MODULES.md
@@ -70,30 +70,26 @@ application.OnShutdown(client.Close)
|
|||||||
|
|
||||||
```go
|
```go
|
||||||
client, err := httpc.New(httpc.Config{
|
client, err := httpc.New(httpc.Config{
|
||||||
Timeout: 3*time.Second, MaxRetries: 2, MaxConnsPerHost: 32,
|
Proxy: httpc.ProxyConfig{Mode: httpc.ProxyDirect},
|
||||||
MaxResponseBytes: 1 << 20,
|
Timeout: 3*time.Second, DialTimeout: time.Second,
|
||||||
|
TLSHandshakeTimeout: time.Second, ResponseHeaderTimeout: time.Second,
|
||||||
|
MaxConnsPerHost: 32, MaxIdleConnsPerHost: 16, MaxIdleConns: 64,
|
||||||
|
IdleConnTimeout: 60*time.Second, MaxResponseBytes: 1<<20,
|
||||||
})
|
})
|
||||||
if err != nil { return err }
|
if err != nil { return err }
|
||||||
application.OnShutdown(client.Close)
|
application.OnShutdown(client.Close)
|
||||||
err = client.JSON(ctx, "GET", serviceURL, nil, &result)
|
err = client.JSON(ctx, "GET", serviceURL, nil, &result,
|
||||||
|
httpc.WithHeader("Authorization", "Bearer "+token),
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
客户端应在服务启动时创建并复用。默认使用独立的 `net/http.Transport` 连接池,每个目标主机
|
启动时创建并复用 Client。代理可选环境、直连、固定地址;不配置时保留标准库原代理策略。JSON 支持单次请求头和响应限额,不保存 Client 全局 Token。新阶段超时与空闲连接参数可选,0 沿用默认。
|
||||||
有连接上限;该上限不是整个进程的全局连接数上限。也可以注入自有 `http.RoundTripper`,
|
|
||||||
其连接限制和生命周期由调用方负责。这样出站实现同样可以替换。
|
|
||||||
|
|
||||||
总超时覆盖建连、重试、等待和读取响应体。`Do` 返回后调用方必须读取/关闭 `response.Body`;
|
成功响应读取有界;output=nil 流式丢弃,不整体缓存。小错误体只在大小/时间预算内尝试读完以复用连接;未知或大错误体及时关闭。Do 保留标准流式 Request/Response,由调用者限量读取并关闭 body。Close 拒绝新调用并关闭自有池空闲连接,项目先排空在途工作。
|
||||||
`JSON` 自动关闭,并限制解码前读取的字节数。`Do` 允许流式读取,本身不限制响应体大小。
|
|
||||||
`Close` 拒绝新请求、关闭自有池的空闲连接;在途请求应先排空。
|
|
||||||
|
|
||||||
`MaxRetries` 默认 0,最多 5。封装只对可重放的 GET/HEAD/OPTIONS 重试网络错误及
|
自定义 Transport 借用、不修改、不关闭;不能同时指定池/代理/阶段选项。总超时持续覆盖 HTTP 等待、重试和读取 body,但不抢占 JSON 编解码或不遵守 context 的自定义代码。MaxRetries 默认 0,只对可重放 GET/HEAD/OPTIONS 重试;3xx 不跟随。标准库和借用 Transport 自身仍可能重放,不能据此承诺业务只执行一次。
|
||||||
429/502/503/504;POST/PUT/PATCH/DELETE 不做应用层自动重试。标准库 Transport 和借用的
|
|
||||||
RoundTripper 自身的重放规则仍生效,不能据此承诺“只执行一次”。退避带抖动,尊重
|
|
||||||
`Retry-After`;服务器要求等待超过 `MaxBackoff` 时直接返回该响应,不提前重试。
|
|
||||||
重定向交给业务处理,3xx 不会自动跟随。
|
|
||||||
|
|
||||||
默认从 context 传递 `X-Request-ID`,日志只包含方法、主机、状态、尝试次数和收到响应头的耗时,
|
完整参数、NO_PROXY、TLS、安全日志、重试、签名兼容说明见 [httpc 指南](httpc/README.md),可运行 [HTTP Client 示例](examples/httpClient/README.md)。
|
||||||
不记录完整 URL、查询参数、凭证和响应体。非 2xx 的 JSON 调用返回只带状态码的 `StatusError`。
|
|
||||||
|
|
||||||
## 请求保护:middleware
|
## 请求保护:middleware
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 生成项目与文件维护边界
|
# 生成项目与文件维护边界
|
||||||
|
|
||||||
v0.20.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
|
v0.21.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
|
||||||
|
|
||||||
## 创建和日常开发
|
## 创建和日常开发
|
||||||
|
|
||||||
@@ -215,7 +215,7 @@ get /download (DownloadReq)
|
|||||||
|
|
||||||
## 已有服务接入步骤
|
## 已有服务接入步骤
|
||||||
|
|
||||||
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.20.0-preview:
|
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.21.0-preview:
|
||||||
|
|
||||||
1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。
|
1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。
|
||||||
2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。
|
2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。
|
||||||
|
|||||||
35
README.md
35
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
|
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
|
||||||
|
|
||||||
**从这份 README 开始接入。** 当前版本 **`v0.20.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
|
**从这份 README 开始接入。** 当前版本 **`v0.21.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
|
||||||
|
|
||||||
**v0.19 改动**:新增可选 `copyx` 工具,用普通 Go 调用完成浅拷贝、深拷贝和显式类型转换;深拷贝会拒绝自定义转换中残留的源对象可变引用。**移除了 `mappings` / `mappingFiles` 和映射代码生成**,已有使用者须按 [迁移步骤](COPY.md#从旧-mapping-迁移) 调整,不能直接删除仍在调用的旧文件。
|
**v0.19 改动**:新增可选 `copyx` 工具,用普通 Go 调用完成浅拷贝、深拷贝和显式类型转换;深拷贝会拒绝自定义转换中残留的源对象可变引用。**移除了 `mappings` / `mappingFiles` 和映射代码生成**,已有使用者须按 [迁移步骤](COPY.md#从旧-mapping-迁移) 调整,不能直接删除仍在调用的旧文件。
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
|
|||||||
| 可选 GORM 与迁移 | gormx 复用已有连接池;`go-omni migrate` 预览,显式 apply | [GORM 示例](database/gormx/README.md)、[迁移用法](ADVANCED.md#迁移审计与分页) |
|
| 可选 GORM 与迁移 | gormx 复用已有连接池;`go-omni migrate` 预览,显式 apply | [GORM 示例](database/gormx/README.md)、[迁移用法](ADVANCED.md#迁移审计与分页) |
|
||||||
| 认证与权限接口 | 注入 Authenticator / Policy;JWT 或 Session 只是可选实现 | [认证说明](auth/README.md)、[鉴权完整例子](examples/platform/README.md) |
|
| 认证与权限接口 | 注入 Authenticator / Policy;JWT 或 Session 只是可选实现 | [认证说明](auth/README.md)、[鉴权完整例子](examples/platform/README.md) |
|
||||||
| 缓存 | `cache.NewMemory` 或 Redis 实现;业务面向 Store 的 Get/Set/Delete | [缓存配置/代码](MODULES.md)、[可运行模块服务](examples/modules/README.md) |
|
| 缓存 | `cache.NewMemory` 或 Redis 实现;业务面向 Store 的 Get/Set/Delete | [缓存配置/代码](MODULES.md)、[可运行模块服务](examples/modules/README.md) |
|
||||||
| 出站 HTTP、熔断 | `httpc.New` 复用连接池与截止时间;`resilience.New` 隔离故障依赖 | [HTTP 用法](MODULES.md)、[熔断/观测](ADVANCED.md#熔断与观测) |
|
| 出站 HTTP、熔断 | `httpc.New` 配置代理、连接池和阶段超时;JSON 支持单次请求头/限额;`resilience.New` 隔离故障 | [HTTP 完整用法](httpc/README.md)、[可运行示例](examples/httpClient/README.md)、[熔断/观测](ADVANCED.md#熔断与观测) |
|
||||||
| 请求追踪、访问日志、恢复、限流、幂等、超时 | `app.Use(...)` 按需组合 middleware;跨副本选择 Redis 实现 | [模块说明与限制](MODULES.md#请求追踪与基础指标)、[分布式实现](ADVANCED.md#分布式限流与幂等) |
|
| 请求追踪、访问日志、恢复、限流、幂等、超时 | `app.Use(...)` 按需组合 middleware;跨副本选择 Redis 实现 | [模块说明与限制](MODULES.md#请求追踪与基础指标)、[分布式实现](ADVANCED.md#分布式限流与幂等) |
|
||||||
| 本地后台任务与调度 | `task.New`、Submit、Every;有界队列、排空关闭 | [task 示例与生命周期](MODULES.md#后台执行task) |
|
| 本地后台任务与调度 | `task.New`、Submit、Every;有界队列、排空关闭 | [task 示例与生命周期](MODULES.md#后台执行task) |
|
||||||
| 持久队列 | 业务使用 Publisher / Consumer;Redis Streams 提供至少一次交付 | [队列初始化/消费示例](ADVANCED.md#可靠队列) |
|
| 持久队列 | 业务使用 Publisher / Consumer;Redis Streams 提供至少一次交付 | [队列初始化/消费示例](ADVANCED.md#可靠队列) |
|
||||||
@@ -54,6 +54,31 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
|
|||||||
| 可选结构转换与深拷贝 | `copyx.Copy` / `Clone` / `New`;Go converter 处理差异 | [下方示例](#可选结构转换与深拷贝)、[完整 copyx 指南](COPY.md)、[可运行代码](examples/copying) |
|
| 可选结构转换与深拷贝 | `copyx.Copy` / `Clone` / `New`;Go converter 处理差异 | [下方示例](#可选结构转换与深拷贝)、[完整 copyx 指南](COPY.md)、[可运行代码](examples/copying) |
|
||||||
| 自定义适配器验收 | `transporttest.Run`、`storagetest.Run` / `RunUploads` 验证扩展契约 | [网络契约测试入口](transport/transporttest/router.go)、[存储契约测试入口](storage/storagetest/contract.go) |
|
| 自定义适配器验收 | `transporttest.Run`、`storagetest.Run` / `RunUploads` 验证扩展契约 | [网络契约测试入口](transport/transporttest/router.go)、[存储契约测试入口](storage/storagetest/contract.go) |
|
||||||
|
|
||||||
|
## 带认证的出站 HTTP
|
||||||
|
|
||||||
|
HTTP Client 在 svc 初始化一次,业务重复使用。普通内部请求可直接选择直连和连接参数,无需手写 Transport:
|
||||||
|
|
||||||
|
```go
|
||||||
|
client, err := httpc.New(httpc.Config{
|
||||||
|
Proxy: httpc.ProxyConfig{Mode: httpc.ProxyDirect},
|
||||||
|
Timeout: 3*time.Second, DialTimeout: time.Second,
|
||||||
|
TLSHandshakeTimeout: time.Second, ResponseHeaderTimeout: time.Second,
|
||||||
|
MaxConnsPerHost: 32, MaxIdleConnsPerHost: 16, MaxIdleConns: 64,
|
||||||
|
})
|
||||||
|
if err != nil { return err }
|
||||||
|
// 在 svc 生命周期登记 client.Close;关闭前先排空使用它的工作。
|
||||||
|
err = client.JSON(ctx, "GET", endpoint, nil, &result,
|
||||||
|
httpc.WithHeader("Authorization", "Bearer "+token),
|
||||||
|
httpc.WithMaxResponseBytes(64<<10),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
请求头只作用于本次调用,不把用户 Token 留在共享 Client。默认代理/参数保持原行为;也可选择环境代理或固定代理。JSON 自动关闭并限制响应,Do 保留下载/流式读取扩展。完整配置、重试边界和已有自定义 Transport 的迁移见 [httpc](httpc/README.md)。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./examples/httpClient
|
||||||
|
```
|
||||||
|
|
||||||
## 统一配置加载
|
## 统一配置加载
|
||||||
|
|
||||||
新生成项目的加载流程由框架管理。业务在 `internal/config/config.go` 增加字段、默认值和 `Validate`,无需改 main 或复制环境变量解析。JSON、YAML、XML 共用字段和校验;扩展格式在 `loader.go` 注册。
|
新生成项目的加载流程由框架管理。业务在 `internal/config/config.go` 增加字段、默认值和 `Validate`,无需改 main 或复制环境变量解析。JSON、YAML、XML 共用字段和校验;扩展格式在 `loader.go` 注册。
|
||||||
@@ -97,7 +122,7 @@ module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
|
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
|
||||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.20.0-preview
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.21.0-preview
|
||||||
export PATH="$(go env GOPATH)/bin:$PATH"
|
export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
go-omni --help
|
go-omni --help
|
||||||
```
|
```
|
||||||
@@ -254,7 +279,7 @@ go-omni new --api catalog.api \
|
|||||||
在业务项目的 **go.mod 所在目录**执行:
|
在业务项目的 **go.mod 所在目录**执行:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go get git.yuntuo.hk/k2safe/go-omni@v0.20.0-preview
|
go get git.yuntuo.hk/k2safe/go-omni@v0.21.0-preview
|
||||||
```
|
```
|
||||||
|
|
||||||
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
|
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
|
||||||
@@ -556,7 +581,7 @@ go test ./...
|
|||||||
|
|
||||||
`main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、`omni.gen.json`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。
|
`main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、`omni.gen.json`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。
|
||||||
|
|
||||||
CLI 与业务 module 固定使用 `v0.20.0-preview`。v0.14–v0.19 项目先按 [配置入口迁移](CONFIG.md#v019-及更早项目迁移) 整理旧 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
CLI 与业务 module 固定使用 `v0.21.0-preview`。v0.14–v0.19 项目先按 [配置入口迁移](CONFIG.md#v019-及更早项目迁移) 整理旧 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
||||||
|
|
||||||
## 按需深入
|
## 按需深入
|
||||||
|
|
||||||
|
|||||||
23
RELEASE.md
23
RELEASE.md
@@ -1,4 +1,25 @@
|
|||||||
# v0.20.0-preview
|
# v0.21.0-preview
|
||||||
|
|
||||||
|
让带认证的 HTTP 调用和内部直连可以直接使用 httpc,减少手写 Transport 与 JSON 响应处理。
|
||||||
|
|
||||||
|
- Proxy 支持环境、直连、固定代理;默认保留既有策略。
|
||||||
|
- 建连、TCP keepalive、TLS 握手、响应头、100-continue、空闲超时和独立空闲连接数量可选配置;不修改全局 Transport,不关闭借用实例。
|
||||||
|
- JSON 支持 WithHeader / WithHeaders / WithMaxResponseBytes;认证信息逐请求隔离,自动关闭和有界响应处理保留。
|
||||||
|
- 成功路径减少请求复制和无用退避;output=nil 按限额流式丢弃;小错误体在大小和时间预算内尝试复用连接。
|
||||||
|
- 不新增依赖、不增加业务规则;Do 保留标准流式扩展。CLI 最新版本与模板最低版本分开,本轮模板兼容门槛仍是 v0.20。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get git.yuntuo.hk/k2safe/go-omni@v0.21.0-preview
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.21.0-preview
|
||||||
|
```
|
||||||
|
|
||||||
|
**兼容注意**:JSON 旧的直接调用仍有效,自定义 interface/函数值需要同步新增的 variadic 参数。自定义 Transport 与显式连接/代理/阶段参数混用现在报错,避免参数被忽略;迁移时在自己的 Transport 配置,或改用内置字段。没有删除原调用入口。
|
||||||
|
|
||||||
|
[参数与使用说明](httpc/README.md)、[可运行示例](examples/httpClient/README.md)、[场景与性能验收](reports/httpcAcceptance.md) 已同步到 README。48 个测试包全量 race、Go 1.22 兼容、vet/build、约 110 万次请求头 fuzz 通过。性能来自本机同条件基准,不能代替项目实际流量验证;仍为 preview。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v0.20.0-preview(历史版本)
|
||||||
|
|
||||||
统一配置加载,减少生成项目重复维护的启动代码。
|
统一配置加载,减少生成项目重复维护的启动代码。
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 仓库读取权限与 PATH 配置见根目录 README
|
# 仓库读取权限与 PATH 配置见根目录 README
|
||||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.20.0-preview
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.21.0-preview
|
||||||
go-omni new --module example.com/hello ./hello
|
go-omni new --module example.com/hello ./hello
|
||||||
|
|
||||||
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
|
||||||
|
// Runtime-only releases need not raise the managed template compatibility floor.
|
||||||
const minimumVersion = "v0.20.0-preview"
|
const minimumVersion = "v0.20.0-preview"
|
||||||
|
const currentVersion = "v0.21.0-preview"
|
||||||
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
|
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
@@ -265,7 +268,7 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
|||||||
return Output{}, errors.New("generation configuration changed during generation; retry")
|
return Output{}, errors.New("generation configuration changed during generation; retry")
|
||||||
}
|
}
|
||||||
out, err := apply(directory, o.Module, entry, generated, seeds, o.Check, o.DryRun, o.profile(), snapshotContract(c.Spec), o.lockHeld)
|
out, err := apply(directory, o.Module, entry, generated, seeds, o.Check, o.DryRun, o.profile(), snapshotContract(c.Spec), o.lockHeld)
|
||||||
expectedVersion := minimumVersion
|
expectedVersion := currentVersion
|
||||||
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Path == frameworkModule && info.Main.Sum != "" && semver.IsValid(info.Main.Version) {
|
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Path == frameworkModule && info.Main.Sum != "" && semver.IsValid(info.Main.Version) {
|
||||||
expectedVersion = info.Main.Version
|
expectedVersion = info.Main.Version
|
||||||
}
|
}
|
||||||
@@ -304,7 +307,7 @@ func moduleFile(o Options) ([]byte, error) {
|
|||||||
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Path == frameworkModule && info.Main.Sum != "" {
|
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Path == frameworkModule && info.Main.Sum != "" {
|
||||||
version = info.Main.Version
|
version = info.Main.Version
|
||||||
} else {
|
} else {
|
||||||
version = minimumVersion
|
version = currentVersion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !semver.IsValid(version) || semver.Major(version) != "v0" && semver.Major(version) != "v1" {
|
if !semver.IsValid(version) || semver.Major(version) != "v0" && semver.Major(version) != "v1" {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func oldInfrastructureProject(t *testing.T) string {
|
|||||||
raw, _ := json.Marshal(m)
|
raw, _ := json.Marshal(m)
|
||||||
writeFile(t, filepath.Join(dir, manifestPath), string(raw))
|
writeFile(t, filepath.Join(dir, manifestPath), string(raw))
|
||||||
path := filepath.Join(dir, "go.mod")
|
path := filepath.Join(dir, "go.mod")
|
||||||
writeFile(t, path, strings.ReplaceAll(readFile(t, path), minimumVersion, "v0.13.0-preview"))
|
writeFile(t, path, strings.ReplaceAll(readFile(t, path), currentVersion, "v0.13.0-preview"))
|
||||||
return dir
|
return dir
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ func TestUpgradeV014PreservesExtensions(t *testing.T) {
|
|||||||
raw, _ := json.Marshal(m)
|
raw, _ := json.Marshal(m)
|
||||||
writeFile(t, filepath.Join(dir, manifestPath), string(raw))
|
writeFile(t, filepath.Join(dir, manifestPath), string(raw))
|
||||||
mod := filepath.Join(dir, "go.mod")
|
mod := filepath.Join(dir, "go.mod")
|
||||||
writeFile(t, mod, strings.ReplaceAll(readFile(t, mod), minimumVersion, "v0.14.1-preview"))
|
writeFile(t, mod, strings.ReplaceAll(readFile(t, mod), currentVersion, "v0.14.1-preview"))
|
||||||
owned := map[string]string{}
|
owned := map[string]string{}
|
||||||
for _, name := range []string{"internal/svc/serviceContext.go", "internal/config/config.go", "etc/config.yaml"} {
|
for _, name := range []string{"internal/svc/serviceContext.go", "internal/config/config.go", "etc/config.yaml"} {
|
||||||
path := filepath.Join(dir, name)
|
path := filepath.Join(dir, name)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
||||||
for _, flag := range [][]string{{"--framework-version", "v0.20.0-preview"}, {"--framework-version=v0.20.0-preview"}} {
|
for _, flag := range [][]string{{"--framework-version", "v0.21.0-preview"}, {"--framework-version=v0.21.0-preview"}} {
|
||||||
project := filepath.Join(t.TempDir(), "billing")
|
project := filepath.Join(t.TempDir(), "billing")
|
||||||
args := append([]string{"--module", "example.com/billing"}, flag...)
|
args := append([]string{"--module", "example.com/billing"}, flag...)
|
||||||
if err := runNew(append(args, project)); err != nil {
|
if err := runNew(append(args, project)); err != nil {
|
||||||
@@ -18,7 +18,7 @@ func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(content), "go-omni v0.20.0-preview") || strings.Contains(string(content), "replace ") {
|
if !strings.Contains(string(content), "go-omni v0.21.0-preview") || strings.Contains(string(content), "replace ") {
|
||||||
t.Fatalf("CLI did not pin remote dependency: %s", content)
|
t.Fatalf("CLI did not pin remote dependency: %s", content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
|||||||
// Source builds can carry an unpublished VCS version (including +dirty).
|
// Source builds can carry an unpublished VCS version (including +dirty).
|
||||||
// Keep this fallback in sync with the next release; installed CLIs use their
|
// Keep this fallback in sync with the next release; installed CLIs use their
|
||||||
// downloaded module version, which also has a module checksum.
|
// downloaded module version, which also has a module checksum.
|
||||||
const defaultFrameworkVersion = "v0.20.0-preview"
|
const defaultFrameworkVersion = "v0.21.0-preview"
|
||||||
|
|
||||||
// This target is not a claim that the release already exists. Source users
|
// This target is not a claim that the release already exists. Source users
|
||||||
// select a local framework checkout until the module templates are published.
|
// select a local framework checkout until the module templates are published.
|
||||||
|
|||||||
12
examples/httpClient/README.md
Normal file
12
examples/httpClient/README.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# HTTP Client 示例
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./examples/httpClient
|
||||||
|
```
|
||||||
|
|
||||||
|
只创建本机临时 HTTP 服务,不访问业务系统、数据库或公网;结束后关闭连接和监听器。
|
||||||
|
示例创建并复用一个 Client,显式直连,配置连接池/阶段超时,通过 JSON 选项传入 Authorization 和 Accept,并按单次请求收紧响应上限。
|
||||||
|
|
||||||
|
输出 `authenticated request succeeded`。示例 token 是固定演示字符串,实际项目从配置或请求上下文取得认证信息。
|
||||||
|
|
||||||
|
完整参数、代理规则、流式调用、生命周期、重试边界和兼容说明见 [httpc](../../httpc/README.md)。
|
||||||
57
examples/httpClient/main.go
Normal file
57
examples/httpClient/main.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// Run with go run ./examples/httpClient. All HTTP traffic stays on loopback.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Authorization") != "Bearer example-token" {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(result{Message: "authenticated request succeeded"})
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
client, err := httpc.New(httpc.Config{
|
||||||
|
Proxy: httpc.ProxyConfig{Mode: httpc.ProxyDirect},
|
||||||
|
Timeout: 3 * time.Second, DialTimeout: time.Second, TLSHandshakeTimeout: time.Second,
|
||||||
|
ResponseHeaderTimeout: time.Second, IdleConnTimeout: 30 * time.Second,
|
||||||
|
MaxConnsPerHost: 16, MaxIdleConnsPerHost: 8, MaxIdleConns: 32,
|
||||||
|
MaxResponseBytes: 1 << 20, DisableLogging: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
var output result
|
||||||
|
if err = client.JSON(context.Background(), http.MethodGet, upstream.URL, nil, &output,
|
||||||
|
httpc.WithHeader("Authorization", "Bearer example-token"),
|
||||||
|
httpc.WithHeaders(http.Header{"Accept": []string{"application/json"}}),
|
||||||
|
httpc.WithMaxResponseBytes(64<<10),
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println(output.Message)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
9
examples/httpClient/main_test.go
Normal file
9
examples/httpClient/main_test.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestExample(t *testing.T) {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ module example.com/managed
|
|||||||
|
|
||||||
go 1.22.0
|
go 1.22.0
|
||||||
|
|
||||||
require git.yuntuo.hk/k2safe/go-omni v0.20.0-preview
|
require git.yuntuo.hk/k2safe/go-omni v0.21.0-preview
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
|
|||||||
153
httpc/README.md
Normal file
153
httpc/README.md
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
# httpc:复用连接的 HTTP 客户端
|
||||||
|
|
||||||
|
`httpc` 在标准 `net/http` 上提供独立连接池、网络阶段总超时、保守重试、请求追踪和有界 JSON 调用。普通接口可以直接带认证头调用;上传、下载、特殊协议仍使用标准 Request/Response。没有业务身份模型或全局认证信息。
|
||||||
|
|
||||||
|
## 创建一次,业务重复使用
|
||||||
|
|
||||||
|
```go
|
||||||
|
client, err := httpc.New(httpc.Config{
|
||||||
|
Proxy: httpc.ProxyConfig{Mode: httpc.ProxyDirect},
|
||||||
|
Timeout: 3 * time.Second,
|
||||||
|
DialTimeout: time.Second,
|
||||||
|
TLSHandshakeTimeout: time.Second,
|
||||||
|
ResponseHeaderTimeout: time.Second,
|
||||||
|
MaxConnsPerHost: 32,
|
||||||
|
MaxIdleConnsPerHost: 16,
|
||||||
|
MaxIdleConns: 64,
|
||||||
|
IdleConnTimeout: 60 * time.Second,
|
||||||
|
MaxResponseBytes: 1 << 20,
|
||||||
|
})
|
||||||
|
if err != nil { return err }
|
||||||
|
// 在 svc 初始化并持有 client,向生命周期登记 client.Close。
|
||||||
|
// 普通独立程序也可 defer client.Close();不要每次请求重新 New。
|
||||||
|
|
||||||
|
err = client.JSON(ctx, http.MethodGet, serviceURL, nil, &result,
|
||||||
|
httpc.WithHeader("Authorization", "Bearer "+token),
|
||||||
|
httpc.WithHeaders(http.Header{"Accept": {"application/json"}}),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Client 可并发使用。每个调用的 input/output 及其读写同步由调用方负责,不要让多个请求同时写同一个 output。
|
||||||
|
|
||||||
|
直接运行 [examples/httpClient](../examples/httpClient/README.md),只使用本地临时服务,无需业务 Token 或外部基础设施。
|
||||||
|
|
||||||
|
## 代理规则
|
||||||
|
|
||||||
|
| 配置 | 行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| 不填写 Proxy | 保留被克隆 DefaultTransport 的代理策略;未改动的标准库默认使用环境代理 |
|
||||||
|
| `Mode: httpc.ProxyEnvironment` | 明确使用 HTTP_PROXY / HTTPS_PROXY / NO_PROXY(及小写形式) |
|
||||||
|
| `Mode: httpc.ProxyDirect` | 设置 Proxy=nil,明确直连,不受环境代理影响 |
|
||||||
|
| `Mode: httpc.ProxyURL, URL: "http://proxy.example:8080"` | 固定代理地址,不通过 NO_PROXY 排除 |
|
||||||
|
|
||||||
|
支持 http、https、socks5、socks5h;底层行为沿用 Go Transport。固定地址必须是代理 origin,不接受业务路径、查询、fragment 或无效端口。凭证可由 URL userinfo 提供,但配置错误不输出 URL/密码;不要打印整个 Config。环境代理规则由标准库读取并缓存,不提供运行时环境代理热更新。
|
||||||
|
|
||||||
|
内部服务要直连,只设置 `proxy.mode: direct`,无需创建 Transport。需要自定义路由、代理认证握手等复杂逻辑时注入自己的 Transport。
|
||||||
|
|
||||||
|
## 超时和连接池
|
||||||
|
|
||||||
|
除原有字段外,新字段都可选:**0 保留默认行为,正数覆盖,负数拒绝**。需要明确关闭某个阶段超时、关闭 TCP keepalive 或特殊 dialer 时,使用自定义 Transport。
|
||||||
|
|
||||||
|
| 字段 | 默认/含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| Timeout | 10s;从 Do 开始,覆盖 DNS/建连、等待连接、重试、退避、响应头和读取响应体 |
|
||||||
|
| DialTimeout | 不填时保留原 dialer;标准库默认 30s;配置后使用标准 net.Dialer |
|
||||||
|
| DialKeepAlive | 标准 net.Dialer 的 TCP keepalive 探测间隔,标准默认 30s;不是 HTTP 空闲连接超时 |
|
||||||
|
| TLSHandshakeTimeout | 沿用克隆 Transport;标准库默认 10s |
|
||||||
|
| ResponseHeaderTimeout | 沿用 Transport,标准默认不单独限制;从完整发送请求后等响应头,总 Timeout 仍生效 |
|
||||||
|
| ExpectContinueTimeout | 沿用 Transport;标准默认 1s,等待 `100-continue` 的时间 |
|
||||||
|
| IdleConnTimeout | 沿用 Transport;标准默认 90s,只回收空闲连接 |
|
||||||
|
| MaxConnsPerHost | 默认 64;每个目标的建连中、活动与空闲连接总上限 |
|
||||||
|
| MaxIdleConnsPerHost | 默认等于 MaxConnsPerHost,可单独配置 |
|
||||||
|
| MaxIdleConns | 默认 MaxConnsPerHost×2,可单独配置;所有目标合计的空闲连接上限 |
|
||||||
|
| MaxResponseBytes | JSON 成功响应默认最多 1 MiB,最大允许配置 1 GiB;应按实际接口保持较小限额 |
|
||||||
|
|
||||||
|
每主机上限最多 65536,总空闲连接最多 131072。总空闲上限可以小于各主机上限之和。MaxConnsPerHost 不等于整个进程的连接上限,也不等于 HTTP/2 的请求并发上限。
|
||||||
|
|
||||||
|
配置 DialTimeout / DialKeepAlive 会创建标准 net.Dialer;未填的另一个值使用标准默认 30s。不要通过全局修改 DefaultTransport 来塞入项目的特殊拨号逻辑;请显式使用借用的 Transport。若 DefaultTransport 有自定义 TLS dialer,相关阶段覆盖会报错,避免 TLS timeout 被悄悄忽略。
|
||||||
|
|
||||||
|
调用者 context 的更早截止时间优先。总超时不能抢占 Go 的 JSON 编解码或不遵守 context 的自定义 RoundTripper;计时从 Do 开始,不包括调用前的 json.Marshal。TLS 校验默认保持开启,没有新增 InsecureSkipVerify 配置。
|
||||||
|
|
||||||
|
## JSON 的单次请求选项
|
||||||
|
|
||||||
|
```go
|
||||||
|
err := client.JSON(ctx, "POST", endpoint, input, &output,
|
||||||
|
httpc.WithHeader("Authorization", "Bearer "+token),
|
||||||
|
httpc.WithHeader("X-Tenant", tenant),
|
||||||
|
httpc.WithMaxResponseBytes(64<<10),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `WithHeader` 设置一个头;`WithHeaders` 支持多值 http.Header。后出现的选项覆盖同名头,不影响其他头。
|
||||||
|
- WithHeaders 在创建选项时快照原 map,每次调用复制值。构建后修改原 map 不影响选项,选项可以并发复用;构建时原 map 的读写同步由调用方负责。
|
||||||
|
- 不提供 Client 全局 Authorization,避免跨用户调用共享凭证。框架不会把请求头写进日志。
|
||||||
|
- 有 input 时默认 Content-Type 为 application/json;显式请求头可覆盖。请求头名称/值会校验,拒绝 CRLF 等控制字符和歧义的大小写重复键。
|
||||||
|
- Host、Content-Length、Transfer-Encoding、Trailer 属于请求目标/分帧控制,请用标准 Request 配合 Do;JSON 选项中不允许伪装成普通头。
|
||||||
|
- `WithMaxResponseBytes` 是本次 JSON 的正数限额,不修改 Client 默认值,不改变 Do 的流式语义。
|
||||||
|
|
||||||
|
JSON 自动关闭响应体。仅 2xx 解码;空成功响应(例如 204)保留 output 原值。非空输入使用标准 json.Unmarshal,拒绝多个 JSON 文档,类型错误保留 Go 的错误信息与行为。
|
||||||
|
|
||||||
|
`output == nil` 按限额流式丢弃成功响应,不把整个 body 读进内存,也不检查 JSON 语法。响应限额按实际读取的 body 计算;标准库自动 gzip 解压后仍受上限约束。input 会先 json.Marshal 到内存,大文件上传不适合用 JSON helper。
|
||||||
|
|
||||||
|
非 2xx 返回 `*httpc.StatusError`,只包含状态码,不包含错误响应体。为连接复用,只尝试读完**声明长度已知且不超过 min(32 KiB, 本次响应限额)** 的小错误体,最多等待 25ms(或更早总截止时间);失败仍返回原 HTTP 状态。未知长度/大错误体立即关闭,优先及时返回。重试过程的错误体另受 32 KiB 大小及整体请求截止时间约束,不无界读取。
|
||||||
|
|
||||||
|
## 配置文件与生成项目
|
||||||
|
|
||||||
|
在项目 Config 中加一个字段:
|
||||||
|
|
||||||
|
```go
|
||||||
|
Outbound httpc.Config `json:"outbound"`
|
||||||
|
```
|
||||||
|
|
||||||
|
项目 Validate 中调用 `c.Outbound.Validate()`;创建时 New 也会验证。svc 初始化一次并通过生命周期登记 Close。文件例子:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
outbound:
|
||||||
|
timeout: 3s
|
||||||
|
proxy:
|
||||||
|
mode: direct
|
||||||
|
dialTimeout: 500ms
|
||||||
|
tlsHandshakeTimeout: 1s
|
||||||
|
responseHeaderTimeout: 1s
|
||||||
|
maxConnsPerHost: 32
|
||||||
|
maxIdleConnsPerHost: 16
|
||||||
|
maxIdleConns: 64
|
||||||
|
idleConnTimeout: 60s
|
||||||
|
maxResponseBytes: 1048576
|
||||||
|
```
|
||||||
|
|
||||||
|
现有统一加载入口可读取 JSON/YAML/XML,并自动覆盖 `GO_OMNI_OUTBOUND_PROXY_MODE`、`GO_OMNI_OUTBOUND_DIAL_TIMEOUT` 等环境变量。无需为每个参数手写环境解析。详见 [统一配置](../CONFIG.md)。
|
||||||
|
|
||||||
|
## 标准请求、特殊 TLS 与生命周期
|
||||||
|
|
||||||
|
```go
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||||||
|
if err != nil { return err }
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil { return err }
|
||||||
|
defer res.Body.Close()
|
||||||
|
// 自行判断状态,按项目要求限流/限制大小,流式写入目标;不要无界 ReadAll 大文件。
|
||||||
|
_, err = io.Copy(destination, res.Body)
|
||||||
|
```
|
||||||
|
|
||||||
|
Do 不自动限制 body,允许下载/流式处理,调用方必须关闭。响应体消费/关闭前网络超时持续有效。
|
||||||
|
|
||||||
|
`Config.Transport` 可注入任意 http.RoundTripper,适配自定义 dialer、mTLS、根证书、监控包装或测试替身。框架借用它,**不修改、不调用其 CloseIdleConnections**。代理、连接池、TLS/建连阶段配置不能同时填入,避免无效配置;总 Timeout、JSON 限额、框架重试/日志仍有效。借用实现必须遵守标准请求和 body 所有权、并发及 context 约定。
|
||||||
|
|
||||||
|
`Close` 幂等:拒绝新调用,关闭自有池的空闲连接,不主动打断已开始的请求。服务关闭应先排空 HTTP/后台调用,再关闭 Client;它不是后台 drain 的替代品。生成项目可通过 svc 的生命周期登记清理。
|
||||||
|
|
||||||
|
## 重试、日志与兼容
|
||||||
|
|
||||||
|
MaxRetries 默认 0,最多 5。仅可重放的 GET/HEAD/OPTIONS 对网络错误、429/502/503/504 做框架重试。POST/PUT/PATCH/DELETE 不自动做应用层重试。标准库或自定义 Transport 自己的重放规则仍可能生效,不能承诺业务“恰好执行一次”。
|
||||||
|
|
||||||
|
Backoff 默认 100ms,MaxBackoff 默认 2s,采用指数退避与抖动。Retry-After 要求等待超过 MaxBackoff 时返回当前响应,不提前请求;总截止时间限制全部尝试。成功或无重试的调用不计算退避。
|
||||||
|
|
||||||
|
不跟随重定向,避免自动向其他地址转发认证信息;3xx 由调用者判断。默认传递 context 中的 X-Request-ID,显式请求头优先。日志只包含 method、hostname、status、attempt、收到响应头的耗时等,不打印路径、查询、用户名密码、认证头或响应体;标准 net/http 返回的 URL 错误可能携带地址,项目不要把 URL 中的密钥当作安全日志字段。
|
||||||
|
|
||||||
|
v0.21 兼容说明:
|
||||||
|
|
||||||
|
- 原来的 JSON 五参数直接调用继续有效;新方法末尾增加 `...httpc.RequestOption`,自行定义的 interface/方法值类型需要同步签名。
|
||||||
|
- 原默认代理、总超时、重试、池大小和成功响应含义保留;新字段未设置时不改变它们。
|
||||||
|
- `Transport` 与显式池/代理/阶段参数混用现在报错;此前 MaxConnsPerHost 在自定义 Transport 下实际被忽略。迁移时把参数放到自己的 Transport,或删掉 Transport 改用内置配置。
|
||||||
|
- 没有移除 Do、JSON、StatusError 或自定义 Transport,没有引入新第三方依赖。
|
||||||
100
httpc/benchmark_test.go
Normal file
100
httpc/benchmark_test.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package httpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type benchmarkTransport struct {
|
||||||
|
payload string
|
||||||
|
status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t benchmarkTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.Body != nil {
|
||||||
|
_ = r.Body.Close()
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: t.status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(t.payload)), ContentLength: int64(len(t.payload)), Request: r}, nil
|
||||||
|
}
|
||||||
|
func BenchmarkHTTPClient(b *testing.B) {
|
||||||
|
for _, size := range []int{128, 4096, 65536} {
|
||||||
|
for _, discard := range []bool{false, true} {
|
||||||
|
label := map[int]string{128: "128B", 4096: "4KiB", 65536: "64KiB"}[size]
|
||||||
|
if discard {
|
||||||
|
label += "/discard"
|
||||||
|
} else {
|
||||||
|
label += "/decode"
|
||||||
|
}
|
||||||
|
b.Run(label, func(b *testing.B) {
|
||||||
|
c, err := New(Config{Transport: benchmarkTransport{`{"value":"` + strings.Repeat("x", size) + `"}`, 200}, DisableLogging: true})
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
var output struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
var target any = &output
|
||||||
|
if discard {
|
||||||
|
target = nil
|
||||||
|
}
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
if err := c.JSON(context.Background(), "GET", "http://example.invalid/item", nil, target); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.Run("Do", func(b *testing.B) {
|
||||||
|
c, _ := New(Config{Transport: benchmarkTransport{"", 200}, DisableLogging: true})
|
||||||
|
defer c.Close()
|
||||||
|
r, _ := http.NewRequest("GET", "http://example.invalid/item", nil)
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
res, err := c.Do(r)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
res.Body.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
func BenchmarkHTTPPool(b *testing.B) {
|
||||||
|
for _, status := range []int{200, 401} {
|
||||||
|
b.Run(http.StatusText(status), func(b *testing.B) {
|
||||||
|
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(status); io.WriteString(w, `{"ok":true}`) }))
|
||||||
|
defer s.Close()
|
||||||
|
c, _ := New(Config{DisableLogging: true})
|
||||||
|
defer c.Close()
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
err := c.JSON(context.Background(), "GET", s.URL, nil, nil)
|
||||||
|
if status == 200 && err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkHTTPHeaders(b *testing.B) {
|
||||||
|
c, _ := New(Config{Transport: benchmarkTransport{`{"ok":true}`, 200}, DisableLogging: true})
|
||||||
|
defer c.Close()
|
||||||
|
option := WithHeaders(http.Header{"Authorization": []string{"Bearer example"}, "X-Request-Id": []string{"example-request"}})
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
if err := c.JSON(context.Background(), "GET", "http://example.invalid", nil, nil, option); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
156
httpc/client.go
156
httpc/client.go
@@ -3,9 +3,7 @@
|
|||||||
package httpc
|
package httpc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -22,18 +20,6 @@ import (
|
|||||||
var ErrClosed = errors.New("httpc: closed")
|
var ErrClosed = errors.New("httpc: closed")
|
||||||
var ErrResponseTooLarge = errors.New("httpc: response exceeds limit")
|
var ErrResponseTooLarge = errors.New("httpc: response exceeds limit")
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Timeout time.Duration `json:"timeout"`
|
|
||||||
MaxRetries int `json:"maxRetries"`
|
|
||||||
Backoff time.Duration `json:"backoff"`
|
|
||||||
MaxBackoff time.Duration `json:"maxBackoff"`
|
|
||||||
MaxConnsPerHost int `json:"maxConnsPerHost"`
|
|
||||||
MaxResponseBytes int64 `json:"maxResponseBytes"`
|
|
||||||
DisableRequestID bool `json:"disableRequestId"`
|
|
||||||
DisableLogging bool `json:"disableLogging"`
|
|
||||||
Transport http.RoundTripper `json:"-"` // Borrowed. The caller owns its lifecycle.
|
|
||||||
}
|
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
config Config
|
config Config
|
||||||
client *http.Client
|
client *http.Client
|
||||||
@@ -41,43 +27,19 @@ type Client struct {
|
|||||||
closed atomic.Bool
|
closed atomic.Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New creates a reusable client. Close it after callers and response streams drain.
|
||||||
func New(cfg Config) (*Client, error) {
|
func New(cfg Config) (*Client, error) {
|
||||||
if cfg.Timeout < 0 || cfg.Backoff < 0 || cfg.MaxBackoff < 0 || cfg.MaxRetries < 0 || cfg.MaxRetries > 5 || cfg.MaxConnsPerHost < 0 || cfg.MaxResponseBytes < 0 {
|
normalized, err := normalizeConfig(cfg)
|
||||||
return nil, errors.New("httpc: invalid configuration")
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
if cfg.Timeout == 0 {
|
c := &Client{config: normalized}
|
||||||
cfg.Timeout = 10 * time.Second
|
|
||||||
}
|
|
||||||
if cfg.Backoff == 0 {
|
|
||||||
cfg.Backoff = 100 * time.Millisecond
|
|
||||||
}
|
|
||||||
if cfg.MaxBackoff == 0 {
|
|
||||||
cfg.MaxBackoff = 2 * time.Second
|
|
||||||
}
|
|
||||||
if cfg.Backoff > cfg.MaxBackoff {
|
|
||||||
return nil, errors.New("httpc: backoff exceeds maxBackoff")
|
|
||||||
}
|
|
||||||
if cfg.MaxConnsPerHost == 0 {
|
|
||||||
cfg.MaxConnsPerHost = 64
|
|
||||||
}
|
|
||||||
if cfg.MaxResponseBytes == 0 {
|
|
||||||
cfg.MaxResponseBytes = 1 << 20
|
|
||||||
}
|
|
||||||
if cfg.MaxResponseBytes > 1<<30 || cfg.MaxConnsPerHost > 65536 {
|
|
||||||
return nil, errors.New("httpc: response or connection limit exceeds supported capacity")
|
|
||||||
}
|
|
||||||
c := &Client{config: cfg}
|
|
||||||
transport := cfg.Transport
|
transport := cfg.Transport
|
||||||
if transport == nil {
|
if transport == nil {
|
||||||
// Own a private pool; never mutate the process-wide DefaultTransport.
|
c.owned, err = ownedTransport(normalized)
|
||||||
base, ok := http.DefaultTransport.(*http.Transport)
|
if err != nil {
|
||||||
if !ok {
|
return nil, err
|
||||||
return nil, errors.New("httpc: provide Transport when DefaultTransport is replaced")
|
|
||||||
}
|
}
|
||||||
c.owned = base.Clone()
|
|
||||||
c.owned.MaxConnsPerHost = cfg.MaxConnsPerHost
|
|
||||||
c.owned.MaxIdleConnsPerHost = cfg.MaxConnsPerHost
|
|
||||||
c.owned.MaxIdleConns = cfg.MaxConnsPerHost * 2
|
|
||||||
transport = c.owned
|
transport = c.owned
|
||||||
}
|
}
|
||||||
c.client = &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
c.client = &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||||
@@ -114,7 +76,10 @@ func (c *Client) Do(request *http.Request) (*http.Response, error) {
|
|||||||
}
|
}
|
||||||
replayable := (base.Method == "" || base.Method == "GET" || base.Method == "HEAD" || base.Method == "OPTIONS") && (base.Body == nil || base.Body == http.NoBody || base.GetBody != nil)
|
replayable := (base.Method == "" || base.Method == "GET" || base.Method == "HEAD" || base.Method == "OPTIONS") && (base.Body == nil || base.Body == http.NoBody || base.GetBody != nil)
|
||||||
for attempt := 0; ; attempt++ {
|
for attempt := 0; ; attempt++ {
|
||||||
req := base.Clone(ctx)
|
req := base
|
||||||
|
if attempt > 0 {
|
||||||
|
req = base.Clone(ctx)
|
||||||
|
}
|
||||||
if attempt > 0 && base.Body != nil && base.Body != http.NoBody {
|
if attempt > 0 && base.Body != nil && base.Body != http.NoBody {
|
||||||
body, err := base.GetBody()
|
body, err := base.GetBody()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -133,23 +98,9 @@ func (c *Client) Do(request *http.Request) (*http.Response, error) {
|
|||||||
logx.InfoContext(ctx, "http.client", "method", req.Method, "host", req.URL.Hostname(), "status", status, "attempt", attempt+1, "durationMs", time.Since(started).Seconds()*1000, "failed", err != nil)
|
logx.InfoContext(ctx, "http.client", "method", req.Method, "host", req.URL.Hostname(), "status", status, "attempt", attempt+1, "durationMs", time.Since(started).Seconds()*1000, "failed", err != nil)
|
||||||
}
|
}
|
||||||
retry := replayable && attempt < c.config.MaxRetries && ctx.Err() == nil && (err != nil || status == 429 || status == 502 || status == 503 || status == 504)
|
retry := replayable && attempt < c.config.MaxRetries && ctx.Err() == nil && (err != nil || status == 429 || status == 502 || status == 503 || status == 504)
|
||||||
delay := c.config.Backoff
|
var delay time.Duration
|
||||||
for i := 0; i < attempt && delay < c.config.MaxBackoff; i++ {
|
if retry {
|
||||||
if delay > c.config.MaxBackoff/2 {
|
delay, retry = c.retryDelay(attempt, response)
|
||||||
delay = c.config.MaxBackoff
|
|
||||||
} else {
|
|
||||||
delay *= 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if response != nil && response.Header.Get("Retry-After") != "" {
|
|
||||||
if value, ok := retryAfter(response.Header.Get("Retry-After")); ok {
|
|
||||||
delay = value
|
|
||||||
}
|
|
||||||
} else if delay > 1 {
|
|
||||||
delay = delay/2 + time.Duration(rand.Int63n(int64(delay/2)+1))
|
|
||||||
}
|
|
||||||
if delay > c.config.MaxBackoff {
|
|
||||||
retry = false
|
|
||||||
}
|
}
|
||||||
if !retry {
|
if !retry {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -160,9 +111,7 @@ func (c *Client) Do(request *http.Request) (*http.Response, error) {
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
if response != nil {
|
if response != nil {
|
||||||
if response.ContentLength >= 0 && response.ContentLength <= 32<<10 {
|
drainSmallBody(response, 32<<10)
|
||||||
_, _ = io.Copy(io.Discard, response.Body)
|
|
||||||
}
|
|
||||||
_ = response.Body.Close()
|
_ = response.Body.Close()
|
||||||
}
|
}
|
||||||
timer := time.NewTimer(delay)
|
timer := time.NewTimer(delay)
|
||||||
@@ -204,48 +153,6 @@ type StatusError struct{ StatusCode int }
|
|||||||
|
|
||||||
func (e *StatusError) Error() string { return fmt.Sprintf("httpc: HTTP status %d", e.StatusCode) }
|
func (e *StatusError) Error() string { return fmt.Sprintf("httpc: HTTP status %d", e.StatusCode) }
|
||||||
|
|
||||||
// JSON bounds response memory, closes the body, and decodes only 2xx responses.
|
|
||||||
// Error bodies, URLs, credentials and query strings are not included in logs.
|
|
||||||
func (c *Client) JSON(ctx context.Context, method, url string, input, output any) error {
|
|
||||||
if ctx == nil {
|
|
||||||
return errors.New("httpc: nil context")
|
|
||||||
}
|
|
||||||
var body io.Reader
|
|
||||||
if input != nil {
|
|
||||||
data, err := json.Marshal(input)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
body = bytes.NewReader(data)
|
|
||||||
}
|
|
||||||
request, err := http.NewRequestWithContext(ctx, method, url, body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if input != nil {
|
|
||||||
request.Header.Set("Content-Type", "application/json")
|
|
||||||
}
|
|
||||||
response, err := c.Do(request)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer response.Body.Close()
|
|
||||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
||||||
return &StatusError{StatusCode: response.StatusCode}
|
|
||||||
}
|
|
||||||
data, err := io.ReadAll(io.LimitReader(response.Body, c.config.MaxResponseBytes+1))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if int64(len(data)) > c.config.MaxResponseBytes {
|
|
||||||
return ErrResponseTooLarge
|
|
||||||
}
|
|
||||||
if output == nil || len(bytes.TrimSpace(data)) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return json.Unmarshal(data, output)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) Close() error {
|
func (c *Client) Close() error {
|
||||||
c.closed.Store(true)
|
c.closed.Store(true)
|
||||||
if c.owned != nil {
|
if c.owned != nil {
|
||||||
@@ -253,3 +160,34 @@ func (c *Client) Close() error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// retryDelay is deliberately outside the success path: no jitter/random work
|
||||||
|
// for calls that cannot or do not need to retry.
|
||||||
|
func (c *Client) retryDelay(attempt int, response *http.Response) (time.Duration, bool) {
|
||||||
|
delay := c.config.Backoff
|
||||||
|
for i := 0; i < attempt && delay < c.config.MaxBackoff; i++ {
|
||||||
|
if delay > c.config.MaxBackoff/2 {
|
||||||
|
delay = c.config.MaxBackoff
|
||||||
|
} else {
|
||||||
|
delay *= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if response != nil {
|
||||||
|
if value, ok := retryAfter(response.Header.Get("Retry-After")); ok {
|
||||||
|
return value, value <= c.config.MaxBackoff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if delay > 1 {
|
||||||
|
delay = delay/2 + time.Duration(rand.Int63n(int64(delay/2)+1))
|
||||||
|
}
|
||||||
|
return delay, delay <= c.config.MaxBackoff
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain only a known small error body. Unknown/large bodies are closed promptly;
|
||||||
|
// never allocate or consume an unbounded error document just to retain a pool slot.
|
||||||
|
func drainSmallBody(response *http.Response, limit int64) {
|
||||||
|
limit = min(limit, 32<<10)
|
||||||
|
if response.Body != nil && response.ContentLength >= 0 && response.ContentLength <= limit {
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, limit+1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
205
httpc/config.go
Normal file
205
httpc/config.go
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
package httpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProxyMode controls the owned transport's proxy selection. Empty preserves
|
||||||
|
// DefaultTransport's policy (normally environment proxies).
|
||||||
|
type ProxyMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProxyEnvironment ProxyMode = "environment"
|
||||||
|
ProxyDirect ProxyMode = "direct"
|
||||||
|
ProxyURL ProxyMode = "url"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProxyConfig struct {
|
||||||
|
Mode ProxyMode `json:"mode"`
|
||||||
|
URL string `json:"url"` // May contain credentials; never log the configuration.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config owns one private connection pool unless Transport is supplied.
|
||||||
|
// New duration/pool fields are optional: zero retains existing defaults, positive
|
||||||
|
// values override them. Use a custom Transport for disabling timeouts, custom
|
||||||
|
// dialers, mTLS and other protocol-specific settings.
|
||||||
|
type Config struct {
|
||||||
|
Timeout time.Duration `json:"timeout"`
|
||||||
|
MaxRetries int `json:"maxRetries"`
|
||||||
|
Backoff time.Duration `json:"backoff"`
|
||||||
|
MaxBackoff time.Duration `json:"maxBackoff"`
|
||||||
|
MaxConnsPerHost int `json:"maxConnsPerHost"`
|
||||||
|
MaxIdleConns int `json:"maxIdleConns"`
|
||||||
|
MaxIdleConnsPerHost int `json:"maxIdleConnsPerHost"`
|
||||||
|
DialTimeout time.Duration `json:"dialTimeout"`
|
||||||
|
DialKeepAlive time.Duration `json:"dialKeepAlive"`
|
||||||
|
TLSHandshakeTimeout time.Duration `json:"tlsHandshakeTimeout"`
|
||||||
|
ResponseHeaderTimeout time.Duration `json:"responseHeaderTimeout"`
|
||||||
|
ExpectContinueTimeout time.Duration `json:"expectContinueTimeout"`
|
||||||
|
IdleConnTimeout time.Duration `json:"idleConnTimeout"`
|
||||||
|
Proxy ProxyConfig `json:"proxy"`
|
||||||
|
MaxResponseBytes int64 `json:"maxResponseBytes"`
|
||||||
|
DisableRequestID bool `json:"disableRequestId"`
|
||||||
|
DisableLogging bool `json:"disableLogging"`
|
||||||
|
Transport http.RoundTripper `json:"-"` // Borrowed; never modified or closed.
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxResponseBytes int64 = 1 << 30
|
||||||
|
|
||||||
|
// Validate checks values without creating pools or making network requests.
|
||||||
|
func (cfg Config) Validate() error { _, err := normalizeConfig(cfg); return err }
|
||||||
|
func normalizeConfig(cfg Config) (Config, error) {
|
||||||
|
for _, field := range []struct {
|
||||||
|
name string
|
||||||
|
value time.Duration
|
||||||
|
}{
|
||||||
|
{"timeout", cfg.Timeout}, {"backoff", cfg.Backoff}, {"maxBackoff", cfg.MaxBackoff},
|
||||||
|
{"dialTimeout", cfg.DialTimeout}, {"dialKeepAlive", cfg.DialKeepAlive}, {"tlsHandshakeTimeout", cfg.TLSHandshakeTimeout},
|
||||||
|
{"responseHeaderTimeout", cfg.ResponseHeaderTimeout}, {"expectContinueTimeout", cfg.ExpectContinueTimeout}, {"idleConnTimeout", cfg.IdleConnTimeout},
|
||||||
|
} {
|
||||||
|
if field.value < 0 {
|
||||||
|
return Config{}, fmt.Errorf("httpc: %s must not be negative", field.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.MaxRetries < 0 || cfg.MaxRetries > 5 {
|
||||||
|
return Config{}, errors.New("httpc: maxRetries must be 0..5")
|
||||||
|
}
|
||||||
|
if cfg.MaxConnsPerHost < 0 || cfg.MaxConnsPerHost > 65536 || cfg.MaxIdleConnsPerHost < 0 || cfg.MaxIdleConnsPerHost > 65536 || cfg.MaxIdleConns < 0 || cfg.MaxIdleConns > 131072 {
|
||||||
|
return Config{}, errors.New("httpc: invalid connection pool limits")
|
||||||
|
}
|
||||||
|
if cfg.MaxResponseBytes < 0 || cfg.MaxResponseBytes > maxResponseBytes {
|
||||||
|
return Config{}, errors.New("httpc: maxResponseBytes must be 0..1 GiB")
|
||||||
|
}
|
||||||
|
if _, err := proxyFunction(cfg.Proxy); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
if cfg.Transport != nil {
|
||||||
|
value := reflect.ValueOf(cfg.Transport)
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Pointer, reflect.Map, reflect.Func, reflect.Slice, reflect.Interface, reflect.Chan:
|
||||||
|
if value.IsNil() {
|
||||||
|
return Config{}, errors.New("httpc: nil Transport")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.hasTransportOptions() {
|
||||||
|
return Config{}, errors.New("httpc: Transport cannot be combined with proxy, dial, TLS or connection pool options; configure the borrowed transport directly")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.Timeout == 0 {
|
||||||
|
cfg.Timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
if cfg.Backoff == 0 {
|
||||||
|
cfg.Backoff = 100 * time.Millisecond
|
||||||
|
}
|
||||||
|
if cfg.MaxBackoff == 0 {
|
||||||
|
cfg.MaxBackoff = 2 * time.Second
|
||||||
|
}
|
||||||
|
if cfg.Backoff > cfg.MaxBackoff {
|
||||||
|
return Config{}, errors.New("httpc: backoff exceeds maxBackoff")
|
||||||
|
}
|
||||||
|
if cfg.MaxConnsPerHost == 0 {
|
||||||
|
cfg.MaxConnsPerHost = 64
|
||||||
|
}
|
||||||
|
if cfg.MaxIdleConnsPerHost == 0 {
|
||||||
|
cfg.MaxIdleConnsPerHost = cfg.MaxConnsPerHost
|
||||||
|
}
|
||||||
|
if cfg.MaxIdleConns == 0 {
|
||||||
|
cfg.MaxIdleConns = cfg.MaxConnsPerHost * 2
|
||||||
|
}
|
||||||
|
if cfg.MaxResponseBytes == 0 {
|
||||||
|
cfg.MaxResponseBytes = 1 << 20
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
func (c Config) hasTransportOptions() bool {
|
||||||
|
return c.Proxy.Mode != "" || c.Proxy.URL != "" || c.MaxConnsPerHost != 0 || c.MaxIdleConns != 0 || c.MaxIdleConnsPerHost != 0 || c.DialTimeout != 0 || c.DialKeepAlive != 0 || c.TLSHandshakeTimeout != 0 || c.ResponseHeaderTimeout != 0 || c.ExpectContinueTimeout != 0 || c.IdleConnTimeout != 0
|
||||||
|
}
|
||||||
|
func proxyFunction(c ProxyConfig) (func(*http.Request) (*url.URL, error), error) {
|
||||||
|
if c.Mode != ProxyURL && c.URL != "" {
|
||||||
|
return nil, errors.New("httpc: proxy URL requires mode url")
|
||||||
|
}
|
||||||
|
switch c.Mode {
|
||||||
|
case "":
|
||||||
|
return nil, nil
|
||||||
|
case ProxyDirect:
|
||||||
|
return nil, nil
|
||||||
|
case ProxyEnvironment:
|
||||||
|
return http.ProxyFromEnvironment, nil
|
||||||
|
case ProxyURL:
|
||||||
|
u, err := url.Parse(c.URL)
|
||||||
|
if err != nil || u == nil || u.Hostname() == "" || u.Opaque != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || (u.Path != "" && u.Path != "/") {
|
||||||
|
return nil, errors.New("httpc: proxy URL must be an absolute proxy origin without path, query or fragment")
|
||||||
|
}
|
||||||
|
switch u.Scheme {
|
||||||
|
case "http", "https", "socks5", "socks5h":
|
||||||
|
default:
|
||||||
|
return nil, errors.New("httpc: proxy scheme must be http, https, socks5 or socks5h")
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(u.Host, ":") {
|
||||||
|
return nil, errors.New("httpc: proxy port must be 1..65535")
|
||||||
|
}
|
||||||
|
if port := u.Port(); port != "" {
|
||||||
|
n, e := strconv.Atoi(port)
|
||||||
|
if e != nil || n < 1 || n > 65535 {
|
||||||
|
return nil, errors.New("httpc: proxy port must be 1..65535")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.Path = ""
|
||||||
|
u.RawPath = ""
|
||||||
|
return http.ProxyURL(u), nil
|
||||||
|
default:
|
||||||
|
return nil, errors.New("httpc: proxy mode must be environment, direct or url")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func ownedTransport(cfg Config) (*http.Transport, error) {
|
||||||
|
base, ok := http.DefaultTransport.(*http.Transport)
|
||||||
|
if !ok || base == nil {
|
||||||
|
return nil, errors.New("httpc: provide Transport when DefaultTransport is replaced")
|
||||||
|
}
|
||||||
|
if (base.DialTLSContext != nil || base.DialTLS != nil) && (cfg.DialTimeout > 0 || cfg.DialKeepAlive > 0 || cfg.TLSHandshakeTimeout > 0) {
|
||||||
|
return nil, errors.New("httpc: provide Transport when DefaultTransport has a custom TLS dialer")
|
||||||
|
}
|
||||||
|
t := base.Clone()
|
||||||
|
t.MaxConnsPerHost = cfg.MaxConnsPerHost
|
||||||
|
t.MaxIdleConnsPerHost = cfg.MaxIdleConnsPerHost
|
||||||
|
t.MaxIdleConns = cfg.MaxIdleConns
|
||||||
|
if cfg.Proxy.Mode != "" {
|
||||||
|
proxy, err := proxyFunction(cfg.Proxy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.Proxy = proxy
|
||||||
|
}
|
||||||
|
if cfg.DialTimeout > 0 || cfg.DialKeepAlive > 0 {
|
||||||
|
dialer := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}
|
||||||
|
if cfg.DialTimeout > 0 {
|
||||||
|
dialer.Timeout = cfg.DialTimeout
|
||||||
|
}
|
||||||
|
if cfg.DialKeepAlive > 0 {
|
||||||
|
dialer.KeepAlive = cfg.DialKeepAlive
|
||||||
|
}
|
||||||
|
t.DialContext = dialer.DialContext
|
||||||
|
t.Dial = nil
|
||||||
|
}
|
||||||
|
if cfg.TLSHandshakeTimeout > 0 {
|
||||||
|
t.TLSHandshakeTimeout = cfg.TLSHandshakeTimeout
|
||||||
|
}
|
||||||
|
if cfg.ResponseHeaderTimeout > 0 {
|
||||||
|
t.ResponseHeaderTimeout = cfg.ResponseHeaderTimeout
|
||||||
|
}
|
||||||
|
if cfg.ExpectContinueTimeout > 0 {
|
||||||
|
t.ExpectContinueTimeout = cfg.ExpectContinueTimeout
|
||||||
|
}
|
||||||
|
if cfg.IdleConnTimeout > 0 {
|
||||||
|
t.IdleConnTimeout = cfg.IdleConnTimeout
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
277
httpc/config_test.go
Normal file
277
httpc/config_test.go
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
package httpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testClient(t *testing.T, cfg Config) *Client {
|
||||||
|
t.Helper()
|
||||||
|
cfg.DisableLogging = true
|
||||||
|
c, err := New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { c.Close() })
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransportConfigurationAndIsolation(t *testing.T) {
|
||||||
|
base := http.DefaultTransport.(*http.Transport)
|
||||||
|
oldConns, oldIdle, oldTimeout := base.MaxConnsPerHost, base.MaxIdleConns, base.ResponseHeaderTimeout
|
||||||
|
cfg := Config{Proxy: ProxyConfig{Mode: ProxyDirect}, DialTimeout: time.Second, DialKeepAlive: 20 * time.Second, TLSHandshakeTimeout: 2 * time.Second, ResponseHeaderTimeout: 3 * time.Second, ExpectContinueTimeout: 4 * time.Second, IdleConnTimeout: 5 * time.Second, MaxConnsPerHost: 8, MaxIdleConns: 12, MaxIdleConnsPerHost: 3}
|
||||||
|
c := testClient(t, cfg)
|
||||||
|
if c.owned == base || c.owned.Proxy != nil || c.owned.DialContext == nil || c.owned.MaxConnsPerHost != 8 || c.owned.MaxIdleConns != 12 || c.owned.MaxIdleConnsPerHost != 3 || c.owned.TLSHandshakeTimeout != 2*time.Second || c.owned.ResponseHeaderTimeout != 3*time.Second || c.owned.ExpectContinueTimeout != 4*time.Second || c.owned.IdleConnTimeout != 5*time.Second {
|
||||||
|
t.Fatal("transport options not applied")
|
||||||
|
}
|
||||||
|
if base.MaxConnsPerHost != oldConns || base.MaxIdleConns != oldIdle || base.ResponseHeaderTimeout != oldTimeout {
|
||||||
|
t.Fatal("modified shared transport")
|
||||||
|
}
|
||||||
|
if c.owned.TLSClientConfig != nil && c.owned.TLSClientConfig.InsecureSkipVerify {
|
||||||
|
t.Fatal("TLS verification disabled")
|
||||||
|
}
|
||||||
|
d := testClient(t, Config{})
|
||||||
|
if d.owned.MaxConnsPerHost != 64 || d.owned.MaxIdleConnsPerHost != 64 || d.owned.MaxIdleConns != 128 || d.owned.IdleConnTimeout != base.IdleConnTimeout || d.owned.TLSHandshakeTimeout != base.TLSHandshakeTimeout || d.owned.ResponseHeaderTimeout != base.ResponseHeaderTimeout {
|
||||||
|
t.Fatal("default behavior changed")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if conn, err := c.owned.DialContext(ctx, "tcp", "127.0.0.1:1"); err == nil {
|
||||||
|
conn.Close()
|
||||||
|
t.Fatal("dial ignored context")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestProxyValidation(t *testing.T) {
|
||||||
|
for _, scheme := range []string{"http", "https", "socks5", "socks5h"} {
|
||||||
|
c := testClient(t, Config{Proxy: ProxyConfig{Mode: ProxyURL, URL: scheme + "://user:private-value@proxy.invalid:8080"}})
|
||||||
|
req, _ := http.NewRequest("GET", "https://target.invalid", nil)
|
||||||
|
u, err := c.owned.Proxy(req)
|
||||||
|
if err != nil || u.Host != "proxy.invalid:8080" || u.Scheme != scheme {
|
||||||
|
t.Fatal("explicit proxy unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bad := []Config{
|
||||||
|
{Proxy: ProxyConfig{Mode: "private-value"}},
|
||||||
|
{Proxy: ProxyConfig{URL: "http://private-value"}},
|
||||||
|
{Proxy: ProxyConfig{Mode: ProxyDirect, URL: "http://private-value"}},
|
||||||
|
{Proxy: ProxyConfig{Mode: ProxyURL, URL: "http://user:private-value@proxy.invalid:bad"}},
|
||||||
|
{Proxy: ProxyConfig{Mode: ProxyURL, URL: "file:///private-value"}},
|
||||||
|
{Proxy: ProxyConfig{Mode: ProxyURL, URL: "http://proxy.invalid/private-value"}},
|
||||||
|
{Proxy: ProxyConfig{Mode: ProxyURL, URL: "http://proxy.invalid?token=private-value"}},
|
||||||
|
{Proxy: ProxyConfig{Mode: ProxyURL, URL: "http://proxy.invalid:65536"}},
|
||||||
|
{DialTimeout: -1}, {DialKeepAlive: -1}, {TLSHandshakeTimeout: -1}, {ResponseHeaderTimeout: -1}, {ExpectContinueTimeout: -1}, {IdleConnTimeout: -1}, {MaxIdleConns: -1}, {MaxIdleConnsPerHost: 65537},
|
||||||
|
{Transport: benchmarkTransport{}, Proxy: ProxyConfig{Mode: ProxyDirect}},
|
||||||
|
{Transport: benchmarkTransport{}, DialTimeout: time.Second}, {Transport: benchmarkTransport{}, MaxConnsPerHost: 2},
|
||||||
|
}
|
||||||
|
for i, cfg := range bad {
|
||||||
|
if _, err := New(cfg); err == nil || strings.Contains(err.Error(), "private-value") {
|
||||||
|
t.Fatalf("case %d: unsafe or missing error %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var nilTransport *http.Transport
|
||||||
|
if _, err := New(Config{Transport: nilTransport}); err == nil {
|
||||||
|
t.Fatal("typed nil accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestProxyRouting(t *testing.T) {
|
||||||
|
var proxied, target atomic.Int32
|
||||||
|
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
proxied.Add(1)
|
||||||
|
if r.URL.Scheme != "http" || r.URL.Host != "target.invalid" {
|
||||||
|
t.Error("not an absolute proxy request")
|
||||||
|
}
|
||||||
|
io.WriteString(w, `{"proxy":true}`)
|
||||||
|
}))
|
||||||
|
defer proxy.Close()
|
||||||
|
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { target.Add(1); io.WriteString(w, `{}`) }))
|
||||||
|
defer origin.Close()
|
||||||
|
// Isolate the standard library's process-cached environment proxy lookup.
|
||||||
|
executable, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, executable, "-test.run=^TestProxyEnvironmentProcess$")
|
||||||
|
for _, value := range os.Environ() {
|
||||||
|
key := strings.SplitN(value, "=", 2)[0]
|
||||||
|
switch strings.ToUpper(key) {
|
||||||
|
case "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY", "GO_OMNI_HTTPC_PROXY_CHILD", "GO_OMNI_HTTPC_TARGET":
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cmd.Env = append(cmd.Env, value)
|
||||||
|
}
|
||||||
|
cmd.Env = append(cmd.Env, "GO_OMNI_HTTPC_PROXY_CHILD=1", "GO_OMNI_HTTPC_TARGET="+origin.URL, "HTTP_PROXY="+proxy.URL, "HTTPS_PROXY="+proxy.URL, "NO_PROXY=bypass.invalid")
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatal(err, string(out))
|
||||||
|
}
|
||||||
|
if proxied.Load() != 3 || target.Load() != 1 {
|
||||||
|
t.Fatalf("proxy=%d direct=%d", proxied.Load(), target.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestProxyEnvironmentProcess(t *testing.T) {
|
||||||
|
if os.Getenv("GO_OMNI_HTTPC_PROXY_CHILD") != "1" {
|
||||||
|
t.Skip("subprocess only")
|
||||||
|
}
|
||||||
|
for _, proxy := range []ProxyConfig{{}, {Mode: ProxyEnvironment}, {Mode: ProxyURL, URL: os.Getenv("HTTP_PROXY")}} {
|
||||||
|
c := testClient(t, Config{Proxy: proxy})
|
||||||
|
if err := c.JSON(context.Background(), "GET", "http://target.invalid/path", nil, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
environment := testClient(t, Config{Proxy: ProxyConfig{Mode: ProxyEnvironment}})
|
||||||
|
req, _ := http.NewRequest("GET", "http://bypass.invalid", nil)
|
||||||
|
u, err := environment.owned.Proxy(req)
|
||||||
|
if err != nil || u != nil {
|
||||||
|
t.Fatal("NO_PROXY ignored")
|
||||||
|
}
|
||||||
|
direct := testClient(t, Config{Proxy: ProxyConfig{Mode: ProxyDirect}})
|
||||||
|
if direct.owned.Proxy != nil {
|
||||||
|
t.Fatal("direct still has proxy")
|
||||||
|
}
|
||||||
|
if err := direct.JSON(context.Background(), "GET", os.Getenv("GO_OMNI_HTTPC_TARGET"), nil, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestResponseHeaderTimeoutRecovery(t *testing.T) {
|
||||||
|
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/slow" {
|
||||||
|
<-r.Context().Done()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
io.WriteString(w, `{}`)
|
||||||
|
}))
|
||||||
|
defer s.Close()
|
||||||
|
c := testClient(t, Config{Timeout: 2 * time.Second, ResponseHeaderTimeout: 30 * time.Millisecond})
|
||||||
|
start := time.Now()
|
||||||
|
err := c.JSON(context.Background(), "GET", s.URL+"/slow", nil, nil)
|
||||||
|
var network net.Error
|
||||||
|
if !errors.As(err, &network) || !network.Timeout() || time.Since(start) > time.Second {
|
||||||
|
t.Fatal("header timeout", err)
|
||||||
|
}
|
||||||
|
if err = c.JSON(context.Background(), "GET", s.URL+"/ok", nil, nil); err != nil {
|
||||||
|
t.Fatal("did not recover", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestTLSHandshakeTimeout(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
_, _ = io.Copy(io.Discard, conn)
|
||||||
|
}()
|
||||||
|
c := testClient(t, Config{Timeout: 2 * time.Second, Proxy: ProxyConfig{Mode: ProxyDirect}, TLSHandshakeTimeout: 30 * time.Millisecond})
|
||||||
|
start := time.Now()
|
||||||
|
err = c.JSON(context.Background(), "GET", "https://"+listener.Addr().String(), nil, nil)
|
||||||
|
var network net.Error
|
||||||
|
if !errors.As(err, &network) || !network.Timeout() || time.Since(start) > time.Second {
|
||||||
|
t.Fatal("handshake timeout", err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("TLS connection leaked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestPoolLimitsAndIdleExpiry(t *testing.T) {
|
||||||
|
var connections atomic.Int32
|
||||||
|
entered := make(chan struct{}, 12)
|
||||||
|
release := make(chan struct{})
|
||||||
|
closed := make(chan struct{}, 12)
|
||||||
|
s := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
entered <- struct{}{}
|
||||||
|
select {
|
||||||
|
case <-release:
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
io.WriteString(w, `{}`)
|
||||||
|
}))
|
||||||
|
s.Config.ConnState = func(_ net.Conn, state http.ConnState) {
|
||||||
|
if state == http.StateNew {
|
||||||
|
connections.Add(1)
|
||||||
|
}
|
||||||
|
if state == http.StateClosed {
|
||||||
|
closed <- struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Start()
|
||||||
|
defer s.Close()
|
||||||
|
c := testClient(t, Config{Timeout: 2 * time.Second, MaxConnsPerHost: 2, MaxIdleConnsPerHost: 2, MaxIdleConns: 2, IdleConnTimeout: 40 * time.Millisecond})
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := c.JSON(context.Background(), "GET", s.URL, nil, nil); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
select {
|
||||||
|
case <-entered:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
close(release)
|
||||||
|
t.Fatal("requests did not start")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if connections.Load() != 2 {
|
||||||
|
t.Error("connection cap", connections.Load())
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
wg.Wait()
|
||||||
|
if connections.Load() != 2 {
|
||||||
|
t.Fatal("pool was not reused", connections.Load())
|
||||||
|
}
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
select {
|
||||||
|
case <-closed:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("idle pool was not reclaimed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := c.JSON(context.Background(), "GET", s.URL, nil, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if connections.Load() != 3 {
|
||||||
|
t.Fatal("new connection not established after idle timeout")
|
||||||
|
}
|
||||||
|
c.Close()
|
||||||
|
select {
|
||||||
|
case <-closed:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Close left idle connections open")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestHTTPSVerificationRetained(t *testing.T) {
|
||||||
|
s := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, `{}`) }))
|
||||||
|
defer s.Close()
|
||||||
|
c := testClient(t, Config{Proxy: ProxyConfig{Mode: ProxyDirect}})
|
||||||
|
err := c.JSON(context.Background(), "GET", s.URL, nil, nil)
|
||||||
|
var urlError *url.Error
|
||||||
|
if !errors.As(err, &urlError) {
|
||||||
|
t.Fatal("self-signed server unexpectedly trusted", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
119
httpc/json.go
Normal file
119
httpc/json.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package httpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JSON bounds response memory, closes the body, and decodes only 2xx responses.
|
||||||
|
// Optional headers/limits belong to this call and never alter the client.
|
||||||
|
// output=nil discards a bounded stream; it does not buffer or validate JSON.
|
||||||
|
// Empty successful bodies leave output unchanged. StatusError contains no body.
|
||||||
|
func (c *Client) JSON(ctx context.Context, method, url string, input, output any, options ...RequestOption) error {
|
||||||
|
if ctx == nil {
|
||||||
|
return errors.New("httpc: nil context")
|
||||||
|
}
|
||||||
|
if c.closed.Load() {
|
||||||
|
return ErrClosed
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
settings := requestOptions{maxResponseBytes: c.config.MaxResponseBytes}
|
||||||
|
if len(options) > 0 {
|
||||||
|
var err error
|
||||||
|
settings, err = jsonOptions(c.config.MaxResponseBytes, options)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var body io.Reader
|
||||||
|
if input != nil {
|
||||||
|
data, err := json.Marshal(input)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
request, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if settings.headers != nil {
|
||||||
|
request.Header = settings.headers
|
||||||
|
}
|
||||||
|
if input != nil {
|
||||||
|
if _, exists := request.Header["Content-Type"]; !exists {
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response, err := c.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
|
drainStatusBody(response, settings.maxResponseBytes)
|
||||||
|
return &StatusError{StatusCode: response.StatusCode}
|
||||||
|
}
|
||||||
|
if response.ContentLength > settings.maxResponseBytes {
|
||||||
|
return ErrResponseTooLarge
|
||||||
|
}
|
||||||
|
limited := io.LimitReader(response.Body, settings.maxResponseBytes+1)
|
||||||
|
if output == nil {
|
||||||
|
count, err := io.Copy(io.Discard, limited)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > settings.maxResponseBytes {
|
||||||
|
return ErrResponseTooLarge
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(limited)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if int64(len(data)) > settings.maxResponseBytes {
|
||||||
|
return ErrResponseTooLarge
|
||||||
|
}
|
||||||
|
if len(bytes.TrimSpace(data)) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A known small status body may still be slow. Spend at most a short grace
|
||||||
|
// period trying to retain the connection, then cancel the HTTP read. The status
|
||||||
|
// remains authoritative and the deferred Close still owns body cleanup.
|
||||||
|
func drainStatusBody(response *http.Response, limit int64) {
|
||||||
|
if response.ContentLength < 0 || response.ContentLength > min(limit, 32<<10) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, ok := response.Body.(*responseBody)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timer := time.AfterFunc(25*time.Millisecond, body.cancel)
|
||||||
|
defer timer.Stop()
|
||||||
|
drainSmallBody(response, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the option builder out of the allocation path of existing plain calls.
|
||||||
|
func jsonOptions(limit int64, options []RequestOption) (requestOptions, error) {
|
||||||
|
settings := requestOptions{maxResponseBytes: limit}
|
||||||
|
for _, option := range options {
|
||||||
|
if option == nil {
|
||||||
|
return requestOptions{}, errors.New("httpc: nil request option")
|
||||||
|
}
|
||||||
|
if err := option(&settings); err != nil {
|
||||||
|
return requestOptions{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return settings, nil
|
||||||
|
}
|
||||||
99
httpc/request.go
Normal file
99
httpc/request.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package httpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type requestOptions struct {
|
||||||
|
headers http.Header
|
||||||
|
maxResponseBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestOption applies only to one JSON call. Returned options can be reused
|
||||||
|
// concurrently; request headers are independent, with no client-wide credentials.
|
||||||
|
type RequestOption func(*requestOptions) error
|
||||||
|
|
||||||
|
// WithHeader sets a header for this call; later options replace earlier values.
|
||||||
|
// Framing and Host must be controlled through a standard Request passed to Do.
|
||||||
|
func WithHeader(name, value string) RequestOption {
|
||||||
|
name = http.CanonicalHeaderKey(name)
|
||||||
|
err := checkHeader(name, []string{value})
|
||||||
|
return func(o *requestOptions) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if o.headers == nil {
|
||||||
|
o.headers = make(http.Header)
|
||||||
|
}
|
||||||
|
o.headers[name] = []string{value}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithHeaders snapshots headers immediately. Editing the original map after
|
||||||
|
// construction does not change the option; each invocation gets separate slices.
|
||||||
|
func WithHeaders(headers http.Header) RequestOption {
|
||||||
|
snapshot := make(http.Header, len(headers))
|
||||||
|
var err error
|
||||||
|
for key, values := range headers {
|
||||||
|
name := http.CanonicalHeaderKey(key)
|
||||||
|
if err = checkHeader(name, values); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if _, exists := snapshot[name]; exists {
|
||||||
|
err = errors.New("httpc: duplicate canonical header name")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
snapshot[name] = append([]string(nil), values...)
|
||||||
|
}
|
||||||
|
return func(o *requestOptions) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if o.headers == nil {
|
||||||
|
o.headers = make(http.Header, len(snapshot))
|
||||||
|
}
|
||||||
|
for name, values := range snapshot {
|
||||||
|
o.headers[name] = append([]string(nil), values...)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithMaxResponseBytes overrides JSON's bounded response read for one call.
|
||||||
|
// The size is positive and at most 1 GiB. Do remains a caller-owned stream.
|
||||||
|
func WithMaxResponseBytes(limit int64) RequestOption {
|
||||||
|
return func(o *requestOptions) error {
|
||||||
|
if limit <= 0 || limit > maxResponseBytes {
|
||||||
|
return errors.New("httpc: response limit must be positive and at most 1 GiB")
|
||||||
|
}
|
||||||
|
o.maxResponseBytes = limit
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func checkHeader(name string, values []string) error {
|
||||||
|
if name == "" {
|
||||||
|
return errors.New("httpc: header name must not be empty")
|
||||||
|
}
|
||||||
|
for _, c := range []byte(name) {
|
||||||
|
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("!#$%&'*+-.^_`|~", rune(c)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return errors.New("httpc: invalid header name")
|
||||||
|
}
|
||||||
|
switch name {
|
||||||
|
case "Host", "Content-Length", "Transfer-Encoding", "Trailer":
|
||||||
|
return errors.New("httpc: use Do for Host or request framing controls")
|
||||||
|
}
|
||||||
|
for _, value := range values {
|
||||||
|
for i := 0; i < len(value); i++ {
|
||||||
|
c := value[i]
|
||||||
|
if c == '\x7f' || c < ' ' && c != '\t' {
|
||||||
|
return errors.New("httpc: invalid header value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
316
httpc/request_test.go
Normal file
316
httpc/request_test.go
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
package httpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJSONHeadersConcurrentSnapshot(t *testing.T) {
|
||||||
|
var leaked atomic.Bool
|
||||||
|
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Authorization") != "Bearer "+r.URL.Query().Get("caller") {
|
||||||
|
leaked.Store(true)
|
||||||
|
}
|
||||||
|
if r.Header.Get("X-Shared") != "original" || len(r.Header.Values("X-Tag")) != 2 {
|
||||||
|
leaked.Store(true)
|
||||||
|
}
|
||||||
|
if r.Header.Get("Content-Type") != "application/vnd.example+json" {
|
||||||
|
leaked.Store(true)
|
||||||
|
}
|
||||||
|
io.WriteString(w, `{"ok":true}`)
|
||||||
|
}))
|
||||||
|
defer s.Close()
|
||||||
|
c := testClient(t, Config{})
|
||||||
|
headers := http.Header{"X-Shared": []string{"original"}, "X-Tag": []string{"one", "two"}}
|
||||||
|
snapshot := WithHeaders(headers)
|
||||||
|
headers.Set("X-Shared", "changed")
|
||||||
|
headers["X-Tag"][0] = "changed"
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 24; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
id := fmt.Sprint(i)
|
||||||
|
var output struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
}
|
||||||
|
if err := c.JSON(context.Background(), "POST", s.URL+"?caller="+id, map[string]bool{"hello": true}, &output, snapshot, WithHeader("Authorization", "Bearer "+id), WithHeader("content-type", "application/vnd.example+json")); err != nil || !output.OK {
|
||||||
|
t.Errorf("JSON failed: %v", err)
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if leaked.Load() {
|
||||||
|
t.Fatal("headers leaked across requests or changed with source map")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestRequestOptionsValidationBeforeNetwork(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
c := testClient(t, Config{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
calls.Add(1)
|
||||||
|
return benchmarkTransport{`{}`, 200}.RoundTrip(r)
|
||||||
|
})})
|
||||||
|
invalid := []RequestOption{nil, WithHeader("bad name", "private-token"), WithHeader("Authorization", "private-token\r\nInjected: yes"), WithHeader("Host", "example.invalid"), WithHeader("Content-Length", "1"), WithHeader("Transfer-Encoding", "chunked"), WithHeader("Trailer", "X-Signature"), WithHeaders(http.Header{"X-Name": []string{"one"}, "x-name": []string{"two"}}), WithMaxResponseBytes(0), WithMaxResponseBytes(maxResponseBytes + 1)}
|
||||||
|
for _, option := range invalid {
|
||||||
|
err := c.JSON(context.Background(), "GET", "http://example.invalid", nil, nil, option)
|
||||||
|
if err == nil || strings.Contains(err.Error(), "private-token") {
|
||||||
|
t.Fatal("missing or unsafe error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if calls.Load() != 0 {
|
||||||
|
t.Fatal("invalid options reached network")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestJSONResponseLimitsAndStreaming(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/empty":
|
||||||
|
w.WriteHeader(204)
|
||||||
|
case "/chunked":
|
||||||
|
w.(http.Flusher).Flush()
|
||||||
|
io.WriteString(w, strings.Repeat("x", 17))
|
||||||
|
case "/status-stream":
|
||||||
|
w.WriteHeader(401)
|
||||||
|
w.(http.Flusher).Flush()
|
||||||
|
<-r.Context().Done()
|
||||||
|
case "/multiple":
|
||||||
|
io.WriteString(w, `{} {}`)
|
||||||
|
default:
|
||||||
|
io.WriteString(w, `{"ok":true}`)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
c := testClient(t, Config{MaxResponseBytes: 8, Timeout: time.Second})
|
||||||
|
var out struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
}
|
||||||
|
if err := c.JSON(context.Background(), "GET", server.URL, nil, &out, WithMaxResponseBytes(32)); err != nil || !out.OK {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := c.JSON(context.Background(), "GET", server.URL, nil, nil); !errors.Is(err, ErrResponseTooLarge) {
|
||||||
|
t.Fatal("limit changed permanently", err)
|
||||||
|
}
|
||||||
|
for _, target := range []any{nil, &out} {
|
||||||
|
if err := c.JSON(context.Background(), "GET", server.URL+"/chunked", nil, target, WithMaxResponseBytes(16)); !errors.Is(err, ErrResponseTooLarge) {
|
||||||
|
t.Fatal("unbounded chunked body", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := c.JSON(context.Background(), "GET", server.URL+"/empty", nil, &out); err != nil || !out.OK {
|
||||||
|
t.Fatal("empty success changed output", err)
|
||||||
|
}
|
||||||
|
if err := c.JSON(context.Background(), "GET", server.URL+"/multiple", nil, &out); err == nil {
|
||||||
|
t.Fatal("multiple JSON documents accepted")
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
err := c.JSON(context.Background(), "GET", server.URL+"/status-stream", nil, nil)
|
||||||
|
var status *StatusError
|
||||||
|
if !errors.As(err, &status) || status.StatusCode != 401 || time.Since(start) > 500*time.Millisecond {
|
||||||
|
t.Fatal("waited for unknown status body", err)
|
||||||
|
}
|
||||||
|
if calls.Load() != 7 {
|
||||||
|
t.Fatal(calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestErrorBodyPoolReuseAndDrainBound(t *testing.T) {
|
||||||
|
var connections atomic.Int32
|
||||||
|
s := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(401)
|
||||||
|
io.WriteString(w, "private-error-body")
|
||||||
|
}))
|
||||||
|
s.Config.ConnState = func(_ net.Conn, state http.ConnState) {
|
||||||
|
if state == http.StateNew {
|
||||||
|
connections.Add(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Start()
|
||||||
|
defer s.Close()
|
||||||
|
c := testClient(t, Config{})
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
err := c.JSON(context.Background(), "GET", s.URL, nil, nil)
|
||||||
|
var status *StatusError
|
||||||
|
if !errors.As(err, &status) || strings.Contains(err.Error(), "private-error-body") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if connections.Load() != 1 {
|
||||||
|
t.Fatal("small status response prevented reuse", connections.Load())
|
||||||
|
}
|
||||||
|
body := &countedBody{Reader: strings.NewReader(strings.Repeat("x", 1<<20))}
|
||||||
|
custom := testClient(t, Config{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{StatusCode: 401, ContentLength: 1, Body: body, Header: make(http.Header)}, nil
|
||||||
|
})})
|
||||||
|
err := custom.JSON(context.Background(), "GET", "http://example.invalid", nil, nil)
|
||||||
|
var status *StatusError
|
||||||
|
if !errors.As(err, &status) || !body.closed || body.read > (32<<10)+1 {
|
||||||
|
t.Fatal("unbounded/missing drain", body.read, body.closed, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type countedBody struct {
|
||||||
|
io.Reader
|
||||||
|
read int
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *countedBody) Read(p []byte) (int, error) {
|
||||||
|
n, err := b.Reader.Read(p)
|
||||||
|
b.read += n
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
func (b *countedBody) Close() error { b.closed = true; return nil }
|
||||||
|
|
||||||
|
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||||
|
|
||||||
|
type borrowedTransport struct{ closed atomic.Bool }
|
||||||
|
|
||||||
|
func (t *borrowedTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||||
|
return benchmarkTransport{`{}`, 200}.RoundTrip(r)
|
||||||
|
}
|
||||||
|
func (t *borrowedTransport) CloseIdleConnections() { t.closed.Store(true) }
|
||||||
|
func TestBorrowedTransportAndHeaderRetry(t *testing.T) {
|
||||||
|
owned := &borrowedTransport{}
|
||||||
|
c := testClient(t, Config{Transport: owned})
|
||||||
|
c.Close()
|
||||||
|
if owned.closed.Load() {
|
||||||
|
t.Fatal("closed caller's transport")
|
||||||
|
}
|
||||||
|
var attempts atomic.Int32
|
||||||
|
retry := testClient(t, Config{MaxRetries: 1, Backoff: time.Millisecond, Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.Header.Get("Authorization") != "Bearer test" {
|
||||||
|
t.Error("retry lost header")
|
||||||
|
}
|
||||||
|
status := 200
|
||||||
|
if attempts.Add(1) == 1 {
|
||||||
|
status = 503
|
||||||
|
}
|
||||||
|
return benchmarkTransport{`{}`, status}.RoundTrip(r)
|
||||||
|
})})
|
||||||
|
if err := retry.JSON(context.Background(), "GET", "http://example.invalid", nil, nil, WithHeader("Authorization", "Bearer test")); err != nil || attempts.Load() != 2 {
|
||||||
|
t.Fatal(err, attempts.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func FuzzRequestHeaderOptions(f *testing.F) {
|
||||||
|
f.Add("Authorization", "Bearer example")
|
||||||
|
f.Add("X-Name", "one\r\nX-Test: bad")
|
||||||
|
f.Fuzz(func(t *testing.T, name, value string) {
|
||||||
|
if len(name)+len(value) > 8192 {
|
||||||
|
t.Skip()
|
||||||
|
}
|
||||||
|
option := WithHeader(name, value)
|
||||||
|
var settings requestOptions
|
||||||
|
if err := option(&settings); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest("GET", "http://example.invalid", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Header = settings.headers
|
||||||
|
// The standard Request writer must be able to serialize accepted headers.
|
||||||
|
if err = req.Write(io.Discard); err != nil {
|
||||||
|
t.Fatal("accepted malformed header", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPLogDoesNotContainCredentials(t *testing.T) {
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
logger, err := logx.New(logx.Config{Writer: &buffer})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer logger.Close()
|
||||||
|
c, err := New(Config{Transport: benchmarkTransport{"private-response", 401}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
ctx := logx.WithLogger(context.Background(), logger)
|
||||||
|
err = c.JSON(ctx, "GET", "http://private-user:private-password@example.invalid/private-path?token=private-query", nil, nil, WithHeader("Authorization", "Bearer private-token"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected status error")
|
||||||
|
}
|
||||||
|
for _, secret := range []string{"private-user", "private-password", "private-path", "private-query", "private-token", "private-response"} {
|
||||||
|
if strings.Contains(buffer.String(), secret) || strings.Contains(err.Error(), secret) {
|
||||||
|
t.Fatal("sensitive field leaked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(buffer.String(), "http.client") {
|
||||||
|
t.Fatal("missing access log")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestCloseAllowsInflightCallToFinish(t *testing.T) {
|
||||||
|
entered := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
var once sync.Once
|
||||||
|
releaseNow := func() { once.Do(func() { close(release) }) }
|
||||||
|
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(entered); <-release; io.WriteString(w, `{}`) }))
|
||||||
|
defer s.Close()
|
||||||
|
c := testClient(t, Config{Timeout: time.Second})
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- c.JSON(context.Background(), "GET", s.URL, nil, nil) }()
|
||||||
|
defer releaseNow()
|
||||||
|
select {
|
||||||
|
case <-entered:
|
||||||
|
case err := <-done:
|
||||||
|
t.Fatal("request failed before handler", err)
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("handler not entered")
|
||||||
|
}
|
||||||
|
c.Close()
|
||||||
|
releaseNow()
|
||||||
|
if err := <-done; err != nil {
|
||||||
|
t.Fatal("Close interrupted in-flight call", err)
|
||||||
|
}
|
||||||
|
if err := c.JSON(context.Background(), "GET", s.URL, nil, nil); !errors.Is(err, ErrClosed) {
|
||||||
|
t.Fatal("accepted new call after Close", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlowKnownErrorBodyHasShortDrainBudget(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Length", "100")
|
||||||
|
w.WriteHeader(503)
|
||||||
|
w.(http.Flusher).Flush()
|
||||||
|
<-r.Context().Done()
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
c := testClient(t, Config{Timeout: 2 * time.Second})
|
||||||
|
start := time.Now()
|
||||||
|
err := c.JSON(context.Background(), "GET", server.URL, nil, nil)
|
||||||
|
var status *StatusError
|
||||||
|
if !errors.As(err, &status) || status.StatusCode != 503 || time.Since(start) > 500*time.Millisecond {
|
||||||
|
t.Fatal("slow error body delayed status", err, time.Since(start))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONLimitAppliesAfterGzipExpansion(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Encoding", "gzip")
|
||||||
|
writer := gzip.NewWriter(w)
|
||||||
|
_, _ = io.WriteString(writer, strings.Repeat("x", 4096))
|
||||||
|
_ = writer.Close()
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
c := testClient(t, Config{MaxResponseBytes: 64})
|
||||||
|
if err := c.JSON(context.Background(), "GET", server.URL, nil, nil); !errors.Is(err, ErrResponseTooLarge) {
|
||||||
|
t.Fatal("gzip bypassed response limit", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
47
reports/httpcAcceptance.md
Normal file
47
reports/httpcAcceptance.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# v0.21 httpc 验收(2026-09-26)
|
||||||
|
|
||||||
|
目标:让普通项目直接配置代理、连接阶段与空闲池参数,带请求头完成有界 JSON 调用,同时保留标准 Do/Transport 扩展。没有加入业务服务规则,也没有修改业务项目或在线服务器。
|
||||||
|
|
||||||
|
## 功能与安全验证
|
||||||
|
|
||||||
|
- 代理:独立子进程验证环境代理缓存、默认/显式 environment、NO_PROXY;本地代理与本地目标验证固定代理/直连。配置错误不回显凭证。
|
||||||
|
- Transport:克隆池与进程默认池互不修改;可配置建连/keepalive/握手/响应头/100-continue/空闲参数,保留 TLS 证书验证。借用 Transport 不修改、不关闭,混合配置明确失败,typed nil 拒绝。
|
||||||
|
- 实际 socket:慢 TLS 握手、慢响应头、超时后恢复、12 并发下最多 2 条 HTTP/1.1 连接、空闲回收与关闭后的连接释放。
|
||||||
|
- 请求选项:24 个并发调用的认证头独立;WithHeaders 快照源 map、多值头与大小写规范化,重试不丢头,控制字符/分帧头拒绝,错误不会带 Token。
|
||||||
|
- JSON:单次响应限额不影响其他请求,已知/未知长度、chunked、自动 gzip 解压后的限制、空成功体、多 JSON 文档、状态错误、调用后关闭。
|
||||||
|
- 错误响应:已知小体复用连接;读取大小有上限;已知但缓慢的错误体最多花 25ms 尝试读取(标准 Transport 遵守 context),未知流直接关闭,不等完整 body。
|
||||||
|
- 生命周期:Close 不中断已开始的调用,拒绝新调用;借用 Transport 生命周期保持项目所有。既有重试、总超时、请求 body 拒绝时关闭、重定向不跟随测试保留。
|
||||||
|
- 日志不含 URL 路径、查询、URL 密码、Authorization 或响应体;标准返回的 URL 错误不承诺自动脱敏,项目应自行选择安全日志字段。
|
||||||
|
|
||||||
|
全量 `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./...`:48 个测试包通过,包含实际生成项目回归。另验证 Go 1.22.12 的 httpc/示例、vet、build 与 managedService 生成检查。没有改动生成模板的兼容门槛;CLI 当前版本与最低模板版本分开,v0.20 的生成能力保持兼容。
|
||||||
|
|
||||||
|
请求头 Fuzz:30s / 4 worker,1104189 次执行,无失败,总收尾时间 31.293s。模糊输入上限 8 KiB,验证被接受的头可以由标准 Request writer 序列化;不等同形式化安全证明。
|
||||||
|
|
||||||
|
## 本机性能对比
|
||||||
|
|
||||||
|
Go 1.27.1 / darwin arm64 / Apple M2 Max,关闭请求日志;每项 500ms,三轮取中位数。基线源代码为 `068e85132f74edccb7657629b162505d038154f7`(v0.20 发布后文档提交),使用相同的基准函数。
|
||||||
|
|
||||||
|
前七项使用内存 RoundTripper 测量包装/JSON 开销;Pool 项使用本地 TCP 服务;Headers 项是新增选项。原始记录:[修改前](httpcBaselineBenchmark.txt)、[修改后](httpcBenchmark.txt)。列内单位依次为 ns/op、B/op、allocs/op。
|
||||||
|
|
||||||
|
| 场景 | 修改前 | 修改后 |
|
||||||
|
| --- | ---: | ---: |
|
||||||
|
| Client/128B/decode | 1681 / 2861 / 25 | 1408 / 2348 / 22 |
|
||||||
|
| Client/128B/discard | 1226 / 2856 / 25 | 1070 / 1836 / 21 |
|
||||||
|
| Client/4KiB/decode | 7590 / 17599 / 34 | 7650 / 17085 / 31 |
|
||||||
|
| Client/4KiB/discard | 2775 / 13480 / 33 | 1130 / 1836 / 21 |
|
||||||
|
| Client/64KiB/decode | 82261 / 214437 / 41 | 83673 / 213925 / 38 |
|
||||||
|
| Client/64KiB/discard | 17397 / 148648 / 40 | 2285 / 1836 / 21 |
|
||||||
|
| Client/Do | 843.6 / 1808 / 20 | 712.1 / 1296 / 17 |
|
||||||
|
| Pool/OK | 39726 / 7338 / 79 | 39598 / 6372 / 75 |
|
||||||
|
| Pool/Unauthorized | 42912 / 7333 / 83 | 41195 / 6503 / 77 |
|
||||||
|
| Headers | 新增能力 | 1761 / 3059 / 30 |
|
||||||
|
|
||||||
|
64 KiB discard 的分配从约 149 KB 降到 1.8 KB,不再为丢弃响应整体 ReadAll;普通 Do 少一次请求复制,也不在成功路径计算退避。大 JSON 解码耗时基本持平(4 KiB 约 +0.8%,64 KiB 约 +1.7%),分配略减;本轮没有更换 JSON 实现,也不宣称每个场景都更快。Headers 有独立快照/请求复制成本,见新增场景。
|
||||||
|
|
||||||
|
这些数字不是公网吞吐、冷 DNS、TLS 建连或高压容量承诺。连接容量与超时用确定性本地场景验证,未对线上资源施压。
|
||||||
|
|
||||||
|
## 兼容与使用
|
||||||
|
|
||||||
|
原 JSON 直接调用保持有效;自定义 interface/函数值中的 JSON 方法签名须补 variadic RequestOption。过去自定义 Transport 下被忽略的 MaxConnsPerHost,现在与其他代理/阶段选项一起明确拒绝混配;应配置借用 Transport 本身。
|
||||||
|
|
||||||
|
无新增第三方依赖。出站实现仍是标准 net/http,Do/Transport 保持通用扩展;HTTP 状态与业务响应码由调用项目解释。完整 [httpc 指南](../httpc/README.md)、[运行示例](../examples/httpClient/README.md)。远程固定版本安装验证在发布后追加。
|
||||||
33
reports/httpcBaselineBenchmark.txt
Normal file
33
reports/httpcBaselineBenchmark.txt
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
goos: darwin
|
||||||
|
goarch: arm64
|
||||||
|
pkg: git.yuntuo.hk/k2safe/go-omni/httpc
|
||||||
|
cpu: Apple M2 Max
|
||||||
|
BenchmarkHTTPClient/128B/decode-12 386178 2005 ns/op 2861 B/op 25 allocs/op
|
||||||
|
BenchmarkHTTPClient/128B/decode-12 382789 1681 ns/op 2861 B/op 25 allocs/op
|
||||||
|
BenchmarkHTTPClient/128B/decode-12 374196 1561 ns/op 2861 B/op 25 allocs/op
|
||||||
|
BenchmarkHTTPClient/128B/discard-12 453388 1243 ns/op 2856 B/op 25 allocs/op
|
||||||
|
BenchmarkHTTPClient/128B/discard-12 512002 1226 ns/op 2856 B/op 25 allocs/op
|
||||||
|
BenchmarkHTTPClient/128B/discard-12 511116 1221 ns/op 2856 B/op 25 allocs/op
|
||||||
|
BenchmarkHTTPClient/4KiB/decode-12 79958 7905 ns/op 17599 B/op 34 allocs/op
|
||||||
|
BenchmarkHTTPClient/4KiB/decode-12 82110 7590 ns/op 17599 B/op 34 allocs/op
|
||||||
|
BenchmarkHTTPClient/4KiB/decode-12 81975 7333 ns/op 17598 B/op 34 allocs/op
|
||||||
|
BenchmarkHTTPClient/4KiB/discard-12 234456 2796 ns/op 13480 B/op 33 allocs/op
|
||||||
|
BenchmarkHTTPClient/4KiB/discard-12 216720 2764 ns/op 13480 B/op 33 allocs/op
|
||||||
|
BenchmarkHTTPClient/4KiB/discard-12 225117 2775 ns/op 13480 B/op 33 allocs/op
|
||||||
|
BenchmarkHTTPClient/64KiB/decode-12 7014 82261 ns/op 214441 B/op 41 allocs/op
|
||||||
|
BenchmarkHTTPClient/64KiB/decode-12 7381 82513 ns/op 214437 B/op 41 allocs/op
|
||||||
|
BenchmarkHTTPClient/64KiB/decode-12 7546 81872 ns/op 214430 B/op 41 allocs/op
|
||||||
|
BenchmarkHTTPClient/64KiB/discard-12 36520 17397 ns/op 148648 B/op 40 allocs/op
|
||||||
|
BenchmarkHTTPClient/64KiB/discard-12 35896 17424 ns/op 148649 B/op 40 allocs/op
|
||||||
|
BenchmarkHTTPClient/64KiB/discard-12 33877 17195 ns/op 148648 B/op 40 allocs/op
|
||||||
|
BenchmarkHTTPClient/Do-12 640717 846.8 ns/op 1808 B/op 20 allocs/op
|
||||||
|
BenchmarkHTTPClient/Do-12 738248 843.6 ns/op 1808 B/op 20 allocs/op
|
||||||
|
BenchmarkHTTPClient/Do-12 695224 830.3 ns/op 1808 B/op 20 allocs/op
|
||||||
|
BenchmarkHTTPPool/OK-12 15036 40489 ns/op 7338 B/op 79 allocs/op
|
||||||
|
BenchmarkHTTPPool/OK-12 15148 39087 ns/op 7337 B/op 79 allocs/op
|
||||||
|
BenchmarkHTTPPool/OK-12 14715 39726 ns/op 7338 B/op 79 allocs/op
|
||||||
|
BenchmarkHTTPPool/Unauthorized-12 13894 42471 ns/op 7333 B/op 83 allocs/op
|
||||||
|
BenchmarkHTTPPool/Unauthorized-12 14020 43046 ns/op 7351 B/op 83 allocs/op
|
||||||
|
BenchmarkHTTPPool/Unauthorized-12 13992 42912 ns/op 7328 B/op 83 allocs/op
|
||||||
|
PASS
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/httpc 20.475s
|
||||||
36
reports/httpcBenchmark.txt
Normal file
36
reports/httpcBenchmark.txt
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
goos: darwin
|
||||||
|
goarch: arm64
|
||||||
|
pkg: git.yuntuo.hk/k2safe/go-omni/httpc
|
||||||
|
cpu: Apple M2 Max
|
||||||
|
BenchmarkHTTPClient/128B/decode-12 403567 1408 ns/op 2348 B/op 22 allocs/op
|
||||||
|
BenchmarkHTTPClient/128B/decode-12 462315 1400 ns/op 2348 B/op 22 allocs/op
|
||||||
|
BenchmarkHTTPClient/128B/decode-12 416222 1427 ns/op 2348 B/op 22 allocs/op
|
||||||
|
BenchmarkHTTPClient/128B/discard-12 595370 1041 ns/op 1837 B/op 21 allocs/op
|
||||||
|
BenchmarkHTTPClient/128B/discard-12 583654 1070 ns/op 1836 B/op 21 allocs/op
|
||||||
|
BenchmarkHTTPClient/128B/discard-12 550502 1086 ns/op 1836 B/op 21 allocs/op
|
||||||
|
BenchmarkHTTPClient/4KiB/decode-12 77176 7547 ns/op 17084 B/op 31 allocs/op
|
||||||
|
BenchmarkHTTPClient/4KiB/decode-12 81969 7677 ns/op 17085 B/op 31 allocs/op
|
||||||
|
BenchmarkHTTPClient/4KiB/decode-12 77571 7650 ns/op 17087 B/op 31 allocs/op
|
||||||
|
BenchmarkHTTPClient/4KiB/discard-12 544194 1132 ns/op 1836 B/op 21 allocs/op
|
||||||
|
BenchmarkHTTPClient/4KiB/discard-12 539278 1128 ns/op 1836 B/op 21 allocs/op
|
||||||
|
BenchmarkHTTPClient/4KiB/discard-12 522939 1130 ns/op 1836 B/op 21 allocs/op
|
||||||
|
BenchmarkHTTPClient/64KiB/decode-12 6986 84111 ns/op 213930 B/op 38 allocs/op
|
||||||
|
BenchmarkHTTPClient/64KiB/decode-12 7156 83673 ns/op 213925 B/op 38 allocs/op
|
||||||
|
BenchmarkHTTPClient/64KiB/decode-12 7516 83041 ns/op 213921 B/op 38 allocs/op
|
||||||
|
BenchmarkHTTPClient/64KiB/discard-12 270482 2285 ns/op 1836 B/op 21 allocs/op
|
||||||
|
BenchmarkHTTPClient/64KiB/discard-12 253564 2259 ns/op 1835 B/op 21 allocs/op
|
||||||
|
BenchmarkHTTPClient/64KiB/discard-12 254343 2290 ns/op 1836 B/op 21 allocs/op
|
||||||
|
BenchmarkHTTPClient/Do-12 857305 713.4 ns/op 1296 B/op 17 allocs/op
|
||||||
|
BenchmarkHTTPClient/Do-12 811660 712.1 ns/op 1296 B/op 17 allocs/op
|
||||||
|
BenchmarkHTTPClient/Do-12 861867 708.3 ns/op 1296 B/op 17 allocs/op
|
||||||
|
BenchmarkHTTPPool/OK-12 14582 40133 ns/op 6374 B/op 75 allocs/op
|
||||||
|
BenchmarkHTTPPool/OK-12 15596 38152 ns/op 6372 B/op 75 allocs/op
|
||||||
|
BenchmarkHTTPPool/OK-12 15513 39598 ns/op 6369 B/op 75 allocs/op
|
||||||
|
BenchmarkHTTPPool/Unauthorized-12 14952 41483 ns/op 6503 B/op 77 allocs/op
|
||||||
|
BenchmarkHTTPPool/Unauthorized-12 14442 40927 ns/op 6516 B/op 77 allocs/op
|
||||||
|
BenchmarkHTTPPool/Unauthorized-12 14697 41195 ns/op 6498 B/op 77 allocs/op
|
||||||
|
BenchmarkHTTPHeaders-12 319768 1761 ns/op 3059 B/op 30 allocs/op
|
||||||
|
BenchmarkHTTPHeaders-12 334965 1731 ns/op 3059 B/op 30 allocs/op
|
||||||
|
BenchmarkHTTPHeaders-12 348751 1774 ns/op 3059 B/op 30 allocs/op
|
||||||
|
PASS
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/httpc 21.809s
|
||||||
@@ -135,6 +135,17 @@ def main():
|
|||||||
assert len(sums) == len(tests) and all(s == dependency["Sum"] for s in sums), "remote consumer checksum mismatch"
|
assert len(sums) == len(tests) and all(s == dependency["Sum"] for s in sums), "remote consumer checksum mismatch"
|
||||||
checks += ["initializationAndListenFailureCleanup", "drainBeforeDependencyClose", "generatedConsumerRaceTests", "errorParamsAndInheritance", "manualHooksAndScalars", "dependencyFatalShutdown", "fatalProcessExitCode", "initializationShutdownNotification", "configuredBuiltinEndpoints", "projectHealthEnvelope", "endpointConflictCleanup", "responseArrayNullableNull", "primitiveAndDynamicArrays", "strictRequestPolicies", "declarativeMultipartBinding", "backgroundStopDrainResourceOrder", "backgroundInitAndListenFailureCleanup", "backgroundTimeoutRetainsResourcesAndExits"]
|
checks += ["initializationAndListenFailureCleanup", "drainBeforeDependencyClose", "generatedConsumerRaceTests", "errorParamsAndInheritance", "manualHooksAndScalars", "dependencyFatalShutdown", "fatalProcessExitCode", "initializationShutdownNotification", "configuredBuiltinEndpoints", "projectHealthEnvelope", "endpointConflictCleanup", "responseArrayNullableNull", "primitiveAndDynamicArrays", "strictRequestPolicies", "declarativeMultipartBinding", "backgroundStopDrainResourceOrder", "backgroundInitAndListenFailureCleanup", "backgroundTimeoutRetainsResourcesAndExits"]
|
||||||
|
|
||||||
|
# Run tests from the downloaded module, not the framework checkout.
|
||||||
|
http_output = run(["go", "test", "-race", "-count=1", "-json", MODULE + "/httpc"], starter, env)
|
||||||
|
http_events = [json.loads(line) for line in http_output.splitlines() if line.startswith("{")]
|
||||||
|
http_passed = {e.get("Test") for e in http_events if e.get("Action") == "pass"}
|
||||||
|
assert {"TestProxyRouting", "TestJSONHeadersConcurrentSnapshot", "TestPoolLimitsAndIdleExpiry",
|
||||||
|
"TestErrorBodyPoolReuseAndDrainBound", "TestSlowKnownErrorBodyHasShortDrainBudget"} <= http_passed
|
||||||
|
example_output = run(["go", "run", MODULE + "/examples/httpClient"], starter, env)
|
||||||
|
assert "authenticated request succeeded" in example_output
|
||||||
|
checks += ["remoteHTTPClientRaceTests", "remoteHTTPProxyAndTimeouts", "remoteHTTPHeaderIsolation",
|
||||||
|
"remoteHTTPPoolAndDrainBudgets", "remoteHTTPExample"]
|
||||||
|
|
||||||
checks += ["flatFieldComposition", "copyxThreeEndpointConsumer", "explicitNullAndMoneyPolicies", "fieldAdditionWithoutCopyRuleChanges", "middlewareFactoryInjectionAndCleanup", "optionalRepositoryPreservesBusiness"]
|
checks += ["flatFieldComposition", "copyxThreeEndpointConsumer", "explicitNullAndMoneyPolicies", "fieldAdditionWithoutCopyRuleChanges", "middlewareFactoryInjectionAndCleanup", "optionalRepositoryPreservesBusiness"]
|
||||||
|
|
||||||
checks += ["generatedUnifiedConfig", "jsonYamlXmlAndCustomDecoder", "configValidationBeforeDependencies", "configEnvProvenance", "configExtensionPreserved", "configLoaderDriftCheck"]
|
checks += ["generatedUnifiedConfig", "jsonYamlXmlAndCustomDecoder", "configValidationBeforeDependencies", "configEnvProvenance", "configExtensionPreserved", "configLoaderDriftCheck"]
|
||||||
@@ -149,6 +160,8 @@ def main():
|
|||||||
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "copyx").glob("*.go")]
|
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "copyx").glob("*.go")]
|
||||||
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "config").glob("*.go")]
|
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "config").glob("*.go")]
|
||||||
sources += ["cmd/go-omni/apigen/configLoader_test.go", "cmd/go-omni/apigen/testdata/configuration/consumer_test.go", "cmd/go-omni/scaffold/templates/api-load.go.tmpl", "cmd/go-omni/scaffold/templates/api-loader.go.tmpl"]
|
sources += ["cmd/go-omni/apigen/configLoader_test.go", "cmd/go-omni/apigen/testdata/configuration/consumer_test.go", "cmd/go-omni/scaffold/templates/api-load.go.tmpl", "cmd/go-omni/scaffold/templates/api-loader.go.tmpl"]
|
||||||
|
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "httpc").glob("*.go")]
|
||||||
|
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "examples/httpClient").glob("*.go")]
|
||||||
report = {"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"],
|
report = {"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"],
|
||||||
"freshModuleCache": True, "noLocalReplace": True,
|
"freshModuleCache": True, "noLocalReplace": True,
|
||||||
"durationSeconds": round(time.monotonic() - started, 2), "checks": checks,
|
"durationSeconds": round(time.monotonic() - started, 2), "checks": checks,
|
||||||
|
|||||||
Reference in New Issue
Block a user