cmd/cli: bound API preflight by service lifetime

processCDFlags retries the resolver-config fetch indefinitely by design: a
device that has no working network at boot must eventually come up. The loop had
no cancellation, so a stop request arriving while the API is unreachable was
ignored - the process kept retrying long after the service reported itself
stopped, doing work on behalf of a service the OS considers stopped.

Thread a context through processCDFlags and derive it from p.stopCh, in both the
startup preflight and the config-reload path. The loop now returns as soon as the
context is cancelled, checked both before a retry and after backoff returns
(backoff can wake up on cancellation). A stop during preflight exits the way a
normal stop does, without Fatal, so the service manager does not treat it as a
failed start and apply its restart policy to a service the operator just asked
to stop.

Bind the two API requests themselves as well, so a stop does not have to wait out
an in-flight request. Without this the loop honours a stop only between attempts,
which leaves up to defaultTimeout (20s) of a request the service is no longer
interested in - the same "still working after Service stopped" the loop change
exists to end, one layer down.

Doing so means a context parameter on FetchResolverConfig, FetchResolverUID,
UpdateCustomLastFailed and SendLogs, since all four reach a request builder. The
callers that have no context pass context.Background(), which is what master
effectively does at those sites: its loggerCtx carries a logger, not
cancellation. doWithFallback needs no parameter, because it clones the request
with req.Context() and so inherits the binding. This also repairs
internal/controld/controld_test.go, which is behind //go:build controld and had
already been written against the context-taking signature, so it could not
compile.

Cover the cancellation paths; removing either check makes the tests hang until
timeout.

Sampling the stop state is the whole point of runAPIPreflight rather than doing
this inline. A stop and a failure need opposite handling - one exits quietly, the
other self-uninstalls a deleted device, surfaces the error to a mobile app, and
reports a failed start - so the two must not be confused. Reading it from the
context after cancelling would report "stopped" for every failure, since
CancelFunc sets ctx.Err() regardless of whether anyone asked to stop; the stop
channel is read directly instead, which also does not depend on the context
watcher goroutine having been scheduled.
This commit is contained in:
Cuong Manh Le
2026-08-21 14:49:30 +07:00
parent b74937fcf3
commit 4f730167d4
7 changed files with 686 additions and 33 deletions
+40 -14
View File
@@ -63,12 +63,35 @@ type ErrorResponse struct {
Message string `json:"message"`
Code int `json:"code"`
} `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
// cannot tell a permanent rejection of the request from a transient server-side
// failure, and callers that act differently on the two need the status to tell them
// apart. Zero means the status was not recorded.
StatusCode int `json:"-"`
}
func (u ErrorResponse) Error() string {
return u.ErrorField.Message
}
// apiErrorFromResponse builds the error for a non-200 API answer, recording the HTTP
// status alongside the decoded body.
//
// The status is what tells a caller whether the answer will change on a retry: this type
// is built for every non-200 whose body decodes, so a 502 from a load balancer and a 404
// for a deleted device are otherwise indistinguishable. Both response paths go through
// here so neither can decode a body and forget to record it.
func apiErrorFromResponse(statusCode int, d *json.Decoder) (*ErrorResponse, error) {
errResp := &ErrorResponse{StatusCode: statusCode}
if err := d.Decode(errResp); err != nil {
return nil, err
}
// Decode fills exported fields from the body; StatusCode is json:"-", so it survives.
errResp.StatusCode = statusCode
return errResp, nil
}
type utilityRequest struct {
UID string `json:"uid"`
ClientID string `json:"client_id,omitempty"`
@@ -96,7 +119,7 @@ type LogsRequest struct {
}
// FetchResolverConfig fetch Control D config for given uid.
func FetchResolverConfig(req *ResolverConfigRequest, cdDev bool) (*ResolverConfig, error) {
func FetchResolverConfig(ctx context.Context, req *ResolverConfigRequest, cdDev bool) (*ResolverConfig, error) {
uid, clientID := ParseRawUID(req.RawUID)
uReq := utilityRequest{
UID: uid,
@@ -106,11 +129,11 @@ func FetchResolverConfig(req *ResolverConfigRequest, cdDev bool) (*ResolverConfi
uReq.ClientID = clientID
}
body, _ := json.Marshal(uReq)
return postUtilityAPI(req.Version, cdDev, false, bytes.NewReader(body))
return postUtilityAPI(ctx, req.Version, cdDev, false, bytes.NewReader(body))
}
// FetchResolverUID fetch resolver uid from a given request.
func FetchResolverUID(req *UtilityOrgRequest, version string, cdDev bool) (*ResolverConfig, error) {
func FetchResolverUID(ctx context.Context, req *UtilityOrgRequest, version string, cdDev bool) (*ResolverConfig, error) {
if req == nil {
return nil, errors.New("invalid request")
}
@@ -131,26 +154,29 @@ func FetchResolverUID(req *UtilityOrgRequest, version string, cdDev bool) (*Reso
ctrld.ProxyLogger.Load().Debug().Msgf("Sending UID request to ControlD API")
body, _ := json.Marshal(req)
return postUtilityAPI(version, cdDev, false, bytes.NewReader(body))
return postUtilityAPI(ctx, version, cdDev, false, bytes.NewReader(body))
}
// UpdateCustomLastFailed calls API to mark custom config is bad.
func UpdateCustomLastFailed(rawUID, version string, cdDev, lastUpdatedFailed bool) (*ResolverConfig, error) {
func UpdateCustomLastFailed(ctx context.Context, rawUID, version string, cdDev, lastUpdatedFailed bool) (*ResolverConfig, error) {
uid, clientID := ParseRawUID(rawUID)
req := utilityRequest{UID: uid}
if clientID != "" {
req.ClientID = clientID
}
body, _ := json.Marshal(req)
return postUtilityAPI(version, cdDev, true, bytes.NewReader(body))
return postUtilityAPI(ctx, version, cdDev, true, bytes.NewReader(body))
}
func postUtilityAPI(version string, cdDev, lastUpdatedFailed bool, body io.Reader) (*ResolverConfig, error) {
func postUtilityAPI(ctx context.Context, version string, cdDev, lastUpdatedFailed bool, body io.Reader) (*ResolverConfig, error) {
apiUrl := resolverDataURLCom
if cdDev {
apiUrl = resolverDataURLDev
}
req, err := http.NewRequest("POST", apiUrl, body)
// Context-bound so an in-flight request is abandoned when the caller is
// cancelled - a service stop during API preflight must not wait out the
// request timeout, let alone keep retrying.
req, err := http.NewRequestWithContext(ctx, "POST", apiUrl, body)
if err != nil {
return nil, fmt.Errorf("http.NewRequest: %w", err)
}
@@ -174,8 +200,8 @@ func postUtilityAPI(version string, cdDev, lastUpdatedFailed bool, body io.Reade
defer resp.Body.Close()
d := json.NewDecoder(resp.Body)
if resp.StatusCode != http.StatusOK {
errResp := &ErrorResponse{}
if err := d.Decode(errResp); err != nil {
errResp, err := apiErrorFromResponse(resp.StatusCode, d)
if err != nil {
return nil, err
}
return nil, errResp
@@ -189,13 +215,13 @@ func postUtilityAPI(version string, cdDev, lastUpdatedFailed bool, body io.Reade
}
// SendLogs sends runtime log to ControlD API.
func SendLogs(lr *LogsRequest, cdDev bool) error {
func SendLogs(ctx context.Context, lr *LogsRequest, cdDev bool) error {
defer lr.Data.Close()
apiUrl := logURLCom
if cdDev {
apiUrl = logURLDev
}
req, err := http.NewRequest("POST", apiUrl, lr.Data)
req, err := http.NewRequestWithContext(ctx, "POST", apiUrl, lr.Data)
if err != nil {
return fmt.Errorf("http.NewRequest: %w", err)
}
@@ -215,8 +241,8 @@ func SendLogs(lr *LogsRequest, cdDev bool) error {
defer resp.Body.Close()
d := json.NewDecoder(resp.Body)
if resp.StatusCode != http.StatusOK {
errResp := &ErrorResponse{}
if err := d.Decode(errResp); err != nil {
errResp, err := apiErrorFromResponse(resp.StatusCode, d)
if err != nil {
return err
}
return errResp
+67
View File
@@ -1,6 +1,9 @@
package controld
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -29,3 +32,67 @@ func Test_parseUID(t *testing.T) {
})
}
}
// TestAPIErrorRecordsHTTPStatus pins the plumbing the caller's exit decision rests on.
//
// cmd/cli treats a 4xx as "this configuration is refused, restarting cannot help" and
// exits cleanly, while a 5xx keeps the abnormal exit so the service manager retries. Both
// readings need the status, and it is not in the JSON body - so a decode path that
// forgets to record it would quietly send every API error down the retry branch,
// including a deleted device that should self-uninstall and stop.
func TestAPIErrorRecordsHTTPStatus(t *testing.T) {
tests := []struct {
name string
statusCode int
body string
wantCode int
wantMsg string
}{
{
name: "deleted device",
statusCode: http.StatusNotFound,
body: `{"error":{"message":"device does not exist","code":40402}}`,
wantCode: InvalidConfigCode,
wantMsg: "device does not exist",
},
{
// A gateway error body carries no error object at all, which decodes
// cleanly into the zero value - so the status is the only thing that
// distinguishes it from a real rejection.
name: "gateway error with an empty body",
statusCode: http.StatusBadGateway,
body: `{}`,
},
{
name: "service unavailable",
statusCode: http.StatusServiceUnavailable,
body: `{"error":{"message":"try again later","code":0}}`,
wantMsg: "try again later",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
d := json.NewDecoder(strings.NewReader(tc.body))
errResp, err := apiErrorFromResponse(tc.statusCode, d)
if err != nil {
t.Fatalf("unexpected decode error: %v", err)
}
if errResp.StatusCode != tc.statusCode {
t.Errorf("StatusCode = %d, want %d: the caller cannot tell a permanent rejection from a transient failure without it", errResp.StatusCode, tc.statusCode)
}
if errResp.ErrorField.Code != tc.wantCode {
t.Errorf("code = %d, want %d", errResp.ErrorField.Code, tc.wantCode)
}
if errResp.Error() != tc.wantMsg {
t.Errorf("message = %q, want %q", errResp.Error(), tc.wantMsg)
}
})
}
t.Run("an undecodable body is reported as a decode failure", func(t *testing.T) {
d := json.NewDecoder(strings.NewReader("<html>502 Bad Gateway</html>"))
if _, err := apiErrorFromResponse(http.StatusBadGateway, d); err == nil {
t.Error("expected a decode error for a non-JSON body")
}
})
}