feat(cli): classify every provisioning failure with stable codes

This commit is contained in:
Anthony Wong
2026-09-04 16:46:55 +07:00
committed by Cuong Manh Le
parent dd77d865b6
commit 0f821a7907
18 changed files with 2044 additions and 136 deletions
+43 -2
View File
@@ -37,6 +37,16 @@ const (
sendLogTimeout = 300 * time.Second
)
// Provisioning-token rejection reasons the API sends in error.metadata.reason
// (HTTP 400, code 40003). This list can grow; a value outside it is not an
// error, just one cmd/cli does not classify yet.
const (
ReasonTokenInvalid = "token_invalid"
ReasonTokenExpired = "token_expired"
ReasonTokenLimitReached = "token_limit_reached"
ReasonTokenDisabled = "token_disabled"
)
// ResolverConfig represents Control D resolver data.
type ResolverConfig struct {
DOH string `json:"doh"`
@@ -64,10 +74,41 @@ type utilityResponse struct {
} `json:"body"`
}
// errorMetadata carries additive, optional detail on top of Code/Message.
// Older API deployments omit it, so it must decode to its zero value rather
// than fail the whole response. Its custom UnmarshalJSON gives the same
// tolerance to a malformed value: a metadata that is not an object, or a
// Reason that is not a string (a number, an object, or null), degrades to
// the zero value rather than failing the response that contains it.
type errorMetadata struct {
// Reason is a machine-readable rejection reason sent on provisioning-token
// errors (HTTP 400, code 40003): token_invalid, token_expired,
// token_limit_reached, or token_disabled. Empty when absent or malformed;
// callers must treat any other value as unknown rather than reject the
// response.
Reason string `json:"reason"`
}
func (m *errorMetadata) UnmarshalJSON(data []byte) error {
var raw struct {
Reason json.RawMessage `json:"reason"`
}
// Best-effort: a metadata that is not an object, or a reason that is not
// a string (number, object, null), leaves the zero value instead of
// failing this decode. Code and Message still classify the failure.
if err := json.Unmarshal(data, &raw); err != nil {
*m = errorMetadata{}
return nil
}
_ = json.Unmarshal(raw.Reason, &m.Reason)
return nil
}
type ErrorResponse struct {
ErrorField struct {
Message string `json:"message"`
Code int `json:"code"`
Message string `json:"message"`
Code int `json:"code"`
Metadata errorMetadata `json:"metadata"`
} `json:"error"`
// StatusCode is the HTTP status the API answered with. It is not part of the JSON
// body: this type is built for *any* non-200 whose body decodes, so the body alone
+110
View File
@@ -98,6 +98,116 @@ func TestAPIErrorRecordsHTTPStatus(t *testing.T) {
})
}
// TestAPIErrorDecodesRejectionReason pins the additive metadata.reason field the
// API sends on provisioning-token rejections. cmd/cli maps known reasons to their
// own failure codes, and must fall back cleanly when the field is absent or holds
// a value this build does not recognize yet.
func TestAPIErrorDecodesRejectionReason(t *testing.T) {
tests := []struct {
name string
body string
wantReason string
}{
{
name: "known reason",
body: `{"error":{"message":"invalid token","code":40003,"metadata":{"reason":"token_disabled"}}}`,
wantReason: "token_disabled",
},
{
name: "reason absent",
body: `{"error":{"message":"invalid token","code":40003}}`,
wantReason: "",
},
{
name: "unknown reason value",
body: `{"error":{"message":"invalid token","code":40003,"metadata":{"reason":"something_new"}}}`,
wantReason: "something_new",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
d := json.NewDecoder(strings.NewReader(tc.body))
errResp, err := apiErrorFromResponse(http.StatusBadRequest, d)
if err != nil {
t.Fatalf("unexpected decode error: %v", err)
}
if errResp.ErrorField.Metadata.Reason != tc.wantReason {
t.Errorf("reason = %q, want %q", errResp.ErrorField.Metadata.Reason, tc.wantReason)
}
})
}
}
// TestAPIErrorToleratesMalformedRejectionReason pins the fix for a decode error
// confined to metadata.reason: a reason sent as the wrong JSON type must not
// discard the rest of the response. Before this fix, apiErrorFromResponse
// returned the raw decode error and nothing else, which cmd/cli's
// apiFailureCode cannot recognize as an *ErrorResponse - it falls back to
// API_UNREACHABLE (a retryable bootstrap failure) instead of the permanent
// rejection the HTTP status and code actually describe.
func TestAPIErrorToleratesMalformedRejectionReason(t *testing.T) {
tests := []struct {
name string
body string
}{
{name: "reason as a number", body: `{"error":{"message":"invalid token","code":40003,"metadata":{"reason":12345}}}`},
{name: "reason as an object", body: `{"error":{"message":"invalid token","code":40003,"metadata":{"reason":{"inner":"value"}}}}`},
{name: "reason as null", body: `{"error":{"message":"invalid token","code":40003,"metadata":{"reason":null}}}`},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
d := json.NewDecoder(strings.NewReader(tc.body))
errResp, err := apiErrorFromResponse(http.StatusBadRequest, d)
if err != nil {
t.Fatalf("a malformed reason must not fail the whole decode: %v", err)
}
if errResp.ErrorField.Message != "invalid token" {
t.Errorf("message = %q, want it to survive the malformed reason", errResp.ErrorField.Message)
}
if errResp.ErrorField.Code != 40003 {
t.Errorf("code = %d, want it to survive the malformed reason", errResp.ErrorField.Code)
}
if errResp.ErrorField.Metadata.Reason != "" {
t.Errorf("reason = %q, want empty for a malformed value", errResp.ErrorField.Metadata.Reason)
}
})
}
}
// TestAPIErrorToleratesMalformedMetadata pins the same tolerance one level
// up: a metadata field that is not a JSON object must not discard the rest
// of the response. Code and Message still classify the failure, and Reason
// stays empty.
func TestAPIErrorToleratesMalformedMetadata(t *testing.T) {
tests := []struct {
name string
body string
}{
{name: "metadata as a string", body: `{"error":{"message":"invalid token","code":40003,"metadata":"foo"}}`},
{name: "metadata as a number", body: `{"error":{"message":"invalid token","code":40003,"metadata":7}}`},
{name: "metadata as an array", body: `{"error":{"message":"invalid token","code":40003,"metadata":["reason"]}}`},
{name: "metadata as a boolean", body: `{"error":{"message":"invalid token","code":40003,"metadata":true}}`},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
d := json.NewDecoder(strings.NewReader(tc.body))
errResp, err := apiErrorFromResponse(http.StatusBadRequest, d)
if err != nil {
t.Fatalf("unexpected decode error: %v", err)
}
if errResp.ErrorField.Code != 40003 {
t.Errorf("code = %d, want 40003", errResp.ErrorField.Code)
}
if errResp.ErrorField.Message != "invalid token" {
t.Errorf("message = %q, want %q", errResp.ErrorField.Message, "invalid token")
}
if errResp.ErrorField.Metadata.Reason != "" {
t.Errorf("reason = %q, want empty for a malformed metadata", errResp.ErrorField.Metadata.Reason)
}
})
}
}
// TestUtilityResponseDecodesDestinationIPs pins the API field that carries the
// organization's effective Allowed Destination IP list. The list is enforced as a
// set of Firewall Mode exceptions, so a silent decode change - a renamed field, a