mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
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:
+40
-14
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user