Merge branch 'feat/m42-logchannel-channel-and-tool-url-plus' into dev
This commit is contained in:
commit
f37fe05aee
11 files changed
+885
-30
No files matched your search
@@ -23,6 +23,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// ErrAPI is the sentinel returned when the remote API reports a non-zero code or
|
||||
@@ -129,7 +130,13 @@ func (c *Client) resolve(path string) string {
|
||||
}
|
||||
|
||||
// do performs the request, injecting auth and decoding the envelope.
|
||||
//
|
||||
// 地址在这一层只以来源形态出镜:target 由调用方给(GetJSON 的 query 可能带凭据、
|
||||
// 绝对 path 可能带 user:pass@),完整串既不进错误也不进日志。响应体原文同理 ——
|
||||
// 上游可能把被拒的输入回显在体里,因此 4xx/5xx 只收敛成状态码 + 信封的
|
||||
// code/message(清洗并截断),其余字节一概不留。
|
||||
func (c *Client) do(ctx context.Context, method, target string, body any) (json.RawMessage, error) {
|
||||
host := trace.HostOf(target)
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
if err := json.NewEncoder(&buf).Encode(body); err != nil {
|
||||
@@ -138,7 +145,7 @@ func (c *Client) do(ctx context.Context, method, target string, body any) (json.
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, target, &buf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpclient: build %s %s request: %w", method, target, err)
|
||||
return nil, fmt.Errorf("httpclient: build %s %s request: %w", method, host, transportCause(err))
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
@@ -152,16 +159,16 @@ func (c *Client) do(ctx context.Context, method, target string, body any) (json.
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpclient: %s %s: %w", method, target, err)
|
||||
return nil, fmt.Errorf("httpclient: %s %s: %w", method, host, transportCause(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpclient: read %s %s response: %w", method, target, err)
|
||||
return nil, fmt.Errorf("httpclient: read %s %s response: %w", method, host, transportCause(err))
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("httpclient: %s %s: status %d: %s: %w", method, target, resp.StatusCode, truncate(raw), ErrAPI)
|
||||
return nil, apiFailure(method, host, resp, raw)
|
||||
}
|
||||
|
||||
var envelope APIEnvelope
|
||||
@@ -169,15 +176,49 @@ func (c *Client) do(ctx context.Context, method, target string, body any) (json.
|
||||
return raw, nil
|
||||
}
|
||||
if envelope.Code != 0 {
|
||||
return nil, fmt.Errorf("httpclient: %s %s: code %d: %s: %w", method, target, envelope.Code, envelope.Message, ErrAPI)
|
||||
return nil, fmt.Errorf("httpclient: %s %s: code %d: %s: %w",
|
||||
method, host, envelope.Code, envelopeMessage(envelope.Message), ErrAPI)
|
||||
}
|
||||
return envelope.Data, nil
|
||||
}
|
||||
|
||||
// truncate caps a byte slice to 512 bytes for inclusion in an error message.
|
||||
func truncate(b []byte) string {
|
||||
if len(b) > 512 {
|
||||
return strings.TrimSpace(string(b[:512])) + "…"
|
||||
// apiFailure 把一次非 2xx 响应收敛成一条不携带响应体原文的错误:来源主机 + 状态码,
|
||||
// 外加(当体确实是 `{code,message}` 信封时)清洗并截断过的 code/message。其余字节只
|
||||
// 经 trace.UpstreamError 留下体积与 sha256 前缀,供「是不是同一份报文」这类相关性判断。
|
||||
func apiFailure(method, host string, resp *http.Response, raw []byte) error {
|
||||
upstream := trace.UpstreamError(host, resp.StatusCode, resp.Header.Get("Content-Type"), raw)
|
||||
var envelope APIEnvelope
|
||||
if err := json.Unmarshal(raw, &envelope); err == nil {
|
||||
message := envelopeMessage(envelope.Message)
|
||||
switch {
|
||||
case envelope.Code != 0 && message != "":
|
||||
return fmt.Errorf("httpclient: %s %s: code %d: %s: %w: %w",
|
||||
method, host, envelope.Code, message, upstream, ErrAPI)
|
||||
case envelope.Code != 0:
|
||||
return fmt.Errorf("httpclient: %s %s: code %d: %w: %w", method, host, envelope.Code, upstream, ErrAPI)
|
||||
case message != "":
|
||||
return fmt.Errorf("httpclient: %s %s: %s: %w: %w", method, host, message, upstream, ErrAPI)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
return fmt.Errorf("httpclient: %s: %w: %w", method, upstream, ErrAPI)
|
||||
}
|
||||
|
||||
// envelopeMessage 是信封 message 进入错误前的唯一处理:压平空白,再交给
|
||||
// trace.RedactText —— 它既清洗凭据形状,也把长度压到 80 rune(超出的带省略号)。
|
||||
// 上游常把被拒的输入回显在 message 里,因此这一层不是可选的。
|
||||
func envelopeMessage(s string) string {
|
||||
return trace.RedactText(strings.Join(strings.Fields(s), " "))
|
||||
}
|
||||
|
||||
// transportCause 剥掉 net/http 的 *url.Error 外壳:它会把**完整请求地址**拼进错误串
|
||||
// (query 里的凭据、user:pass@ 都会跟着走)。剥壳保留内层哨兵链
|
||||
// (context.DeadlineExceeded 等仍可 errors.Is),因此只丢地址、不丢原因。
|
||||
// 与 internal/channel/mediafetch 的 sanitizeErr 同一口径 —— 那份未导出,且服务于入站
|
||||
// 取回路径,本包不能反向依赖它。
|
||||
func transportCause(err error) error {
|
||||
var uerr *url.Error
|
||||
if errors.As(err, &uerr) && uerr.Err != nil {
|
||||
return uerr.Err
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package httpclient
|
||||
|
||||
// 本文件钉住本客户端的两条出镜边界(M41 #10/#11 那一族):
|
||||
//
|
||||
// - **非 2xx 的响应体原文不进错误**:旧实现把体截到 512B 拼进 error,而调用方
|
||||
// (KOOK 适配器)会把这条 error 直接落日志 —— 上游在体里回显什么,日志里就有
|
||||
// 什么。现在只留状态码、来源主机,以及信封的 code/message(已清洗、已截断)。
|
||||
// - **请求地址的凭据不进错误**:target 可能带 `?access_token=…` 或 `user:pass@`,
|
||||
// 而 net/http 的 *url.Error 会把整条地址拼进错误串。
|
||||
//
|
||||
// 每条用例都是「把它改回去就变红」的形状:断言的是**错误文本**,也就是调用方实际
|
||||
// 打进日志的那段字符串。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// failingTransport 让 http.Client.Do 以传输层错误收场。选定它而不是拨一个真端口,
|
||||
// 是因为只有传输层失败才会让 net/http 包出 **带完整地址** 的 *url.Error ——
|
||||
// 那正是要钉住的泄漏形态。
|
||||
type failingTransport struct{}
|
||||
|
||||
func (failingTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("dial tcp: connection refused")
|
||||
}
|
||||
|
||||
// TestDoJSON_StatusErrorDoesNotEchoBodySentinel 4xx 体里放哨兵 ⇒ 错误里搜不到。
|
||||
//
|
||||
// 用非信封体(代理返回的 HTML 错误页就是这样):信封类的体按设计会保留
|
||||
// code/message,非信封体则一个字节都不留。
|
||||
func TestDoJSON_StatusErrorDoesNotEchoBodySentinel(t *testing.T) {
|
||||
t.Parallel()
|
||||
const sentinel = "SENTINEL-4XX-BODY"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte("<html><body>bad gateway " + sentinel + "</body></html>"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
|
||||
_, err := c.DoJSON(context.Background(), http.MethodPost, "/message/create", map[string]any{"a": 1})
|
||||
if err == nil {
|
||||
t.Fatal("502 却报成功")
|
||||
}
|
||||
if !errors.Is(err, ErrAPI) {
|
||||
t.Fatalf("err = %v, want ErrAPI", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), sentinel) {
|
||||
t.Fatalf("响应体原文进了错误(会随调用方落日志):%v", err)
|
||||
}
|
||||
// 可诊断性不能一起丢:来源主机与状态码必须还在,且带得出「体有多大」。
|
||||
host, _, ok := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
|
||||
if !ok || !strings.Contains(err.Error(), host) {
|
||||
t.Fatalf("错误里没有来源主机 %q:%v", host, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "status=502") {
|
||||
t.Fatalf("错误里没有状态码:%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "body_bytes=") {
|
||||
t.Fatalf("错误里没有响应体体积(相关性判断要用):%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoJSON_StatusErrorKeepsEnvelopeCode 信封体仍要给出 code/message:这是本客户端
|
||||
// 唯一认识的诊断面,替换掉体原文之后不能连它一起丢。
|
||||
func TestDoJSON_StatusErrorKeepsEnvelopeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"code":40101,"message":"invalid token","data":null}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
|
||||
_, err := c.DoJSON(context.Background(), http.MethodGet, "/gateway/index", nil)
|
||||
if err == nil {
|
||||
t.Fatal("401 却报成功")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "code 40101") || !strings.Contains(err.Error(), "invalid token") {
|
||||
t.Fatalf("信封的 code/message 丢了:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoJSON_NonZeroCodeRedactsMessageSecrets 信封 message 是上游自由文本面:里面的
|
||||
// 凭据形状必须被清洗掉,长度必须被截断。改回原样透传就变红。
|
||||
func TestDoJSON_NonZeroCodeRedactsMessageSecrets(t *testing.T) {
|
||||
t.Parallel()
|
||||
const secret = "big-secret-value-123456"
|
||||
body := `{"code":40101,"message":"invalid access_token=` + secret + `","data":null}`
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()), WithToken(secret))
|
||||
_, err := c.DoJSON(context.Background(), http.MethodGet, "/gateway/index", nil)
|
||||
if err == nil {
|
||||
t.Fatal("非零 code 却报成功")
|
||||
}
|
||||
if strings.Contains(err.Error(), secret) {
|
||||
t.Fatalf("信封 message 里的凭据原样进了错误:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetJSON_TransportErrorOmitsTargetCredentials 传输层失败时,错误里不得出现目标
|
||||
// 地址(含 query 里的凭据)—— net/http 会把整条 URL 放在 *url.Error 里。
|
||||
func TestGetJSON_TransportErrorOmitsTargetCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
const secret = "QUERY-SECRET-VALUE"
|
||||
c := New(
|
||||
WithBaseURL("http://api.example.com"),
|
||||
WithHTTPClient(&http.Client{Transport: failingTransport{}}),
|
||||
WithLogger(discardLogger()),
|
||||
)
|
||||
_, err := c.GetJSON(context.Background(), "/gateway/index", url.Values{"access_token": {secret}})
|
||||
if err == nil {
|
||||
t.Fatal("传输层失败却报成功")
|
||||
}
|
||||
if strings.Contains(err.Error(), secret) {
|
||||
t.Fatalf("目标地址的凭据进了错误:%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "api.example.com") || !strings.Contains(err.Error(), "connection refused") {
|
||||
t.Fatalf("剥掉地址时把来源或原因一起丢了:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetJSON_UserinfoNeverEntersError 绝对地址带 user:pass@ 时同样不得出镜。
|
||||
func TestGetJSON_UserinfoNeverEntersError(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := New(
|
||||
WithHTTPClient(&http.Client{Transport: failingTransport{}}),
|
||||
WithLogger(discardLogger()),
|
||||
)
|
||||
_, err := c.GetJSON(context.Background(), "https://user:pass@api.example.com/x", nil)
|
||||
if err == nil {
|
||||
t.Fatal("传输层失败却报成功")
|
||||
}
|
||||
if strings.Contains(err.Error(), "user:pass") {
|
||||
t.Fatalf("user:pass@ 进了错误:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoJSON_EnvelopeMessageIsBoundedByRedactText 钉住长度口径:信封 message 只有一道
|
||||
// 清洗(trace.RedactText,80 rune + 省略号)。本片曾按 200 rune 自己再截一刀,那层
|
||||
// 永远命中不了 —— 这条用例让「到底哪一层在截」变成可断言的事实。
|
||||
func TestDoJSON_EnvelopeMessageIsBoundedByRedactText(t *testing.T) {
|
||||
t.Parallel()
|
||||
long := strings.Repeat("a", 300)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"code":40101,"message":"` + long + `","data":null}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
|
||||
_, err := c.DoJSON(context.Background(), http.MethodGet, "/gateway/index", nil)
|
||||
if err == nil {
|
||||
t.Fatal("401 却报成功")
|
||||
}
|
||||
if !strings.Contains(err.Error(), strings.Repeat("a", 80)+"…") {
|
||||
t.Fatalf("信封 message 没有按 80 rune 截断:%v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), strings.Repeat("a", 81)) {
|
||||
t.Fatalf("信封 message 超过了 80 rune 上限:%v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user