Errors identified by a domain and a reason from a reviewed proto catalog, classified by a transport-neutral code, and rendered by transports. The design follows AIP-193, but HTTP statuses are finer-grained than AIP's code-to-HTTP mapping.
| Package | Purpose |
|---|---|
herodot |
Code, Definition, Error, Annotate, From. No transport. |
httperr |
Renders errors as HTTP responses, by default in the AIP JSON shape. |
grpcerr |
Renders errors as gRPC statuses, and server interceptors. |
cmd/protoc-gen-herodot |
Generates Go, JSON, TypeScript, Markdown and OpenAPI from catalogs. |
A catalog is an enum. The enum names the domain; its values are reasons:
import "ory/herodot/v1/herodot.proto";
enum FlowReason {
option (ory.herodot.v1.catalog).domain = "kratos.ory.com";
FLOW_REASON_UNSPECIFIED = 0;
FLOW_EXPIRED = 1 [(ory.herodot.v1.value).reason = {
code: EXPIRED
message: "The self-service flow has expired."
metadata_keys: ["flowId"]
}];
}Catalogs disable buf's ENUM_VALUE_PREFIX lint rule, because the value name is
the reason. Generate them with:
# buf.gen.yaml
version: v2
plugins:
- local: ["go", "run", "github.com/ory/herodot/v2/cmd/protoc-gen-herodot"]
out: .
opt: [paths=source_relative] # Go, next to the catalog
- local: ["go", "run", "github.com/ory/herodot/v2/cmd/protoc-gen-herodot"]
out: gen
opt: [lang=json, lang=ts, lang=md, lang=openapi] # one file per domain// Deep in the call stack.
return kratoserr.FlowExpired.New(
herodot.Metadata("flowId", flow.ID.String()),
herodot.Cause(err),
)
// Higher up: add context without changing the classification.
return herodot.Annotate(err, herodot.Metadata("returnTo", returnTo))
// Anywhere above.
if kratoserr.FlowExpired.Matches(err) { /* ... */ }
// At the edge.
w := &httperr.Writer{Debug: devMode}
w.WriteError(rw, r, err)
grpc.NewServer(grpc.ChainUnaryInterceptor(grpcerr.UnaryServerInterceptor(devMode)))Errors without a classification are rendered as ory.com/INTERNAL. Their text
never reaches the client unless debugging is enabled.
An error received from another service would otherwise be unclassified too. Decode it to keep its identity: catalog definitions match the decoded error, and returning it forwards it unchanged.
_, err := kratosClient.GetIdentity(ctx, req)
if e, ok := grpcerr.FromError(err); ok {
return e
}
resp, err := http.DefaultClient.Do(req)
// ...
if e, ok := httperr.Decode(resp); ok {
return e
}The code survives both transports. HTTP responses carry the canonical code and
the HTTP status, which determine it. gRPC statuses carry only the canonical
code, so grpcerr adds an ory.herodot.v1.CodeInfo detail for refined codes.
The code table is generated from herodot.proto. After changing a proto, run
make gen in the repository root. It runs buf as a Go tool from
herodotpb/tools/go.mod, a separate module that keeps buf's dependencies out of
herodot/v2. The schema and generated protobuf types live in the independent
herodotpb module. The repository's go.work links all three
modules for local development.
Run the Release workflow with a herodot/v2
version, a herodot v1 version, or both. It runs scripts/release.sh, which:
- computes the
go.sumlines of the new herodot/v2 version from master, before the tag exists, withtools/cmd/modproxy; - bumps the root module to require it, in a commit on top of master;
- tags that commit and pushes master and the tags atomically.
Every herodot/v2 release bumps the root module. A herodot v1 release requires
the root module to require the latest herodot/v2 release. Run
scripts/release.sh without --push for a local dry run.