problem

package
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 24, 2026 License: MIT Imports: 6 Imported by: 0

Documentation

Overview

Package problem implements the AtomiCloud RFC 9457 problem-details contract (C0 §2/§14) in Go idiom.

Unlike the TypeScript/Dart/C# siblings, the Go family has NO result monad: fallible APIs return the idiomatic (T, error) pair and carry problem metadata on a concrete Error that is compatible with errors.Is/errors.As and %w wrapping. Consumers recover the Problem with `var pe *problem.Error; errors.As(err, &pe)`.

The RFC 9457 `type` URI is minted in exactly ONE place — TypeURI — from an ErrorPortal service-tree block, with the `{version}` segment kept (D8). Every envelope, registry entry, catalog row, and local error resolves its `type` through that single builder, which is the duplication this package exists to prevent.

The public surface mirrors the accepted Dart sibling 1:1 where Go allows:

Index

Examples

Constants

View Source
const UncataloguedProblemID = "uncatalogued"

UncataloguedProblemID is the id carried by an unexpected/uncatalogued problem (C0 §14 uncatalogued ⇒ 5xx ⇒ catalog-loop rule). It is used as the fallback id when no concrete registry entry is known.

Variables

This section is empty.

Functions

func RegisterGenerics

func RegisterGenerics(registry *Registry) error

RegisterGenerics registers the GenericProblems baseline set on registry, returning the first DuplicateTypeError encountered.

func TypeURI

func TypeURI(portal ErrorPortal, version, id string) (string, error)

TypeURI builds the RFC 9457 `type` URI from an ErrorPortal block.

This is the single source of the C0 §2 template `{scheme}://{host}/docs/{landscape}/{platform}/{service}/{module}/{version}/{id}`. Every problem — catalog entries, runtime envelopes, local errors — resolves its `type` through this function. Each segment must be a non-empty single path segment; otherwise an InvalidSegmentError is returned.

Example

ExampleTypeURI shows the single-source type-URI builder expanding the C0 §2 template (with the deliberate {version} segment).

package main

import (
	"fmt"

	"github.com/AtomiCloud/diene.go-errors-problems/lib/problem"
)

func examplePortal() problem.ErrorPortal {
	return problem.ErrorPortal{
		Scheme:    "https",
		Host:      "docs.raichu.cluster.atomi.cloud",
		Landscape: "raichu",
		Platform:  "go",
		Service:   "user",
		Module:    "api",
	}
}

func main() {
	uri, _ := problem.TypeURI(examplePortal(), "v1", "entity-not-found")
	fmt.Println(uri)
}
Output:
https://docs.raichu.cluster.atomi.cloud/docs/raichu/go/user/api/v1/entity-not-found

Types

type Catalog

type Catalog struct {
	// contains filtered or unexported fields
}

Catalog is a per-service×landscape catalog of [CatalogEntry]s. It builds entries from a Type (so the type URI is always the single-source one) and emits the full Problem CR content payload.

Example

ExampleCatalog shows building the C0 §14 Problem CR content for an endpoint that can return a generic problem.

package main

import (
	"fmt"

	"github.com/AtomiCloud/diene.go-errors-problems/lib/problem"
)

func examplePortal() problem.ErrorPortal {
	return problem.ErrorPortal{
		Scheme:    "https",
		Host:      "docs.raichu.cluster.atomi.cloud",
		Landscape: "raichu",
		Platform:  "go",
		Service:   "user",
		Module:    "api",
	}
}

func main() {
	catalog := problem.NewCatalog(examplePortal())
	_ = catalog.AddType(problem.EntityNotFound(), problem.CatalogEndpoint{Method: "GET", Path: "/user/{id}"})

	content := catalog.ToCRDContent()
	fmt.Println(content[0]["id"], content[0]["status"], content[0]["recoverable"])
}
Output:
entity-not-found 404 false

func NewCatalog

func NewCatalog(portal ErrorPortal, entries ...CatalogEntry) *Catalog

NewCatalog creates a catalog bound to portal, optionally pre-populated with entries.

func (*Catalog) Add

func (c *Catalog) Add(entry CatalogEntry)

Add stores entry, replacing any existing entry with the same id while keeping its original insertion position.

func (*Catalog) AddGenerics

func (c *Catalog) AddGenerics() error

AddGenerics adds the GenericProblems baseline set, returning the first InvalidSegmentError encountered while building type URIs.

func (*Catalog) AddType

func (c *Catalog) AddType(problemType Type, endpoints ...CatalogEndpoint) error

AddType builds and adds an entry from a registry problemType, attaching endpoints. The type URI is minted by the single-source builder via the catalog's portal, so this is the canonical way to declare a cataloged problem. A default status of 500 is applied when the type has no status hint.

func (*Catalog) Entries

func (c *Catalog) Entries() []CatalogEntry

Entries returns the declared entries in insertion order.

func (*Catalog) Lookup

func (c *Catalog) Lookup(id string) (CatalogEntry, bool)

Lookup returns the entry registered for id and whether it was present.

func (*Catalog) Portal

func (c *Catalog) Portal() ErrorPortal

Portal returns the portal used when building type URIs for entries added via Catalog.AddType.

func (*Catalog) ToCRDContent

func (c *Catalog) ToCRDContent() []map[string]any

ToCRDContent emits the full Problem CR content payload (C0 §14): the `problems[]` list rendered per row by the service's primordial chart.

type CatalogEndpoint

type CatalogEndpoint struct {
	// Method is the HTTP method, e.g. GET, POST.
	Method string
	// Path is the absolute path, e.g. /user/me.
	Path string
}

CatalogEndpoint is a method+path pair declaring which endpoint can return a problem.

func (CatalogEndpoint) ToContent

func (e CatalogEndpoint) ToContent() map[string]any

ToContent renders the endpoint as its Problem CR content member.

type CatalogEntry

type CatalogEntry struct {
	// ID is the stable problem id.
	ID string
	// TypeURI is the RFC 9457 `type` URI built by [TypeURI].
	TypeURI string
	// Title is a short human-readable title.
	Title string
	// Status is the HTTP status code.
	Status int
	// Recoverable is the recoverable-vs-fatal flag the frontend classifier reads.
	Recoverable bool
	// DataSchema is the JSON Schema of the `data` extension payload.
	DataSchema map[string]any
	// Endpoints are the endpoints that can return this problem.
	Endpoints []CatalogEndpoint
}

CatalogEntry is one per-endpoint catalog entry (C0 §14 `problems[]` shape).

The TypeURI is built in exactly ONE place; callers pass a URI minted by TypeURI (typically via Registry.TypeURIFor or Catalog.AddType) so no second template exists.

func (CatalogEntry) ToCRDContent

func (e CatalogEntry) ToCRDContent() map[string]any

ToCRDContent renders the entry as its Problem CR content member (C0 §14): `{id, type, title, status, recoverable, data, endpoints[]}`.

type DuplicateTypeError

type DuplicateTypeError struct {
	// ID is the duplicate problem id.
	ID string
}

DuplicateTypeError reports an attempt to register a problem id that is already present in a Registry.

func (*DuplicateTypeError) Error

func (e *DuplicateTypeError) Error() string

Error implements the error interface.

type Error

type Error struct {
	// Problem is the RFC 9457 envelope this error carries.
	Problem Problem
	// contains filtered or unexported fields
}

Error is the Go realization of the family "result slot": a concrete error that carries a Problem and is compatible with errors.Is/errors.As and %w wrapping. Fallible APIs return the idiomatic (T, error) pair; consumers recover the envelope with:

var pe *problem.Error
if errors.As(err, &pe) {
	use(pe.Problem)
}
Example

ExampleError shows recovering a Problem from a wrapped (T, error) result via errors.As — the Go realization of the family result slot.

package main

import (
	"errors"
	"fmt"

	"github.com/AtomiCloud/diene.go-errors-problems/lib/problem"
)

func main() {
	envelope := problem.Problem{Type: "https://x/v1/conflict", Title: "Conflict", Status: 409}
	err := fmt.Errorf("saving user: %w", problem.NewError(envelope))

	var problemErr *problem.Error
	if errors.As(err, &problemErr) {
		fmt.Println(problemErr.Problem.Status)
	}
}
Output:
409

func NewError

func NewError(problem Problem) *Error

NewError creates a problem-typed error carrying problem.

func WrapError

func WrapError(problem Problem, cause error) *Error

WrapError creates a problem-typed error carrying problem and wrapping cause, so errors.Is/errors.As traverse into the underlying error chain.

func (*Error) Error

func (e *Error) Error() string

Error implements the error interface, appending the wrapped cause when present.

func (*Error) Unwrap

func (e *Error) Unwrap() error

Unwrap returns the wrapped cause (nil when none), enabling errors.Is and errors.As to traverse the chain.

type ErrorPortal

type ErrorPortal struct {
	// Scheme is the URI scheme, conventionally `https`.
	Scheme string
	// Host serves the problem docs, e.g. docs.raichu.cluster.atomi.cloud.
	Host string
	// Landscape is the LPSM `L` segment.
	Landscape string
	// Platform is the LPSM `P` segment.
	Platform string
	// Service is the LPSM `S` segment.
	Service string
	// Module is the LPSM `M` segment.
	Module string
}

ErrorPortal is the service-tree config block the problem type-URI template is fed from (C0 §2). The LPSM segments (Landscape/Platform/Service/Module) are the declaring identity that also derives public hostnames, so a type URI is stable, addressable, and never hand-authored per row.

Real services supply their build-time portal (sourced from config, never hardcoded per R4); LocalErrorPortal is the fallback for client-local errors that have no backend context.

func LocalErrorPortal

func LocalErrorPortal() ErrorPortal

LocalErrorPortal is the default portal for client-local errors with no service backend context (e.g. a crash before any backend is known). Real programs pass their build-time LPSM portal instead; this keeps the single builder usable in isolation and in tests.

type ErrorSink

type ErrorSink interface {
	// Capture reports problem, returning any delivery error.
	Capture(ctx context.Context, problem Problem) error
}

ErrorSink receives unexpected problems captured by LocalError. In production this forwards to the telemetry path; tests inject a recording sink.

type InvalidSegmentError

type InvalidSegmentError struct {
	// Name is the offending segment name (e.g. "host", "id").
	Name string
	// Value is the rejected segment value.
	Value string
}

InvalidSegmentError reports a problem type-URI segment that is empty or contains a `/`. Validating segments at the boundary keeps the URI canonical and catches misconfigured portals instead of producing a malformed URI.

Example

ExampleInvalidSegmentError shows the boundary validation rejecting a segment that contains a slash.

package main

import (
	"errors"
	"fmt"

	"github.com/AtomiCloud/diene.go-errors-problems/lib/problem"
)

func main() {
	_, err := problem.TypeURI(problem.LocalErrorPortal(), "v1", "bad/id")
	var segmentErr *problem.InvalidSegmentError
	if errors.As(err, &segmentErr) {
		fmt.Println(segmentErr.Name)
	}
}
Output:
id

func (*InvalidSegmentError) Error

func (e *InvalidSegmentError) Error() string

Error implements the error interface.

type LocalError

type LocalError struct {
	// Sink is the destination for captured local-error problems.
	Sink ErrorSink
	// Portal builds the local-error type URI.
	Portal ErrorPortal
}

LocalError wraps unexpected errors into a `local-error` Problem carrying the `message` and `stackTrace` in `data`, and captures it on a ErrorSink. The type URI flows through the single-source builder so even local errors share one identity shape.

Example

ExampleLocalError shows wrapping an unexpected error into a local-error Problem carrying the message and stack in data.

package main

import (
	"context"
	"errors"
	"fmt"

	"github.com/AtomiCloud/diene.go-errors-problems/lib/problem"
)

func main() {
	local := problem.NewLocalError(problem.NoopErrorSink{})
	envelope, _ := local.Wrap(context.Background(), errors.New("boom"), "goroutine 1 [running]")
	fmt.Println(envelope.Type)
	fmt.Println(envelope.Data["message"])
}
Output:
https://local.atomi.cloud/docs/local/go/app/core/v1/local-error
boom

func NewLocalError

func NewLocalError(sink ErrorSink) LocalError

NewLocalError creates a wrapper writing captures to sink, using LocalErrorPortal for its type URIs.

func NewLocalErrorWithPortal

func NewLocalErrorWithPortal(sink ErrorSink, portal ErrorPortal) LocalError

NewLocalErrorWithPortal creates a wrapper writing captures to sink and building type URIs from portal.

func (LocalError) Wrap

func (local LocalError) Wrap(ctx context.Context, cause error, stack string) (Problem, error)

Wrap folds cause and stack into a `local-error` Problem, captures it on the sink, and returns the envelope. A capture failure is returned alongside the (still valid) envelope so callers never lose the problem.

type NoopErrorSink

type NoopErrorSink struct{}

NoopErrorSink is an ErrorSink that discards every capture.

func (NoopErrorSink) Capture

func (NoopErrorSink) Capture(_ context.Context, _ Problem) error

Capture discards problem and always succeeds.

type Problem

type Problem struct {
	// Type is the RFC 9457 `type` URI identifying the problem type.
	Type string
	// Title is the RFC 9457 short, human-readable summary.
	Title string
	// Status is the RFC 9457 origin-generated HTTP status code.
	Status int
	// Detail is the RFC 9457 human-readable, occurrence-specific explanation.
	// It is optional: nil is omitted from the wire form, a non-nil pointer
	// (including a pointer to an empty string) is emitted.
	Detail *string
	// Instance is the RFC 9457 URI identifying the specific occurrence. It is
	// optional with the same nil-omitted / present-emitted semantics as Detail.
	Instance *string
	// Recoverable reports whether the frontend may offer a retry (C0 §2/§14).
	Recoverable bool
	// Data is the typed payload extension (schema published per problem).
	Data map[string]any
}

Problem is an RFC 9457 problem-details envelope plus the AtomiCloud `data` and `recoverable` extensions (C0 §2).

The standard RFC 9457 members are Type, Title, Status, Detail, and Instance. The extensions are Data (the typed payload whose schema is published per problem in the catalog) and Recoverable (the flag the frontend classifier splits retry-vs-fatal on). Detail and Instance are optional Go `*string` values mirroring RFC 9457's (and the Dart sibling's) nullable members: a nil pointer is absent and omitted from the wire form, while a non-nil pointer — including a pointer to an empty string — is present and emitted. This keeps a wire envelope with `"detail":""`/`"instance":""` losslessly round-trippable.

Type is always a URI minted by TypeURI; this type never formats the template itself.

Example

ExampleProblem shows the RFC 9457 envelope marshalling to its canonical wire shape (keys are emitted in encoding/json's sorted order).

package main

import (
	"encoding/json"
	"fmt"

	"github.com/AtomiCloud/diene.go-errors-problems/lib/problem"
)

func main() {
	envelope := problem.Problem{
		Type:        "https://docs.example/v1/entity-not-found",
		Title:       "Entity not found",
		Status:      404,
		Recoverable: false,
		Data:        map[string]any{"resource": "user"},
	}
	data, _ := json.Marshal(envelope)
	fmt.Println(string(data))
}
Output:
{"data":{"resource":"user"},"recoverable":false,"status":404,"title":"Entity not found","type":"https://docs.example/v1/entity-not-found"}

func FromObject

func FromObject(value any, options TransformOptions) Problem

FromObject folds value into a typed Problem. It never panics: the whole point is to guarantee a Problem for any value.

  • A Problem value is returned unchanged.
  • An error carrying a Error anywhere in its chain (via errors.As) yields that error's Problem.
  • When options.Registry recognises a `problemId` carried on a map[string]any value, the registry's type/title/status/recoverable are used and the value's `data` map is preserved.
  • Otherwise an uncatalogued problem (C0 §14) is produced with the UncataloguedProblemID fallback type.
Example

ExampleFromObject shows the total value→Problem fold producing an uncatalogued 5xx problem for an unrecognised error (C0 §14).

package main

import (
	"errors"
	"fmt"

	"github.com/AtomiCloud/diene.go-errors-problems/lib/problem"
)

func main() {
	envelope := problem.FromObject(errors.New("disk full"), problem.DefaultTransformOptions())
	fmt.Println(envelope.Status, *envelope.Detail)
}
Output:
500 disk full

func (Problem) Equal

func (p Problem) Equal(other Problem) bool

Equal reports whether p and other serialize to the same canonical JSON. It is value equality tolerant of int-vs-float numeric decoding across the wire.

func (Problem) MarshalJSON

func (p Problem) MarshalJSON() ([]byte, error)

MarshalJSON renders the envelope in its canonical wire shape (`type,title,status,detail?,instance?,recoverable,data`). Detail and Instance are omitted when nil and emitted (even for an empty string) when non-nil; Data always renders as an object (never null).

func (Problem) String

func (p Problem) String() string

String returns a compact human-readable form of the envelope.

Example

ExampleProblem_String shows the compact human-readable form.

package main

import (
	"fmt"

	"github.com/AtomiCloud/diene.go-errors-problems/lib/problem"
)

func main() {
	envelope := problem.Problem{Type: "https://x/v1/conflict", Title: "Conflict", Status: 409}
	fmt.Println(envelope.String())
}
Output:
Problem(https://x/v1/conflict, 409, Conflict)

func (*Problem) UnmarshalJSON

func (p *Problem) UnmarshalJSON(data []byte) error

UnmarshalJSON parses the envelope from its JSON object form, applying the RFC 9457 defaults for absent members (about:blank type, 500 status).

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry is an enumerable registry of [Type]s bound to an ErrorPortal. The portal supplies the LPSM segments every type URI is built from, so a registry is the per-service×landscape source of truth the catalog emitter renders into Problem CR content (C0 §14).

Example

ExampleRegistry shows registering the generic baseline and resolving a type URI through the single-source builder.

package main

import (
	"fmt"

	"github.com/AtomiCloud/diene.go-errors-problems/lib/problem"
)

func examplePortal() problem.ErrorPortal {
	return problem.ErrorPortal{
		Scheme:    "https",
		Host:      "docs.raichu.cluster.atomi.cloud",
		Landscape: "raichu",
		Platform:  "go",
		Service:   "user",
		Module:    "api",
	}
}

func main() {
	registry, _ := problem.NewRegistry(examplePortal())
	_ = problem.RegisterGenerics(registry)

	entityNotFound, _ := registry.Require("entity-not-found")
	uri, _ := registry.TypeURIFor(entityNotFound)
	fmt.Println(uri)
}
Output:
https://docs.raichu.cluster.atomi.cloud/docs/raichu/go/user/api/v1/entity-not-found

func NewRegistry

func NewRegistry(portal ErrorPortal, types ...Type) (*Registry, error)

NewRegistry creates a registry bound to portal, optionally pre-populated with types. It returns a DuplicateTypeError if types contains a repeated id.

func (*Registry) Entries

func (r *Registry) Entries() []Type

Entries returns the registered types in insertion order.

func (*Registry) Lookup

func (r *Registry) Lookup(id string) (Type, bool)

Lookup returns the type registered for id and whether it was present.

func (*Registry) Portal

func (r *Registry) Portal() ErrorPortal

Portal returns the error-portal block every type URI is built from.

func (*Registry) Register

func (r *Registry) Register(problemType Type) error

Register adds problemType, rejecting duplicates by id.

func (*Registry) Require

func (r *Registry) Require(id string) (Type, error)

Require returns the type registered for id, or an UnknownTypeError when it is absent.

func (*Registry) TypeURIFor

func (r *Registry) TypeURIFor(problemType Type) (string, error)

TypeURIFor builds the RFC 9457 `type` URI for problemType via the single-source builder and the registry's portal.

type TransformOptions

type TransformOptions struct {
	// Portal builds fallback type URIs for uncatalogued problems.
	Portal ErrorPortal
	// Registry, when non-nil, is consulted for values carrying a known
	// `problemId`.
	Registry *Registry
	// DefaultStatus is the status for an uncatalogued fallback problem.
	DefaultStatus int
	// DefaultVersion is the version segment for fallback type URIs.
	DefaultVersion string
}

TransformOptions configures how FromObject folds an arbitrary value into a Problem.

func DefaultTransformOptions

func DefaultTransformOptions() TransformOptions

DefaultTransformOptions returns options that mint fallback URIs from LocalErrorPortal with a 500 status and the v1 version segment, and no registry.

type Type

type Type struct {
	// ID is the stable identifier, e.g. "entity-not-found".
	ID string
	// Title is a short human-readable title.
	Title string
	// Version is the contract version segment, e.g. "v1".
	Version string
	// Status is the default HTTP status hint (0 means "no default").
	Status int
	// Recoverable reports whether the frontend may offer a retry (C0 §2/§14).
	Recoverable bool
	// DataSchema is the JSON Schema describing the `data` extension payload.
	DataSchema map[string]any
}

Type is a versioned problem-type declaration. Version is part of the contract identity (C0 §2): bumping it mints a NEW problem type URI rather than mutating an existing one.

func Conflict

func Conflict() Type

Conflict is the generic 409 problem: a state conflict (duplicate, stale version, …). It is recoverable.

func EntityNotFound

func EntityNotFound() Type

EntityNotFound is the generic 404 problem: the referenced entity does not exist. It is not recoverable.

func GenericProblems

func GenericProblems() []Type

GenericProblems returns the portable v1 baseline catalog in stable order: validation-error, entity-not-found, conflict, unauthenticated, unauthorized, invalid-json. Domain problems stay in consumer services and are never part of this set.

Example

ExampleGenericProblems lists the portable v1 baseline catalog in stable order.

package main

import (
	"fmt"

	"github.com/AtomiCloud/diene.go-errors-problems/lib/problem"
)

func main() {
	for _, problemType := range problem.GenericProblems() {
		fmt.Println(problemType.ID)
	}
}
Output:
validation-error
entity-not-found
conflict
unauthenticated
unauthorized
invalid-json

func InvalidJSON

func InvalidJSON() Type

InvalidJSON is the generic 400 problem: the request body was not valid JSON. It is recoverable.

func Unauthenticated

func Unauthenticated() Type

Unauthenticated is the generic 401 problem: the authenticated identity is missing or invalid. It is recoverable.

func Unauthorized

func Unauthorized() Type

Unauthorized is the generic 403 problem: the identity is authenticated but lacks permission. It is not recoverable.

func ValidationError

func ValidationError() Type

ValidationError is the generic 400 problem: the request body failed validation. It is recoverable and carries a `fields[]` payload.

type UnknownTypeError

type UnknownTypeError struct {
	// ID is the missing problem id.
	ID string
}

UnknownTypeError reports a lookup for a problem id that is not registered.

func (*UnknownTypeError) Error

func (e *UnknownTypeError) Error() string

Error implements the error interface.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL