滑动续期、网络错误归类、API Key 激活乐观锁与生效提示

This commit is contained in:
2026-08-11 11:45:03 +08:00
parent 23b2820101
commit e63af49831
21 changed files with 679 additions and 15 deletions
+69
View File
@@ -1,7 +1,11 @@
package oci
import (
"context"
"errors"
"fmt"
"net"
"net/url"
"regexp"
"strings"
@@ -16,6 +20,7 @@ var ociErrorHints = map[string]string{
"QuotaExceeded": "compartment 配额不足",
"OutOfHostCapacity": "该可用域容量不足,可稍后重试或改用抢机任务",
"TooManyRequests": "请求过于频繁,请稍后再试",
"NotAuthenticated": "OCI 拒绝了请求签名:密钥无效或尚未生效。若刚替换过 API Key,全球生效需数分钟,请稍后重试",
"InvalidParameter": "请求参数无效",
"InternalError": "OCI 服务内部错误,请稍后重试",
"Conflict": "资源正在变更中,请稍后重试",
@@ -24,6 +29,9 @@ var ociErrorHints = map[string]string{
// ocidRe 匹配消息中的完整 OCID(unique 段 20 位以上才压缩,避免误伤短标识)。
var ocidRe = regexp.MustCompile(`ocid1\.([a-z0-9]+)\.[a-z0-9]*\.[a-z0-9-]*\.{1,2}[a-z0-9]{20,}`)
// proxyUserinfoRe 只匹配网络错误文本里位于 token / URL 起点的 userinfo@。
var proxyUserinfoRe = regexp.MustCompile(`(^|[\s/("'=])[^@\s/]+@`)
// shortenOcids 把消息里的长 OCID 压缩为「ocid1.<类型>…<尾6位>」,
// 保留资源类型与可比对的尾部,避免整条错误被 OCID 撑爆。
func shortenOcids(s string) string {
@@ -68,6 +76,67 @@ func ErrorHint(err error) string {
return ociErrorHints[svcErr.GetCode()]
}
// UpstreamNetworkError 判定错误链是否为上游连接层失败(代理拨号 / SOCKS 握手 /
// 超时 / DNS 等),命中返回给用户看的精简描述。已拿到 OCI 响应的 ServiceError
// 不在此列;判定保守:宁可漏归 500,不把业务错误误标为网络问题。
func UpstreamNetworkError(err error) (string, bool) {
var svcErr common.ServiceError
if errors.As(err, &svcErr) {
return "", false
}
var uerr *url.Error
if errors.As(err, &uerr) {
return classifyNetDetail(redactURLError(uerr)), true
}
if errors.Is(err, context.DeadlineExceeded) {
return "上游连接失败: 请求超时", true
}
var nerr net.Error
if errors.As(err, &nerr) && nerr.Timeout() {
return "上游连接失败: 连接超时", true
}
return "", false
}
// redactURLError 把 url.Error 的完整 URL 压缩为主机,并递归处理内层 url.Error。
func redactURLError(uerr *url.Error) string {
host := "<redacted-url>"
if u, err := url.Parse(uerr.URL); err == nil && u.Host != "" {
host = u.Host
}
return fmt.Sprintf("%s %s: %s", uerr.Op, host, redactURLCause(uerr.Err))
}
// redactURLCause 保留普通包装前缀,但用脱敏文本替换其中嵌套的 url.Error。
func redactURLCause(err error) string {
if err == nil {
return "unknown network error"
}
var nested *url.Error
if !errors.As(err, &nested) {
return redactProxyUserinfo(err.Error())
}
raw, nestedRaw := err.Error(), nested.Error()
if strings.Contains(raw, nestedRaw) {
raw = strings.Replace(raw, nestedRaw, redactURLError(nested), 1)
return redactProxyUserinfo(raw)
}
return redactURLError(nested)
}
// redactProxyUserinfo 清除存量非法代理 host 可能写入普通错误文本的 userinfo。
func redactProxyUserinfo(detail string) string {
return proxyUserinfoRe.ReplaceAllString(detail, "$1")
}
// classifyNetDetail 按细节特征加中文分类前缀:代理链路失败与一般上游失败分开表述。
func classifyNetDetail(detail string) string {
if strings.Contains(detail, "proxyconnect") || strings.Contains(detail, "socks connect") {
return "代理连接失败: " + detail
}
return "上游连接失败: " + detail
}
// ServiceStatus 返回错误链中 OCI ServiceError 的 HTTP 状态码;非服务端错误 ok=false。
func ServiceStatus(err error) (int, bool) {
var svcErr common.ServiceError
+110
View File
@@ -1,8 +1,10 @@
package oci
import (
"context"
"errors"
"fmt"
"net/url"
"testing"
)
@@ -95,6 +97,11 @@ func TestErrorHint(t *testing.T) {
err: fmt.Errorf("launch: %w", fakeServiceError{500, "InternalError", "Out of host capacity."}),
want: ociErrorHints["OutOfHostCapacity"],
},
{
name: "NotAuthenticated 提示密钥无效或传播中",
err: fmt.Errorf("get instance: %w", fakeServiceError{401, "NotAuthenticated", "The required information ..."}),
want: ociErrorHints["NotAuthenticated"],
},
{
name: "未知错误码返回空串",
err: fmt.Errorf("x: %w", fakeServiceError{400, "SomethingNew", "boom"}),
@@ -158,3 +165,106 @@ func TestIsModelUnavailable(t *testing.T) {
})
}
}
// fakeTimeoutErr 模拟实现 net.Error 的超时错误(如响应体读取超时)。
type fakeTimeoutErr struct{}
func (fakeTimeoutErr) Error() string { return "read tcp 10.0.0.1:443: i/o timeout" }
func (fakeTimeoutErr) Timeout() bool { return true }
func (fakeTimeoutErr) Temporary() bool { return false }
type upstreamNetworkErrorCase struct {
name string
err error
want string
wantOK bool
}
var upstreamNetworkErrorCases = []upstreamNetworkErrorCase{
{
name: "OCI 服务错误不归网络",
err: fmt.Errorf("get instance: %w", fakeServiceError{status: 401, code: "NotAuthenticated", message: "x"}),
wantOK: false,
},
{
name: "普通业务错误不归网络",
err: errors.New("parse payload: bad json"),
wantOK: false,
},
{
name: "代理 CONNECT 失败带中文分类且 URL 缩为主机",
err: fmt.Errorf("list instances: %w", &url.Error{
Op: "Get",
URL: "https://iaas.uk-london-1.oraclecloud.com/20160918/instances?limit=100",
Err: errors.New("proxyconnect tcp: dial tcp 1.2.3.4:8080: connect: connection refused"),
}),
want: "代理连接失败: Get iaas.uk-london-1.oraclecloud.com: proxyconnect tcp: dial tcp 1.2.3.4:8080: connect: connection refused",
wantOK: true,
},
{
name: "SOCKS 握手失败归代理类",
err: &url.Error{
Op: "Post",
URL: "https://identity.us-sanjose-1.oci.oraclecloud.com/20160918/users",
Err: errors.New("socks connect tcp 5.6.7.8:1080->identity: dial refused"),
},
want: "代理连接失败: Post identity.us-sanjose-1.oci.oraclecloud.com: socks connect tcp 5.6.7.8:1080->identity: dial refused",
wantOK: true,
},
{
name: "URL 内嵌 userinfo 不回显",
err: &url.Error{
Op: "Get",
URL: "https://user:pass@example.com/path",
Err: errors.New("EOF"),
},
want: "上游连接失败: Get example.com: EOF",
wantOK: true,
},
{
name: "嵌套 URL 的代理 userinfo 不回显",
err: &url.Error{
Op: "Get",
URL: "https://identity.us-ashburn-1.oraclecloud.com/20160918/tenancies/x",
Err: fmt.Errorf("proxyconnect tcp: %w", &url.Error{
Op: "Dial", URL: "http://proxy-user:proxy-pass@proxy.example.com:8080",
Err: errors.New("connect: connection refused"),
}),
},
want: "代理连接失败: Get identity.us-ashburn-1.oraclecloud.com: proxyconnect tcp: Dial proxy.example.com:8080: connect: connection refused",
wantOK: true,
},
{
name: "普通内层错误中的存量代理 userinfo 不回显",
err: &url.Error{
Op: "Get",
URL: "https://identity.us-phoenix-1.oraclecloud.com/20160918/tenancies/x",
Err: errors.New("proxyconnect tcp: dial tcp alice:secret@proxy.example.com:8080: connect: connection refused"),
},
want: "代理连接失败: Get identity.us-phoenix-1.oraclecloud.com: proxyconnect tcp: dial tcp proxy.example.com:8080: connect: connection refused",
wantOK: true,
},
{
name: "context 超时归网络",
err: fmt.Errorf("summarize costs: %w", context.DeadlineExceeded),
want: "上游连接失败: 请求超时",
wantOK: true,
},
{
name: "net.Error 超时归网络",
err: fmt.Errorf("read body: %w", fakeTimeoutErr{}),
want: "上游连接失败: 连接超时",
wantOK: true,
},
}
func TestUpstreamNetworkError(t *testing.T) {
for _, tt := range upstreamNetworkErrorCases {
t.Run(tt.name, func(t *testing.T) {
got, ok := UpstreamNetworkError(tt.err)
if ok != tt.wantOK || got != tt.want {
t.Errorf("UpstreamNetworkError() = (%q, %v), want (%q, %v)", got, ok, tt.want, tt.wantOK)
}
})
}
}