From ec8c5fb42b0f2c0c676b92ee8e885284dd946fd0 Mon Sep 17 00:00:00 2001 From: vireshnavalli Date: Wed, 16 Sep 2026 08:59:46 +0000 Subject: [PATCH 1/5] feat(sbi): added problem types helper for rfc9457 implementation Signed-off-by: vireshnavalli --- .../generatedCode/wfm/sbi/problem_helpers.go | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 standard/generatedCode/wfm/sbi/problem_helpers.go diff --git a/standard/generatedCode/wfm/sbi/problem_helpers.go b/standard/generatedCode/wfm/sbi/problem_helpers.go new file mode 100644 index 00000000..7606f0d5 --- /dev/null +++ b/standard/generatedCode/wfm/sbi/problem_helpers.go @@ -0,0 +1,233 @@ +// 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" + +// ── 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() || p.Status >= 500 +} + +// ── 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) *ProblemDetail { + return NewProblemDetail(ProblemTypeSemanticError, "Semantic Error", http.StatusUnprocessableEntity). + WithDetail(detail).WithInstance(instance). + WithRetryable(false).WithBackoffStrategy(None) +} + +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(true).WithBackoffStrategy(Exponential) +} + +func NewServiceUnavailable(detail, instance string) *ProblemDetail { + return NewProblemDetail(ProblemTypeAboutBlank, "Service Unavailable", http.StatusServiceUnavailable). + WithDetail(detail).WithInstance(instance). + WithRetryable(true).WithBackoffStrategy(Exponential) +} + +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) +} From cdb186bf8696fd6583168c94dc250af16a91621c Mon Sep 17 00:00:00 2001 From: vireshnavalli Date: Wed, 16 Sep 2026 11:02:40 +0000 Subject: [PATCH 2/5] feat(problem-types): added problem types for all client responses Signed-off-by: vireshnavalli --- poc/device/agent/onboarding.go | 16 +++++++---- poc/device/agent/stateSync.go | 51 ++++++++++++++++++++++++---------- poc/device/agent/status.go | 19 ++++++++++++- 3 files changed, 64 insertions(+), 22 deletions(-) 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..c4772d35 100644 --- a/poc/device/agent/stateSync.go +++ b/poc/device/agent/stateSync.go @@ -89,21 +89,22 @@ 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 { + ss.log.Errorw("WFM returned problem detail on sync", + "type", pd.Type, + "status", pd.Status, + "title", pd.Title, + "detail", pd.Detail, + "retryable", pd.IsRetryable(), + "backoff", pd.BackoffStrategy, + ) + if !pd.ShouldRetry() { + ss.log.Errorw("Non-retryable WFM error — skipping sync cycle", + "type", pd.Type, "status", pd.Status) + } + } else { + ss.log.Errorw("Sync failed", "err", err.Error()) + } return } @@ -347,6 +348,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 +405,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 } From c41545a08e1e6a3a369fb302f1d8e4587e71ecc7 Mon Sep 17 00:00:00 2001 From: vireshnavalli Date: Wed, 16 Sep 2026 14:10:39 +0000 Subject: [PATCH 3/5] feat(problem-helpers): modified to include error fields Signed-off-by: vireshnavalli --- .../generatedCode/wfm/sbi/problem_helpers.go | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/standard/generatedCode/wfm/sbi/problem_helpers.go b/standard/generatedCode/wfm/sbi/problem_helpers.go index 7606f0d5..84eaf259 100644 --- a/standard/generatedCode/wfm/sbi/problem_helpers.go +++ b/standard/generatedCode/wfm/sbi/problem_helpers.go @@ -58,6 +58,12 @@ const ( // 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 { @@ -114,12 +120,26 @@ func NewInvalidRequest(detail, instance string) *ProblemDetail { WithRetryable(false).WithBackoffStrategy(None) } -func NewSemanticError(detail, instance string) *ProblemDetail { - return NewProblemDetail(ProblemTypeSemanticError, "Semantic Error", http.StatusUnprocessableEntity). +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). From a07859d669829536e0c4810a1d92f91eeffc277c Mon Sep 17 00:00:00 2001 From: vireshnavalli Date: Thu, 17 Sep 2026 08:40:47 +0000 Subject: [PATCH 4/5] feat: added problem details changes for 303 and 4xx/5xx Signed-off-by: vireshnavalli --- poc/device/agent/stateSync.go | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/poc/device/agent/stateSync.go b/poc/device/agent/stateSync.go index c4772d35..f5ce108b 100644 --- a/poc/device/agent/stateSync.go +++ b/poc/device/agent/stateSync.go @@ -90,17 +90,20 @@ func (ss *StateSyncer) performSync() { ) if err != nil { if pd, ok := sbi.AsProblemDetail(err); ok { - ss.log.Errorw("WFM returned problem detail on sync", - "type", pd.Type, - "status", pd.Status, - "title", pd.Title, - "detail", pd.Detail, - "retryable", pd.IsRetryable(), - "backoff", pd.BackoffStrategy, - ) - if !pd.ShouldRetry() { - ss.log.Errorw("Non-retryable WFM error — skipping sync cycle", - "type", pd.Type, "status", pd.Status) + 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()) From c53254ff1494a0d4acacc70a098b6b240e3dec82 Mon Sep 17 00:00:00 2001 From: vireshnavalli Date: Thu, 17 Sep 2026 10:07:11 +0000 Subject: [PATCH 5/5] fix: removes retryable for 500 response code Signed-off-by: vireshnavalli --- standard/generatedCode/wfm/sbi/problem_helpers.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/standard/generatedCode/wfm/sbi/problem_helpers.go b/standard/generatedCode/wfm/sbi/problem_helpers.go index 84eaf259..a2e56b4f 100644 --- a/standard/generatedCode/wfm/sbi/problem_helpers.go +++ b/standard/generatedCode/wfm/sbi/problem_helpers.go @@ -78,7 +78,7 @@ func (p *ProblemDetail) IsRetryable() bool { } func (p *ProblemDetail) ShouldRetry() bool { - return p.IsRetryable() || p.Status >= 500 + return p.IsRetryable() } // ── Builder ─────────────────────────────────────────────────────────────────── @@ -179,13 +179,13 @@ func NewServerCannotGenerateResponse(detail, instance string) *ProblemDetail { func NewInternalError(detail, instance string) *ProblemDetail { return NewProblemDetail(ProblemTypeAboutBlank, "Internal Server Error", http.StatusInternalServerError). WithDetail(detail).WithInstance(instance). - WithRetryable(true).WithBackoffStrategy(Exponential) + WithRetryable(false).WithBackoffStrategy(None) } func NewServiceUnavailable(detail, instance string) *ProblemDetail { return NewProblemDetail(ProblemTypeAboutBlank, "Service Unavailable", http.StatusServiceUnavailable). WithDetail(detail).WithInstance(instance). - WithRetryable(true).WithBackoffStrategy(Exponential) + WithRetryable(false).WithBackoffStrategy(None) } func NewConflict(detail, instance string) *ProblemDetail {