From a5efccd4cccfab19c8aad95d71cca3c2b4648693 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Tue, 2 Jun 2026 19:01:58 +0200 Subject: [PATCH] feat(gotenberg): add ClassifyError with bounded error.type enum --- pkg/gotenberg/errortype.go | 52 ++++++++++++++++++++++++++++ pkg/gotenberg/errortype_test.go | 60 +++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 pkg/gotenberg/errortype.go create mode 100644 pkg/gotenberg/errortype_test.go diff --git a/pkg/gotenberg/errortype.go b/pkg/gotenberg/errortype.go new file mode 100644 index 0000000..389c65b --- /dev/null +++ b/pkg/gotenberg/errortype.go @@ -0,0 +1,52 @@ +package gotenberg + +import ( + "context" + "errors" + + semconv "go.opentelemetry.io/otel/semconv/v1.41.0" + "go.opentelemetry.io/otel/trace" +) + +// Engine-agnostic, low-cardinality error.type values shared by the conversion +// engines. They are safe to use both as the semconv error.type span attribute +// and as bounded metric label values. +const ( + ErrorTypeTimeout = "timeout" + ErrorTypeContextCancelled = "context_cancelled" + ErrorTypeQueueSizeExceeded = "queue_size_exceeded" + ErrorTypeProcessRestarting = "process_restarting" + ErrorTypeInvalidInput = "invalid_input" + ErrorTypeUnknown = "unknown" +) + +// ClassifyError maps err to a bounded, engine-agnostic error.type value. It +// recognizes the failure modes shared by every engine: deadline, cancellation, +// queue saturation, and process restart. It returns an empty string for a nil +// error and [ErrorTypeUnknown] for anything it does not recognize, leaving +// engine-specific refinement (such as [ErrorTypeInvalidInput]) to the caller. +func ClassifyError(err error) string { + switch { + case err == nil: + return "" + case errors.Is(err, context.DeadlineExceeded): + return ErrorTypeTimeout + case errors.Is(err, context.Canceled): + return ErrorTypeContextCancelled + case errors.Is(err, ErrMaximumQueueSizeExceeded): + return ErrorTypeQueueSizeExceeded + case errors.Is(err, ErrProcessAlreadyRestarting): + return ErrorTypeProcessRestarting + default: + return ErrorTypeUnknown + } +} + +// SpanErrorType records errorType as the semconv error.type attribute on span. +// It is a no-op when errorType is empty. +func SpanErrorType(span trace.Span, errorType string) { + if errorType == "" { + return + } + span.SetAttributes(semconv.ErrorTypeKey.String(errorType)) +} diff --git a/pkg/gotenberg/errortype_test.go b/pkg/gotenberg/errortype_test.go new file mode 100644 index 0000000..9518c71 --- /dev/null +++ b/pkg/gotenberg/errortype_test.go @@ -0,0 +1,60 @@ +package gotenberg + +import ( + "context" + "errors" + "fmt" + "testing" + + "go.opentelemetry.io/otel" +) + +func TestClassifyError(t *testing.T) { + for _, tc := range []struct { + name string + err error + want string + }{ + {"nil", nil, ""}, + {"deadline", context.DeadlineExceeded, ErrorTypeTimeout}, + {"canceled", context.Canceled, ErrorTypeContextCancelled}, + {"queue size exceeded", ErrMaximumQueueSizeExceeded, ErrorTypeQueueSizeExceeded}, + {"process restarting", ErrProcessAlreadyRestarting, ErrorTypeProcessRestarting}, + {"wrapped deadline", fmt.Errorf("convert: %w", context.DeadlineExceeded), ErrorTypeTimeout}, + {"joined queue", errors.Join(errors.New("attempt"), ErrMaximumQueueSizeExceeded), ErrorTypeQueueSizeExceeded}, + {"arbitrary", errors.New("boom"), ErrorTypeUnknown}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := ClassifyError(tc.err); got != tc.want { + t.Errorf("ClassifyError(%v) = %q, want %q", tc.err, got, tc.want) + } + }) + } +} + +func TestSpanErrorType(t *testing.T) { + recorder := newTestSpanRecorder(t) + + _, span := otel.Tracer("test").Start(context.Background(), "engine.Op") + SpanErrorType(span, "") // no-op, must not add an attribute + SpanErrorType(span, ErrorTypeTimeout) // sets error.type + span.End() + + got := findSpan(recorder, "engine.Op") + if got == nil { + t.Fatal("expected the span to be recorded") + } + + count := 0 + for _, kv := range got.Attributes() { + if string(kv.Key) == "error.type" { + count++ + if kv.Value.AsString() != ErrorTypeTimeout { + t.Errorf("expected error.type=%q, got %q", ErrorTypeTimeout, kv.Value.AsString()) + } + } + } + if count != 1 { + t.Errorf("expected exactly one error.type attribute, got %d", count) + } +}