Proto-first error handling for Go services.
proterror lets you declare application errors as protobuf messages, annotate
them with canonical gRPC status codes, and generate Go helpers that work with
errors.As, gRPC status details, grpc-gateway responses, and service
interceptors.
- Keep error contracts in proto files, next to the APIs that return them.
- Return typed Go errors from services while sending canonical gRPC statuses on the wire.
- Decode gRPC status details back into typed errors on clients.
- Hide private/internal errors from clients by falling back to
Unknown. - Map common PostgreSQL failures to public API errors.
- Reuse the same error model for gRPC and HTTP gateway responses.
github.com/not-for-prod/proterrorprovides the runtime helpers, interceptors, HTTP writer, and database error mapping.github.com/not-for-prod/proterror/proterrorcontains built-in canonical error messages such asNotFound,AlreadyExists,Internal, andUnavailable.github.com/not-for-prod/proterror/registrystores public generated error types used during status conversion.github.com/not-for-prod/proterror/cmd/protoc-gen-proterroris the protobuf generator.
The generator emits <name>.pb.proterror.go files for annotated messages.
Install the generator with a pinned module version in production builds:
go install github.com/not-for-prod/proterror/cmd/protoc-gen-proterror@<version>For local development you can use:
go install github.com/not-for-prod/proterror/cmd/protoc-gen-proterror@latestAdd the runtime package to your service:
go get github.com/not-for-prod/proterrorAdd the generator after the standard Go protobuf and gRPC plugins:
version: v2
plugins:
- remote: buf.build/protocolbuffers/go
out: .
opt:
- paths=source_relative
- remote: buf.build/grpc/go
out: .
opt:
- paths=source_relative
- local: protoc-gen-proterror
out: .
opt:
- paths=source_relativeIf you build the plugin inside the repository, point Buf at the binary:
- local: ./bin/protoc-gen-proterror
out: .
opt:
- paths=source_relativeImport proterror/options.proto and annotate any protobuf message that should
act as an API error:
syntax = "proto3";
package example.v1;
import "proterror/options.proto";
message ValidationFailed {
option (proterror.options) = {
code: INVALID_ARGUMENT
internal: false
};
string field = 1;
string reason = 2;
}
message ProviderUnavailable {
option (proterror.options) = {
code: UNAVAILABLE
internal: true
};
}For each annotated message, the generator adds:
Error() stringIs(err error) boolCode() codes.CodeInternal() boolStatus() *status.StatusJoin(err error) error
Public errors (internal: false) are registered automatically and can be
serialized to clients. Internal errors (internal: true) are not registered;
when they pass through AsStatus or the server interceptor, clients receive the
built-in Unknown error instead.
Install the unary server interceptor once when constructing your gRPC server:
package main
import (
"net"
proterrors "github.com/not-for-prod/proterror"
examplev1 "github.com/you/service/gen/example/v1"
"google.golang.org/grpc"
)
func main() {
listener, err := net.Listen("tcp", ":50051")
if err != nil {
panic(err)
}
server := grpc.NewServer(
grpc.ChainUnaryInterceptor(
proterrors.UnaryServerInterceptor(),
),
)
examplev1.RegisterExampleServiceServer(server, newService())
if err := server.Serve(listener); err != nil {
panic(err)
}
}Return generated errors from handlers:
func (s *Service) Create(ctx context.Context, req *examplev1.CreateRequest) (*examplev1.CreateResponse, error) {
if req.GetName() == "" {
return nil, &examplev1.ValidationFailed{
Field: "name",
Reason: "required",
}
}
return &examplev1.CreateResponse{}, nil
}To keep the original cause for logs, tracing, or errors.Is/errors.As, join
the typed API error with the lower-level error:
func (s *Service) Get(ctx context.Context, req *examplev1.GetRequest) (*examplev1.GetResponse, error) {
row, err := s.repo.Get(ctx, req.GetId())
if err != nil {
return nil, (&examplev1.ValidationFailed{Field: "id"}).Join(err)
}
return mapRow(row), nil
}When a joined error reaches the server interceptor, the first registered public
ProtError is converted to a gRPC status.
Install the unary client interceptor to decode registered protobuf status details back into typed errors:
conn, err := grpc.NewClient(
target,
grpc.WithChainUnaryInterceptor(
proterrors.UnaryClientInterceptor(),
),
)Then handle errors using the standard library:
resp, err := client.Create(ctx, req)
if err != nil {
var validation *examplev1.ValidationFailed
if errors.As(err, &validation) {
return fmt.Errorf("invalid %s: %s", validation.GetField(), validation.GetReason())
}
return err
}WriteHTTPResponse writes a grpc-gateway-compatible JSON response using the
same status code and details model:
func handler(w http.ResponseWriter, r *http.Request) {
if err := doWork(r.Context()); err != nil {
proterrors.WriteHTTPResponse(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}Unknown non-ProtError values are written as the built-in Internal error.
FromPG converts common database/sql and pgx errors into built-in
proterror errors while preserving the original error with errors.Join:
func (r *Repository) Get(ctx context.Context, id string) (*Entity, error) {
entity, err := r.query(ctx, id)
if err != nil {
return nil, proterrors.FromPG(err)
}
return entity, nil
}Current mappings include:
sql.ErrNoRows->NotFound- unique violations ->
AlreadyExists - foreign-key and missing-object failures ->
NotFound - invalid input, check, not-null, and range failures ->
InvalidArgument - privilege and password failures ->
PermissionDeniedorUnauthenticated - deadlocks and query cancellations ->
DeadlineExceeded - connection pressure and unavailable database states ->
Unavailable - all other database errors ->
Internal
- Pin the generator version in CI and regenerate code as part of your protobuf workflow.
- Treat proto error messages as API contracts. Add fields carefully and avoid removing or repurposing existing field numbers.
- Mark sensitive failures as
internal: true; they will not be registered for public conversion. - Put user-safe fields in public errors and keep raw causes in joined errors, logs, traces, or metrics.
- Install the server interceptor on every gRPC server entry point that returns generated errors.
- Install the client interceptor where callers need typed error handling.
- Test public error behavior at API boundaries, not only in repository or service unit tests.
Repository layout:
proterror/contains the built-in error and option proto definitions.cmd/protoc-gen-proterror/contains the generator.docs/example/contains a runnable gRPC example.registry/contains the runtime public error registry.
Useful commands:
make generate
go test ./...make generate builds bin/protoc-gen-proterror and runs buf generate.
Install Buf and the protobuf toolchain before regenerating code.