From 0c2ab8b99557ca5448fea0155a481b0df39362dc Mon Sep 17 00:00:00 2001 From: froppa <5844892+froppa@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:30:36 +0200 Subject: [PATCH 1/3] Struktly: Log the readiness failure server-side instead of returning it --- internal/app/wire.go | 2 +- .../transport/http/handlers/handlers_test.go | 66 ++++++++++++++++++- internal/transport/http/handlers/health.go | 13 ++-- internal/transport/http/server_test.go | 4 +- 4 files changed, 76 insertions(+), 9 deletions(-) diff --git a/internal/app/wire.go b/internal/app/wire.go index 00f36c7..243a24a 100644 --- a/internal/app/wire.go +++ b/internal/app/wire.go @@ -58,7 +58,7 @@ func Build(ctx context.Context, cfg config.Config) (*App, error) { getOrder := queries.NewGetOrderHandler(database, ordersStore, tracer) dispatcher := outbox.NewDispatcher(clock, database, outboxStore, publisher, logger, metrics, tracer) backgroundWorker := worker.New(cfg.WorkerInterval, dispatcher, logger) - health := handlers.NewHealthHandler(func(ctx context.Context) error { + health := handlers.NewHealthHandler(logger, func(ctx context.Context) error { return database.Ping(ctx) }) server := httptransport.NewServer(httptransport.Dependencies{ diff --git a/internal/transport/http/handlers/handlers_test.go b/internal/transport/http/handlers/handlers_test.go index da155b3..c2978e8 100644 --- a/internal/transport/http/handlers/handlers_test.go +++ b/internal/transport/http/handlers/handlers_test.go @@ -4,18 +4,24 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" + "strings" "testing" "time" "github.com/go-chi/chi/v5" "go.opentelemetry.io/otel/trace/noop" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" "github.com/froppa/orders-service/internal/application/commands" "github.com/froppa/orders-service/internal/application/ports" "github.com/froppa/orders-service/internal/application/queries" "github.com/froppa/orders-service/internal/domain/orders" + "github.com/froppa/orders-service/internal/observability" ) type handlerClock struct { @@ -58,14 +64,14 @@ func (handlerOutboxRepo) MarkDispatched(context.Context, ports.DBTX, string, tim } func TestHealthHandlers(t *testing.T) { - ready := NewHealthHandler(func(context.Context) error { return nil }) + ready := NewHealthHandler(zap.NewNop(), func(context.Context) error { return nil }) healthResp := httptest.NewRecorder() ready.Healthz(healthResp, httptest.NewRequest(http.MethodGet, "/healthz", nil)) if healthResp.Code != http.StatusOK { t.Fatalf("health status = %d", healthResp.Code) } - notReady := NewHealthHandler(func(context.Context) error { return context.DeadlineExceeded }) + notReady := NewHealthHandler(zap.NewNop(), func(context.Context) error { return context.DeadlineExceeded }) readyResp := httptest.NewRecorder() notReady.Readyz(readyResp, httptest.NewRequest(http.MethodGet, "/readyz", nil)) if readyResp.Code != http.StatusServiceUnavailable { @@ -73,6 +79,62 @@ func TestHealthHandlers(t *testing.T) { } } +func TestReadyzLogsCauseInsteadOfReturningIt(t *testing.T) { + const causeDetail = "10.0.0.7:5432" + core, logs := observer.New(zapcore.DebugLevel) + handler := NewHealthHandler(zap.New(core), func(context.Context) error { + return errors.New("dial tcp " + causeDetail + ": connect: connection refused") + }) + + req := httptest.NewRequest(http.MethodGet, "/readyz", nil) + req = req.WithContext(observability.WithRequestID(req.Context(), "req-1")) + resp := httptest.NewRecorder() + handler.Readyz(resp, req) + + if resp.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d", resp.Code) + } + if strings.Contains(resp.Body.String(), causeDetail) { + t.Fatalf("response body leaked the readiness error: %s", resp.Body.String()) + } + + var body struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + Details map[string]any `json:"details"` + } `json:"error"` + } + if err := json.Unmarshal(resp.Body.Bytes(), &body); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if body.Error.Code != "not_ready" || body.Error.Message != "service is not ready" { + t.Fatalf("error = %+v", body.Error) + } + if body.Error.Details != nil { + t.Fatalf("details = %v, want nil", body.Error.Details) + } + + entries := logs.All() + if len(entries) != 1 { + t.Fatalf("log entries = %d, want 1", len(entries)) + } + entry := entries[0] + if entry.Level != zapcore.WarnLevel { + t.Fatalf("level = %s, want warn", entry.Level) + } + if strings.Contains(entry.Message, causeDetail) { + t.Fatalf("log message embeds the cause instead of a zap.Error field: %s", entry.Message) + } + fields := entry.ContextMap() + if loggedErr, _ := fields["error"].(string); !strings.Contains(loggedErr, causeDetail) { + t.Fatalf("error field = %v, want the readiness error", fields["error"]) + } + if fields["request_id"] != "req-1" { + t.Fatalf("request_id = %v, want req-1", fields["request_id"]) + } +} + func TestOrdersHandlers(t *testing.T) { now := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC) ordersRepo := &handlerOrdersRepo{} diff --git a/internal/transport/http/handlers/health.go b/internal/transport/http/handlers/health.go index 864d50e..686485f 100644 --- a/internal/transport/http/handlers/health.go +++ b/internal/transport/http/handlers/health.go @@ -6,15 +6,19 @@ import ( "errors" "net/http" + "go.uber.org/zap" + "github.com/froppa/orders-service/internal/domain/orders" + "github.com/froppa/orders-service/internal/observability" ) type HealthHandler struct { - ready func(context.Context) error + logger *zap.Logger + ready func(context.Context) error } -func NewHealthHandler(ready func(context.Context) error) *HealthHandler { - return &HealthHandler{ready: ready} +func NewHealthHandler(logger *zap.Logger, ready func(context.Context) error) *HealthHandler { + return &HealthHandler{logger: logger, ready: ready} } func (h *HealthHandler) Healthz(w http.ResponseWriter, _ *http.Request) { @@ -23,7 +27,8 @@ func (h *HealthHandler) Healthz(w http.ResponseWriter, _ *http.Request) { func (h *HealthHandler) Readyz(w http.ResponseWriter, r *http.Request) { if err := h.ready(r.Context()); err != nil { - writeError(w, http.StatusServiceUnavailable, "not_ready", "service is not ready", map[string]any{"cause": err.Error()}) + observability.WithContext(r.Context(), h.logger).Warn("readiness check failed", zap.Error(err)) + writeError(w, http.StatusServiceUnavailable, "not_ready", "service is not ready", nil) return } writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) diff --git a/internal/transport/http/server_test.go b/internal/transport/http/server_test.go index 69cb9d8..1cb9dc8 100644 --- a/internal/transport/http/server_test.go +++ b/internal/transport/http/server_test.go @@ -122,7 +122,7 @@ func TestCreateOrderAndReplayIdempotency(t *testing.T) { Metrics: metrics, Idempotency: &memoryIdempotencyRepo{records: make(map[string]ports.IdempotencyRecord)}, Transactor: transactor, - HealthHandler: handlers.NewHealthHandler(func(context.Context) error { return nil }), + HealthHandler: handlers.NewHealthHandler(logger, func(context.Context) error { return nil }), CreateOrder: createHandler, GetOrder: getHandler, }) @@ -183,7 +183,7 @@ func TestIdempotencyConflict(t *testing.T) { Metrics: metrics, Idempotency: &memoryIdempotencyRepo{records: make(map[string]ports.IdempotencyRecord)}, Transactor: transactor, - HealthHandler: handlers.NewHealthHandler(func(context.Context) error { return nil }), + HealthHandler: handlers.NewHealthHandler(logger, func(context.Context) error { return nil }), CreateOrder: handlers.NewOrdersCreateHandler(commands.NewCreateOrderHandler(testClock{now: time.Now()}, transactor, ordersRepo, memoryOutboxRepo{}, tracer)), GetOrder: handlers.NewOrdersGetHandler(queries.NewGetOrderHandler(transactor, ordersRepo, tracer)), }) From 081af2628cef872a7a943160daa5093428d55eb5 Mon Sep 17 00:00:00 2001 From: froppa <5844892+froppa@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:32:50 +0200 Subject: [PATCH 2/3] Struktly: Assert the 503 body excludes the readiness error text --- .../commands/create_order_invalid_test.go | 1 + .../infrastructure/outbox/dispatcher_test.go | 1 + internal/infrastructure/store/outbox_store.go | 4 +--- internal/infrastructure/worker/worker_test.go | 1 + .../http/handlers/error_paths_test.go | 1 + .../transport/http/handlers/handlers_test.go | 22 ++++++++++++++++++- .../http/middleware/middleware_test.go | 3 +++ internal/transport/http/server_test.go | 1 + 8 files changed, 30 insertions(+), 4 deletions(-) diff --git a/internal/application/commands/create_order_invalid_test.go b/internal/application/commands/create_order_invalid_test.go index 8733d47..f61c97a 100644 --- a/internal/application/commands/create_order_invalid_test.go +++ b/internal/application/commands/create_order_invalid_test.go @@ -36,6 +36,7 @@ func (invalidOutboxRepo) Add(context.Context, ports.DBTX, ports.OutboxMessage) e func (invalidOutboxRepo) ListPending(context.Context, ports.DBTX, int) ([]ports.OutboxMessage, error) { return nil, nil } + func (invalidOutboxRepo) MarkDispatched(context.Context, ports.DBTX, string, time.Time) error { return nil } diff --git a/internal/infrastructure/outbox/dispatcher_test.go b/internal/infrastructure/outbox/dispatcher_test.go index e4e2cc3..02d9074 100644 --- a/internal/infrastructure/outbox/dispatcher_test.go +++ b/internal/infrastructure/outbox/dispatcher_test.go @@ -35,6 +35,7 @@ func (o *dispatcherOutbox) Add(context.Context, ports.DBTX, ports.OutboxMessage) func (o *dispatcherOutbox) ListPending(context.Context, ports.DBTX, int) ([]ports.OutboxMessage, error) { return o.messages, nil } + func (o *dispatcherOutbox) MarkDispatched(_ context.Context, _ ports.DBTX, id string, _ time.Time) error { o.marked = append(o.marked, id) return nil diff --git a/internal/infrastructure/store/outbox_store.go b/internal/infrastructure/store/outbox_store.go index 250231d..c8701fc 100644 --- a/internal/infrastructure/store/outbox_store.go +++ b/internal/infrastructure/store/outbox_store.go @@ -67,6 +67,4 @@ func (s *OutboxStore) MarkDispatched(ctx context.Context, q ports.DBTX, id strin return err } -var ( - _ = sql.NullTime{} -) +var _ = sql.NullTime{} diff --git a/internal/infrastructure/worker/worker_test.go b/internal/infrastructure/worker/worker_test.go index 7225e8a..42f3be7 100644 --- a/internal/infrastructure/worker/worker_test.go +++ b/internal/infrastructure/worker/worker_test.go @@ -34,6 +34,7 @@ func (w *workerOutbox) ListPending(context.Context, ports.DBTX, int) ([]ports.Ou w.calls++ return nil, nil } + func (w *workerOutbox) MarkDispatched(context.Context, ports.DBTX, string, time.Time) error { return nil } diff --git a/internal/transport/http/handlers/error_paths_test.go b/internal/transport/http/handlers/error_paths_test.go index 5cf441f..8305e46 100644 --- a/internal/transport/http/handlers/error_paths_test.go +++ b/internal/transport/http/handlers/error_paths_test.go @@ -39,6 +39,7 @@ func (errorOutboxRepo) Add(context.Context, ports.DBTX, ports.OutboxMessage) err func (errorOutboxRepo) ListPending(context.Context, ports.DBTX, int) ([]ports.OutboxMessage, error) { return nil, nil } + func (errorOutboxRepo) MarkDispatched(context.Context, ports.DBTX, string, time.Time) error { return nil } diff --git a/internal/transport/http/handlers/handlers_test.go b/internal/transport/http/handlers/handlers_test.go index c2978e8..20b907b 100644 --- a/internal/transport/http/handlers/handlers_test.go +++ b/internal/transport/http/handlers/handlers_test.go @@ -46,6 +46,7 @@ func (r *handlerOrdersRepo) Create(_ context.Context, _ ports.DBTX, order orders r.order = order return nil } + func (r *handlerOrdersRepo) GetByID(_ context.Context, _ ports.DBTX, id string) (orders.Order, error) { if r.order.ID != id { return orders.Order{}, orders.ErrNotFound @@ -59,6 +60,7 @@ func (handlerOutboxRepo) Add(context.Context, ports.DBTX, ports.OutboxMessage) e func (handlerOutboxRepo) ListPending(context.Context, ports.DBTX, int) ([]ports.OutboxMessage, error) { return nil, nil } + func (handlerOutboxRepo) MarkDispatched(context.Context, ports.DBTX, string, time.Time) error { return nil } @@ -71,12 +73,30 @@ func TestHealthHandlers(t *testing.T) { t.Fatalf("health status = %d", healthResp.Code) } - notReady := NewHealthHandler(zap.NewNop(), func(context.Context) error { return context.DeadlineExceeded }) + const readyCause = "10.1.2.3:5432" + notReady := NewHealthHandler(zap.NewNop(), func(context.Context) error { + return errors.New("dial tcp " + readyCause + ": connect: connection refused") + }) readyResp := httptest.NewRecorder() notReady.Readyz(readyResp, httptest.NewRequest(http.MethodGet, "/readyz", nil)) if readyResp.Code != http.StatusServiceUnavailable { t.Fatalf("ready status = %d", readyResp.Code) } + if strings.Contains(readyResp.Body.String(), readyCause) { + t.Fatalf("readyz body leaked the readiness cause: %s", readyResp.Body.String()) + } + + var readyBody struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(readyResp.Body.Bytes(), &readyBody); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if readyBody.Error.Code != "not_ready" { + t.Fatalf("error code = %q, want not_ready", readyBody.Error.Code) + } } func TestReadyzLogsCauseInsteadOfReturningIt(t *testing.T) { diff --git a/internal/transport/http/middleware/middleware_test.go b/internal/transport/http/middleware/middleware_test.go index a368ef4..679caae 100644 --- a/internal/transport/http/middleware/middleware_test.go +++ b/internal/transport/http/middleware/middleware_test.go @@ -29,6 +29,7 @@ type middlewareDBTX struct{} func (middlewareDBTX) ExecContext(context.Context, string, ...any) (sql.Result, error) { return nil, nil } + func (middlewareDBTX) QueryContext(context.Context, string, ...any) (*sql.Rows, error) { return nil, nil } @@ -45,6 +46,7 @@ func (m *memoryIdempotency) Get(_ context.Context, _ ports.DBTX, key string) (po } return ports.IdempotencyRecord{}, ports.ErrIdempotencyKeyNotFound } + func (m *memoryIdempotency) Reserve(_ context.Context, _ ports.DBTX, record ports.IdempotencyRecord) (bool, error) { if _, ok := m.records[record.Key]; ok { return false, nil @@ -52,6 +54,7 @@ func (m *memoryIdempotency) Reserve(_ context.Context, _ ports.DBTX, record port m.records[record.Key] = record return true, nil } + func (m *memoryIdempotency) Finalize(_ context.Context, _ ports.DBTX, key string, responseCode int, responseBody []byte) error { record := m.records[key] record.ResponseCode = responseCode diff --git a/internal/transport/http/server_test.go b/internal/transport/http/server_test.go index 1cb9dc8..1d352b3 100644 --- a/internal/transport/http/server_test.go +++ b/internal/transport/http/server_test.go @@ -67,6 +67,7 @@ func (memoryOutboxRepo) Add(context.Context, ports.DBTX, ports.OutboxMessage) er func (memoryOutboxRepo) ListPending(context.Context, ports.DBTX, int) ([]ports.OutboxMessage, error) { return nil, nil } + func (memoryOutboxRepo) MarkDispatched(context.Context, ports.DBTX, string, time.Time) error { return nil } From 8253b59626ea857b352aff1ab6fbe85858a71583 Mon Sep 17 00:00:00 2001 From: froppa <5844892+froppa@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:44:22 +0200 Subject: [PATCH 3/3] Struktly: Static analysis on the integrated tree --- internal/transport/http/handlers/health.go | 5 ++--- internal/transport/http/handlers/orders_create.go | 4 ++-- internal/transport/http/handlers/orders_get.go | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/internal/transport/http/handlers/health.go b/internal/transport/http/handlers/health.go index 686485f..2f35e30 100644 --- a/internal/transport/http/handlers/health.go +++ b/internal/transport/http/handlers/health.go @@ -28,18 +28,17 @@ func (h *HealthHandler) Healthz(w http.ResponseWriter, _ *http.Request) { func (h *HealthHandler) Readyz(w http.ResponseWriter, r *http.Request) { if err := h.ready(r.Context()); err != nil { observability.WithContext(r.Context(), h.logger).Warn("readiness check failed", zap.Error(err)) - writeError(w, http.StatusServiceUnavailable, "not_ready", "service is not ready", nil) + writeError(w, http.StatusServiceUnavailable, "not_ready", "service is not ready") return } writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) } -func writeError(w http.ResponseWriter, status int, code, message string, details map[string]any) { +func writeError(w http.ResponseWriter, status int, code, message string) { writeJSON(w, status, map[string]any{ "error": map[string]any{ "code": code, "message": message, - "details": details, }, }) } diff --git a/internal/transport/http/handlers/orders_create.go b/internal/transport/http/handlers/orders_create.go index 1346d13..47bbb43 100644 --- a/internal/transport/http/handlers/orders_create.go +++ b/internal/transport/http/handlers/orders_create.go @@ -22,7 +22,7 @@ func NewOrdersCreateHandler(useCase *commands.CreateOrderHandler) *OrdersCreateH func (h *OrdersCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { var req createOrderRequest if err := decodeJSON(r, &req); err != nil { - writeError(w, http.StatusBadRequest, "invalid_json", "request body must be valid JSON", nil) + writeError(w, http.StatusBadRequest, "invalid_json", "request body must be valid JSON") return } order, err := h.useCase.Handle(r.Context(), commands.CreateOrderCommand{ @@ -31,7 +31,7 @@ func (h *OrdersCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) }) if err != nil { status, code, message := mapDomainError(err) - writeError(w, status, code, message, nil) + writeError(w, status, code, message) return } writeJSON(w, http.StatusCreated, map[string]any{ diff --git a/internal/transport/http/handlers/orders_get.go b/internal/transport/http/handlers/orders_get.go index d901e4d..ec128f6 100644 --- a/internal/transport/http/handlers/orders_get.go +++ b/internal/transport/http/handlers/orders_get.go @@ -20,7 +20,7 @@ func (h *OrdersGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { order, err := h.useCase.Handle(r.Context(), chi.URLParam(r, "id")) if err != nil { status, code, message := mapDomainError(err) - writeError(w, status, code, message, nil) + writeError(w, status, code, message) return } writeJSON(w, http.StatusOK, map[string]any{