diff --git a/poc/device/agent/onboarding.go b/poc/device/agent/onboarding.go index bdaebcfb..cc042cb2 100644 --- a/poc/device/agent/onboarding.go +++ b/poc/device/agent/onboarding.go @@ -119,14 +119,18 @@ func (da *DeviceClientSettings) ReportCapabilities( da.log.Infow("Starting capabilities reporting") err := da.apiClient.ReportCapabilities(ctx, capabilities.Properties.Id, capabilities) if err != nil { - da.log.Errorw( - "Failed to report capabilities", - "error", - err, - ) + if pd, ok := sbi.AsProblemDetail(err); ok { + da.log.Errorw("WFM returned problem detail on capabilities report", + "deviceId", capabilities.Properties.Id, + "type", pd.Type, + "status", pd.Status, + "detail", pd.Detail, + "retryable", pd.IsRetryable(), + ) + return fmt.Errorf("WFM rejected capabilities [%d] %s: %w", pd.Status, pd.Title, err) + } return fmt.Errorf("failed to report capabilities: %w", err) } - da.log.Infow("Capabilities reported successfully", "deviceClientId", capabilities.Properties.Id) return nil } diff --git a/poc/device/agent/stateSync.go b/poc/device/agent/stateSync.go index e366fff6..f5ce108b 100644 --- a/poc/device/agent/stateSync.go +++ b/poc/device/agent/stateSync.go @@ -89,21 +89,25 @@ func (ss *StateSyncer) performSync() { currentETag, ) if err != nil { - ss.log.Errorw( - "Sync failed", - "err", - err.Error(), - ) - return - } - - // Handle 304 Not Modified - if response != nil && response.StatusCode == http.StatusNotModified { - ss.log.Infow( - "Sync completed", - "msg", - "No change in desired and current states (304 Not Modified)", - ) + if pd, ok := sbi.AsProblemDetail(err); ok { + if pd.Status == http.StatusNotModified { + // 304 — expected cache hit, not an error + ss.log.Infow("No change in desired and current states (304 Not Modified)", + "status", pd.Status) + } else { + // 4xx/5xx — genuine WFM error + ss.log.Errorw("WFM returned error response", + "type", pd.Type, + "status", pd.Status, + "title", pd.Title, + "detail", pd.Detail, + "retryable", pd.IsRetryable(), + "backoff", pd.BackoffStrategy, + ) + } + } else { + ss.log.Errorw("Sync failed", "err", err.Error()) + } return } @@ -347,6 +351,16 @@ func (ss *StateSyncer) fetchDeploymentYAML( deploymentRef.Digest, ) if err != nil { + if pd, ok := sbi.AsProblemDetail(err); ok { + ss.log.Errorw("WFM returned problem detail fetching deployment YAML", + "deploymentId", deploymentRef.DeploymentId, + "type", pd.Type, + "status", pd.Status, + "detail", pd.Detail, + "retryable", pd.IsRetryable(), + ) + return nil, fmt.Errorf("WFM error [%d] %s: %w", pd.Status, pd.Title, err) + } return nil, fmt.Errorf("failed to fetch deployment: %w", err) } @@ -394,6 +408,16 @@ func (ss *StateSyncer) downloadAndExtractBundle( *bundleRef.Digest, ) if err != nil { + if pd, ok := sbi.AsProblemDetail(err); ok { + ss.log.Errorw("WFM returned problem detail downloading bundle", + "digest", *bundleRef.Digest, + "type", pd.Type, + "status", pd.Status, + "detail", pd.Detail, + "retryable", pd.IsRetryable(), + ) + return nil, fmt.Errorf("WFM error [%d] %s: %w", pd.Status, pd.Title, err) + } return nil, fmt.Errorf("failed to download bundle: %w", err) } diff --git a/poc/device/agent/status.go b/poc/device/agent/status.go index 3782e5b4..fcec2f74 100644 --- a/poc/device/agent/status.go +++ b/poc/device/agent/status.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "net/http" "time" "github.com/margo/sandbox/poc/device/agent/database" @@ -198,7 +199,23 @@ func (sr *StatusReporter) reportStatus(appID string, record *database.Deployment deploymentErr, ) if err != nil { - sr.log.Errorw("Failed to report status", "appId", appID, "error", err) + if pd, ok := sbi.AsProblemDetail(err); ok { + sr.log.Errorw("WFM returned problem detail on status report", + "appId", appID, + "type", pd.Type, + "status", pd.Status, + "title", pd.Title, + "detail", pd.Detail, + "retryable", pd.IsRetryable(), + ) + // 403 — device relationship retired, stop retrying + if pd.Status == http.StatusForbidden { + sr.log.Errorw("Device not authorized — capabilities may need re-registration", + "appId", appID, "type", pd.Type) + } + } else { + sr.log.Errorw("Failed to report status", "appId", appID, "error", err) + } return } diff --git a/standard/generatedCode/wfm/sbi/problem_helpers.go b/standard/generatedCode/wfm/sbi/problem_helpers.go new file mode 100644 index 00000000..a2e56b4f --- /dev/null +++ b/standard/generatedCode/wfm/sbi/problem_helpers.go @@ -0,0 +1,253 @@ +// sandbox/standard/generatedCode/wfm/sbi/problem_helpers.go +// Hand-written helpers for the generated ProblemDetail type. DO NOT regenerate. +package sbi + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" +) + +// ── Margo-reserved problem type URIs ───────────────────────────────────────── +// Source: https://docs.margo.org/specification/problem-types +// Stable, unversioned identifiers. Clients MUST use type URI for programmatic +// error handling — NOT the status code or title. +const ( + ProblemBaseURI = "https://docs.margo.org/specification/problem-types" + + // 400 — Malformed request body. + ProblemTypeInvalidRequest = ProblemBaseURI + "#invalid-request" + + // 403 — Request not authorized by WFM local policy. + ProblemTypeNotAuthorized = ProblemBaseURI + "#not-authorized" + + // 404 — No gateway found for the given child-device deviceId. + ProblemTypeGatewayNotFound = ProblemBaseURI + "#gateway-not-found" + + // 404 — No device with the given deviceId found for the client. + ProblemTypeDeviceNotFound = ProblemBaseURI + "#device-not-found" + + // 404 — Bundle not found for the given digest. + ProblemTypeInvalidBundle = ProblemBaseURI + "#invalid-bundle" + + // 404 — Deployment not found for the given digest. + ProblemTypeDeploymentNotFound = ProblemBaseURI + "#deployment-not-found" + + // 404 — Trust domain discovery document not available. + ProblemTypeDiscoveryDocumentNotFound = ProblemBaseURI + "#discovery-document-not-found" + + // 404 — SPIFFE bundle unavailable. + ProblemTypeSpiffeBundleNotFound = ProblemBaseURI + "#spiffe-bundle-not-found" + + // 406 — Server cannot generate a response matching the Accept header. + ProblemTypeServerCannotGenerateResponse = ProblemBaseURI + "#server-cannot-generate-response" + + // 422 — Request body syntactically valid but contains a semantic error. + ProblemTypeSemanticError = ProblemBaseURI + "#semantic-error" +) + +// ── Non-Margo (about:blank) ─────────────────────────────────────────────────── +// Used when no Margo-specific problem type applies (RFC 9457 §4.2). +const ( + ProblemTypeAboutBlank = "about:blank" +) + +// ProblemContentType is the RFC 9457 media type. +const ProblemContentType = "application/problem+json" + +// FieldError represents a single field-level validation error. +type FieldError struct { + Field string `json:"field"` + Message string `json:"message"` +} + +// ── error interface ─────────────────────────────────────────────────────────── + +func (p *ProblemDetail) Error() string { + if p.Detail != nil && *p.Detail != "" { + return fmt.Sprintf("[%d] %s: %s", p.Status, p.Title, *p.Detail) + } + return fmt.Sprintf("[%d] %s", p.Status, p.Title) +} + +func (p *ProblemDetail) IsRetryable() bool { + return p.Retryable != nil && *p.Retryable +} + +func (p *ProblemDetail) ShouldRetry() bool { + return p.IsRetryable() +} + +// ── Builder ─────────────────────────────────────────────────────────────────── + +func NewProblemDetail(problemType, title string, status int) *ProblemDetail { + return &ProblemDetail{Type: problemType, Title: title, Status: status} +} + +func (p *ProblemDetail) WithDetail(d string) *ProblemDetail { + p.Detail = &d + return p +} + +func (p *ProblemDetail) WithInstance(i string) *ProblemDetail { + p.Instance = &i + return p +} + +func (p *ProblemDetail) WithRetryable(r bool) *ProblemDetail { + p.Retryable = &r + return p +} + +func (p *ProblemDetail) WithRetryAfterSeconds(s int) *ProblemDetail { + p.RetryAfterSeconds = &s + return p +} + +func (p *ProblemDetail) WithBackoffStrategy(s ProblemDetailBackoffStrategy) *ProblemDetail { + p.BackoffStrategy = &s + return p +} + +// ── Convenience constructors ────────────────────────────────────────────────── + +func NewInvalidRequest(detail, instance string) *ProblemDetail { + return NewProblemDetail(ProblemTypeInvalidRequest, "Invalid Request", http.StatusBadRequest). + WithDetail(detail).WithInstance(instance). + WithRetryable(false).WithBackoffStrategy(None) +} + +func NewSemanticError(detail, instance string, fieldErrors ...FieldError) *ProblemDetail { + pd := NewProblemDetail(ProblemTypeSemanticError, "Semantic Error", http.StatusUnprocessableEntity). + WithDetail(detail).WithInstance(instance). + WithRetryable(false).WithBackoffStrategy(None) + if len(fieldErrors) > 0 { + errs := make([]struct { + Field *string `json:"field,omitempty"` + Message *string `json:"message,omitempty"` + }, len(fieldErrors)) + for i, fe := range fieldErrors { + f, m := fe.Field, fe.Message + errs[i].Field = &f + errs[i].Message = &m + } + pd.Errors = &errs + } + return pd +} + + +func NewNotAuthorized(detail, instance string) *ProblemDetail { + return NewProblemDetail(ProblemTypeNotAuthorized, "Not Authorized", http.StatusForbidden). + WithDetail(detail).WithInstance(instance). + WithRetryable(false).WithBackoffStrategy(None) +} + +func NewGatewayNotFound(detail, instance string) *ProblemDetail { + return NewProblemDetail(ProblemTypeGatewayNotFound, "Gateway Not Found", http.StatusNotFound). + WithDetail(detail).WithInstance(instance). + WithRetryable(false).WithBackoffStrategy(None) +} + +func NewDeviceNotFound(detail, instance string) *ProblemDetail { + return NewProblemDetail(ProblemTypeDeviceNotFound, "Device Not Found", http.StatusNotFound). + WithDetail(detail).WithInstance(instance). + WithRetryable(false).WithBackoffStrategy(None) +} + +func NewInvalidBundle(detail, instance string) *ProblemDetail { + return NewProblemDetail(ProblemTypeInvalidBundle, "Invalid Bundle", http.StatusNotFound). + WithDetail(detail).WithInstance(instance). + WithRetryable(false).WithBackoffStrategy(None) +} + +func NewDeploymentNotFound(detail, instance string) *ProblemDetail { + return NewProblemDetail(ProblemTypeDeploymentNotFound, "Deployment Not Found", http.StatusNotFound). + WithDetail(detail).WithInstance(instance). + WithRetryable(false).WithBackoffStrategy(None) +} + +func NewServerCannotGenerateResponse(detail, instance string) *ProblemDetail { + return NewProblemDetail(ProblemTypeServerCannotGenerateResponse, "Server Cannot Generate Response", http.StatusNotAcceptable). + WithDetail(detail).WithInstance(instance). + WithRetryable(false).WithBackoffStrategy(None) +} + +func NewInternalError(detail, instance string) *ProblemDetail { + return NewProblemDetail(ProblemTypeAboutBlank, "Internal Server Error", http.StatusInternalServerError). + WithDetail(detail).WithInstance(instance). + WithRetryable(false).WithBackoffStrategy(None) +} + +func NewServiceUnavailable(detail, instance string) *ProblemDetail { + return NewProblemDetail(ProblemTypeAboutBlank, "Service Unavailable", http.StatusServiceUnavailable). + WithDetail(detail).WithInstance(instance). + WithRetryable(false).WithBackoffStrategy(None) +} + +func NewConflict(detail, instance string) *ProblemDetail { + return NewProblemDetail(ProblemTypeAboutBlank, "Conflict", http.StatusConflict). + WithDetail(detail).WithInstance(instance). + WithRetryable(false).WithBackoffStrategy(None) +} + +func NewTooManyRequests(detail, instance string, retryAfterSeconds int) *ProblemDetail { + return NewProblemDetail(ProblemTypeAboutBlank, "Too Many Requests", http.StatusTooManyRequests). + WithDetail(detail).WithInstance(instance). + WithRetryable(true).WithBackoffStrategy(Exponential). + WithRetryAfterSeconds(retryAfterSeconds) +} + +func NewNotImplemented(detail, instance string) *ProblemDetail { + return NewProblemDetail(ProblemTypeAboutBlank, "Not Implemented", http.StatusNotImplemented). + WithDetail(detail).WithInstance(instance). + WithRetryable(false).WithBackoffStrategy(None) +} + +// ── HTTP writer ─────────────────────────────────────────────────────────────── + +func (p *ProblemDetail) WriteHTTP(w http.ResponseWriter) { + b, err := p.MarshalJSON() + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), + http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", ProblemContentType) + if p.RetryAfterSeconds != nil { + w.Header().Set("Retry-After", strconv.Itoa(*p.RetryAfterSeconds)) + } + w.WriteHeader(p.Status) + _, _ = w.Write(b) +} + +// ── Client-side helpers ─────────────────────────────────────────────────────── + +func ParseErrorResponse(resp *http.Response) error { + if resp == nil { + return nil + } + if resp.StatusCode == http.StatusNotModified || + (resp.StatusCode >= 200 && resp.StatusCode < 300) { + return nil + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("HTTP %d: failed to read error body: %w", resp.StatusCode, err) + } + if resp.Header.Get("Content-Type") == ProblemContentType { + var pd ProblemDetail + if jsonErr := json.Unmarshal(body, &pd); jsonErr == nil { + return &pd + } + } + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) +} + +func AsProblemDetail(err error) (*ProblemDetail, bool) { + var pd *ProblemDetail + return pd, errors.As(err, &pd) +}