Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions poc/device/agent/onboarding.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
54 changes: 39 additions & 15 deletions poc/device/agent/stateSync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
}

Expand Down
19 changes: 18 additions & 1 deletion poc/device/agent/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
"net/http"
"time"

"github.com/margo/sandbox/poc/device/agent/database"
Expand Down Expand Up @@ -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
}

Expand Down
253 changes: 253 additions & 0 deletions standard/generatedCode/wfm/sbi/problem_helpers.go
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
spulkit138 marked this conversation as resolved.
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)
}
Loading