From 7c1184a799ede30596f7963f9fabbf0a95aabd81 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Fri, 25 Sep 2026 17:05:21 +0700 Subject: [PATCH] feat(api): classify Control D API maintenance responses During the reported incident the API answered "Maintenance is in progress. Please try again later." after a successful DNS resolution and TCP connection, and ctrld reported it as API_UNREACHABLE. Nothing in the client could tell that answer apart from the API refusing the request, so callers had to guess from an HTTP status that does not carry the distinction. Add IsMaintenance. The API answers every public request in hard maintenance mode with HTTP 503 and error.code 50302 (Application::ERROR_HARD_MAINTENANCE), exported as MaintenanceCode, and IsMaintenance classifies on that code. It falls back to the message for deployments that answer without the code, which a test covers so that path cannot silently drop. The answer's "body" is an empty JSON array; a test pins that the error decode does not depend on it. 50301 (service unavailable) and 50003 (read-only mode) are deliberately not maintenance: the first is a failure, and read-only only gates authenticated controllers, so it never answers /utility or /logs. Maintenance is temporary and says nothing about the configuration that was asked for. The HTTP status cannot classify it on its own, because a maintenance answer can arrive with a client-error status that otherwise means the same request will be refused again, so callers must ask this question before they act on a status. --- internal/controld/config.go | 39 ++++++++++++ internal/controld/maintenance_test.go | 85 +++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 internal/controld/maintenance_test.go diff --git a/internal/controld/config.go b/internal/controld/config.go index d3fd4c0..2faac8d 100644 --- a/internal/controld/config.go +++ b/internal/controld/config.go @@ -48,6 +48,45 @@ const ( ReasonTokenDisabled = "token_disabled" ) +// MaintenanceCode is the error.code the API answers with in hard maintenance +// mode (Application::ERROR_HARD_MAINTENANCE), on HTTP 503, for every public +// API request including /utility and /logs. +// +// The code decides. The message match in IsMaintenance stays as the +// compatibility path for deployments that answer without it. +// +// Two neighbouring codes are deliberately not maintenance. 50301 is the +// generic "service unavailable" (a provisioning rate limit, Internal Domains or +// log storage down), a failure rather than a maintenance window. 50003 is +// read-only mode, which only gates authenticated controllers and so never +// answers /utility or /logs; its message ("Maintenance is in progress, cannot +// make any modifications.") does not match MaintenanceMessage either. +const MaintenanceCode = 50302 + +// MaintenanceMessage is what the API says while it is in hard maintenance. It +// classifies an answer that carries no MaintenanceCode. Matched as a substring because the answer carries guidance after it +// ("Please try again later.") that is not part of the condition itself. +const MaintenanceMessage = "Maintenance is in progress." + +// IsMaintenance reports whether err is the Control D API saying it is in +// maintenance, rather than refusing this particular request. +// +// Maintenance is temporary and says nothing about the configuration that was +// asked for, so a caller must not treat it as a permanent rejection: the same +// request succeeds once the window ends. The HTTP status cannot classify it on +// its own, because a maintenance answer can arrive with a client-error status +// that otherwise means "this request will be refused again". +func IsMaintenance(err error) bool { + var uer *ErrorResponse + if !errors.As(err, &uer) { + return false + } + if uer.ErrorField.Code == MaintenanceCode { + return true + } + return strings.Contains(uer.ErrorField.Message, MaintenanceMessage) +} + // ResolverConfig represents Control D resolver data. type ResolverConfig struct { DOH string `json:"doh"` diff --git a/internal/controld/maintenance_test.go b/internal/controld/maintenance_test.go new file mode 100644 index 0000000..182fe1d --- /dev/null +++ b/internal/controld/maintenance_test.go @@ -0,0 +1,85 @@ +package controld + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "testing" +) + +func maintenanceErr(status, code int, message string) *ErrorResponse { + e := &ErrorResponse{StatusCode: status} + e.ErrorField.Code = code + e.ErrorField.Message = message + return e +} + +// The message the API answered with during the reported incident, verbatim. +const reportedMaintenanceMessage = "Maintenance is in progress. Please try again later." + +func TestIsMaintenanceByMessage(t *testing.T) { + for _, tc := range []struct { + name string + err error + want bool + }{ + {"the reported answer", maintenanceErr(http.StatusServiceUnavailable, 0, reportedMaintenanceMessage), true}, + {"carried on a client-error status", maintenanceErr(http.StatusBadRequest, 40003, reportedMaintenanceMessage), true}, + {"wrapped", fmt.Errorf("fetching config: %w", maintenanceErr(503, 0, reportedMaintenanceMessage)), true}, + {"a device rejection is not maintenance", maintenanceErr(http.StatusNotFound, InvalidConfigCode, "device does not exist"), false}, + {"an empty message is not maintenance", maintenanceErr(http.StatusBadGateway, 0, ""), false}, + {"a plain error is not maintenance", errors.New("dial tcp: connection refused"), false}, + {"no error is not maintenance", nil, false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := IsMaintenance(tc.err); got != tc.want { + t.Fatalf("IsMaintenance() = %v, want %v", got, tc.want) + } + }) + } +} + +// TestIsMaintenanceByCode covers the dedicated code. It must classify an +// answer that carries no recognizable message, must not stop recognizing the +// message form, which older deployments keep sending, and must not swallow +// the neighbouring codes that mean something else. +func TestIsMaintenanceByCode(t *testing.T) { + for _, tc := range []struct { + name string + err error + want bool + }{ + {"hard maintenance code", maintenanceErr(http.StatusServiceUnavailable, 50302, "scheduled downtime"), true}, + {"message form without the code", maintenanceErr(http.StatusServiceUnavailable, 0, reportedMaintenanceMessage), true}, + {"service unavailable is a failure", maintenanceErr(http.StatusServiceUnavailable, 50301, "Service unavailable"), false}, + {"read-only mode", maintenanceErr(http.StatusInternalServerError, 50003, + "Maintenance is in progress, cannot make any modifications. Please try again soon."), false}, + {"deleted device", maintenanceErr(http.StatusNotFound, InvalidConfigCode, "device does not exist"), false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := IsMaintenance(tc.err); got != tc.want { + t.Fatalf("IsMaintenance() = %v, want %v", got, tc.want) + } + }) + } +} + +// The API's hard maintenance answer, byte for byte as api_public/index.php +// writes it. "body" is an empty JSON array rather than an object, so the error +// decode must not depend on it. +func TestIsMaintenanceFromHardMaintenanceResponse(t *testing.T) { + const body = `{"body":[],"success":false,"error":{"date":"Mon, 05 Oct 2026 10:00:00 +0000",` + + `"message":"Maintenance is in progress. Please try again later.","code":50302}}` + errResp, err := apiErrorFromResponse(http.StatusServiceUnavailable, json.NewDecoder(strings.NewReader(body))) + if err != nil { + t.Fatalf("decoding the maintenance answer: %v", err) + } + if errResp.StatusCode != http.StatusServiceUnavailable || errResp.ErrorField.Code != MaintenanceCode { + t.Fatalf("errResp = %+v", errResp) + } + if !IsMaintenance(fmt.Errorf("fetching config: %w", errResp)) { + t.Fatal("the hard maintenance answer was not classified as maintenance") + } +}