Skip to content

Commit 8642e49

Browse files
authored
Merge pull request #715 from Classique-seyi/feat/error-code-registry
feat: stable error code registry enforcement across handlers
2 parents 6e4270d + 02774e4 commit 8642e49

11 files changed

Lines changed: 294 additions & 91 deletions

File tree

docs/error-codes.md

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ Error codes are registered in `internal/errcode/registry.go`. Each sentinel erro
2525
| `client` | Client-side errors (bad request, validation, auth, etc.) |
2626
| `subscription` | Subscription lifecycle and billing errors |
2727
| `export` | Tenant data export errors |
28+
| `fee` | Fee calculation and tax split errors |
2829
| `swap` | Token swap errors |
30+
| `pagination` | Pagination and cursor errors |
31+
| `idempotency` | Idempotency key errors |
2932
| `system` | Internal server and service errors |
3033

3134
### Code Reference
@@ -60,12 +63,32 @@ Error codes are registered in `internal/errcode/registry.go`. Each sentinel erro
6063
|------|-------------|-------------|
6164
| `export/in-progress` | 409 | An export is already in progress for this tenant |
6265

66+
#### Fee Errors
67+
68+
| Code | HTTP Status | Description |
69+
|------|-------------|-------------|
70+
| `fee/invalid-amount` | 422 | Amount must be non-negative |
71+
| `fee/invalid-tax-rate` | 422 | Tax rate must be between 0 and 1 inclusive |
72+
| `fee/invalid-parts` | 422 | Number of proration parts must be greater than zero |
73+
6374
#### Swap Errors
6475

6576
| Code | HTTP Status | Description |
6677
|------|-------------|-------------|
6778
| `swap/insufficient-liquidity` | 422 | Swap cannot be fulfilled due to insufficient liquidity |
6879

80+
#### Pagination Errors
81+
82+
| Code | HTTP Status | Description |
83+
|------|-------------|-------------|
84+
| `pagination/invalid-limit` | 400 | Limit parameter is not a valid integer or exceeds maximum |
85+
86+
#### Idempotency Errors
87+
88+
| Code | HTTP Status | Description |
89+
|------|-------------|-------------|
90+
| `idempotency/request-mismatch` | 422 | Idempotency key reused with a different request payload |
91+
6992
#### System Errors
7093

7194
| Code | HTTP Status | Description |
@@ -143,10 +166,11 @@ Feature flag middleware returns its own structured response:
143166

144167
1. Add the `Code` constant in `internal/errcode/registry.go`
145168
2. Register the matcher in the sending service package's `init()` function using `errcode.Register`
146-
3. Add documentation to this file
147-
4. Add tests verifying the error emits the correct code
169+
3. Add the sentinel error to `registeredSentinelErrors` in `internal/service/errors_enforce_test.go`
170+
4. Add documentation to this file
171+
5. Add tests verifying the error emits the correct code
148172

149-
**Adding a new error without registering the code will fail CI** — the registry validation tests ensure every error sentinel used in the codebase has a corresponding code entry.
173+
**Adding a new error without completing these steps will fail CI** — the `TestEverySentinelErrorIsRegistered` test in `internal/service/errors_enforce_test.go` ensures every sentinel error used in the codebase has a corresponding code entry and is listed in the enforcement table.
150174

151175
## Testing
152176

internal/errcode/registry.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@ const (
2929
// Export errors
3030
CodeExportInProgress Code = "export/in-progress"
3131

32+
// Fee errors
33+
CodeFeeInvalidAmount Code = "fee/invalid-amount"
34+
CodeFeeInvalidTaxRate Code = "fee/invalid-tax-rate"
35+
CodeFeeInvalidParts Code = "fee/invalid-parts"
36+
37+
// Pagination errors
38+
CodeInvalidLimit Code = "pagination/invalid-limit"
39+
40+
// Idempotency errors
41+
CodeIdempotencyRequestMismatch Code = "idempotency/request-mismatch"
42+
3243
// Swap errors
3344
CodeSwapInsufficientLiquidity Code = "swap/insufficient-liquidity"
3445

internal/metrics/metrics.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,16 @@ var (
8585
Name: "churn_rate_24h",
8686
Help: "Churn rate over the last 24 hours (0.0 to 1.0)",
8787
})
88+
89+
// AnalyzeLastRunTimestamp tracks the last successful ANALYZE execution
90+
// time per table as a Unix timestamp. Updated by the AnalyzeJob worker.
91+
AnalyzeLastRunTimestamp = promauto.NewGaugeVec(
92+
prometheus.GaugeOpts{
93+
Name: "analyze_last_run_timestamp_seconds",
94+
Help: "Unix timestamp of the last successful ANALYZE run per table",
95+
},
96+
[]string{"table"},
97+
)
8898
)
8999

90100
func MetricsMiddleware() gin.HandlerFunc {

internal/middleware/idempotency_store.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,19 @@ import (
66
"sync"
77
"time"
88

9+
"stellarbill-backend/internal/errcode"
10+
911
"github.com/jackc/pgx/v5"
1012
"github.com/jackc/pgx/v5/pgxpool"
1113
)
1214

1315
// ErrRequestMismatch is returned when an idempotency key is reused with a different request.
1416
var ErrRequestMismatch = errors.New("idempotency key reused with a different request")
1517

18+
func init() {
19+
errcode.Register(func(err error) bool { return errors.Is(err, ErrRequestMismatch) }, errcode.CodeIdempotencyRequestMismatch)
20+
}
21+
1622
// IdempotencyStore defines the contract for persisting idempotency keys and request states.
1723
type IdempotencyStore interface {
1824
GetOrInsert(ctx context.Context, scope, key, method, path, payloadHash string, ttl time.Duration) (statusCode int, responseBody []byte, isReplay bool, isInFlight bool, err error)

internal/pagination/limit.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"errors"
55
"strconv"
66
"strings"
7+
8+
"stellarbill-backend/internal/errcode"
79
)
810

911
const (
@@ -16,6 +18,10 @@ const (
1618
// ErrInvalidLimit is returned when a limit parameter is not a valid integer.
1719
var ErrInvalidLimit = errors.New("invalid limit value")
1820

21+
func init() {
22+
errcode.Register(func(err error) bool { return errors.Is(err, ErrInvalidLimit) }, errcode.CodeInvalidLimit)
23+
}
24+
1925
// ParseLimit parses the raw limit query parameter.
2026
// It enforces the following rules:
2127
// - empty string (missing or empty) -> defaultLimit

internal/routes/routes.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ package routes
33
import (
44
"context"
55
"fmt"
6+
"log"
67
"os"
78
"time"
89

910
"stellarbill-backend/internal/auth"
1011
"stellarbill-backend/internal/config"
12+
"stellarbill-backend/internal/db"
1113
"stellarbill-backend/internal/handlers"
1214
"stellarbill-backend/internal/middleware"
1315
"stellarbill-backend/internal/reconciliation"
@@ -16,6 +18,7 @@ import (
1618
"stellarbill-backend/internal/startup"
1719
"stellarbill-backend/internal/storage/s3"
1820
"stellarbill-backend/internal/tracing"
21+
"stellarbill-backend/internal/worker"
1922

2023
"github.com/gin-gonic/gin"
2124
"github.com/prometheus/client_golang/prometheus/promhttp"
@@ -29,6 +32,11 @@ func Register(r *gin.Engine) {
2932
panic(fmt.Sprintf("failed to load configuration: %v", err))
3033
}
3134

35+
// Start the ANALYZE background job to keep table statistics fresh.
36+
// Uses pgxpool so ANALYZE runs on the same connection pool as the rest
37+
// of the application, avoiding a separate connection.
38+
startAnalyzeJob(cfg)
39+
3240
// Initialize tracing
3341
if cfg.TracingExporter != "none" {
3442
_, err := tracing.InitTracer(cfg.TracingServiceName)
@@ -186,6 +194,39 @@ func Register(r *gin.Engine) {
186194

187195
type noopS3Uploader struct{}
188196

197+
// startAnalyzeJob initializes the database pool and starts the periodic ANALYZE
198+
// background job. If the pool cannot be created (e.g. no DATABASE_URL in
199+
// development), it logs a warning and skips the job rather than failing
200+
// the entire startup.
201+
//
202+
// Note: The pool and job are intentionally not torn down on graceful shutdown.
203+
// The pool is long-lived (matching the server process lifetime) and ANALYZE is
204+
// non-blocking, so immediate termination on process exit is safe. A shutdown
205+
// hook can be added later if needed.
206+
func startAnalyzeJob(cfg config.Config) {
207+
pool, err := db.NewPool(context.Background(), cfg)
208+
if err != nil {
209+
log.Printf("analyze job: skipping — db pool creation failed: %v", err)
210+
return
211+
}
212+
if pool == nil {
213+
log.Println("analyze job: skipping — no database configured (empty DATABASE_URL)")
214+
return
215+
}
216+
217+
analyzeJob := worker.NewAnalyzeJob(pool, worker.DefaultAnalyzeConfig(), analyzeLogger{})
218+
analyzeJob.Start()
219+
log.Println("analyze job: started periodic ANALYZE for hot tables (outbox_events, statements, subscriptions)")
220+
}
221+
222+
// analyzeLogger adapts the standard log package to the worker's analyzeLogger
223+
// interface so the ANALYZE job can emit structured error messages.
224+
type analyzeLogger struct{}
225+
226+
func (analyzeLogger) Error(msg string, keysAndValues ...any) {
227+
log.Printf("ERROR: %s %v", msg, keysAndValues)
228+
}
229+
189230
func (noopS3Uploader) PutObject(context.Context, string, []byte, string) error { return nil }
190231
func (noopS3Uploader) PresignURL(context.Context, string, time.Duration) (s3.PresignedURL, error) {
191232
return s3.PresignedURL{URL: "", ExpiresAt: time.Time{}}, nil
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package service_test
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"testing"
7+
8+
"stellarbill-backend/internal/errcode"
9+
"stellarbill-backend/internal/middleware"
10+
"stellarbill-backend/internal/pagination"
11+
"stellarbill-backend/internal/service"
12+
)
13+
14+
// registeredSentinelErrors is the canonical list of every service-layer
15+
// sentinel error that surfaces to API clients. Every entry must have a
16+
// corresponding errcode.Register call in its package's init() function.
17+
//
18+
// ADDING A NEW ERROR WITHOUT ADDING IT HERE AND REGISTERING IT WILL FAIL CI.
19+
//
20+
// To add a new error code:
21+
// 1. Define the var ErrXxx = errors.New(...) in the appropriate package.
22+
// 2. Add errcode.Register(...) in that package's init().
23+
// 3. Add the code constant to internal/errcode/registry.go.
24+
// 4. Add the sentinel to the list below.
25+
// 5. Document the new code in docs/error-codes.md.
26+
var registeredSentinelErrors = []struct {
27+
name string
28+
err error
29+
code errcode.Code
30+
}{
31+
// service/errors.go
32+
{"ErrNotFound", service.ErrNotFound, errcode.CodeNotFound},
33+
{"ErrDeleted", service.ErrDeleted, errcode.CodeSubscriptionDeleted},
34+
{"ErrForbidden", service.ErrForbidden, errcode.CodeForbidden},
35+
{"ErrBillingParse", service.ErrBillingParse, errcode.CodeSubscriptionBillingParse},
36+
{"ErrExportInProgress", service.ErrExportInProgress, errcode.CodeExportInProgress},
37+
{"ErrInvalidTransition", service.ErrInvalidTransition, errcode.CodeSubscriptionInvalidTransition},
38+
{"ErrUnknownCurrentState", service.ErrUnknownCurrentState, errcode.CodeSubscriptionUnknownState},
39+
{"ErrInvalidStatus", service.ErrInvalidStatus, errcode.CodeSubscriptionInvalidStatus},
40+
41+
// service/fees_service.go
42+
{"ErrInvalidAmount", service.ErrInvalidAmount, errcode.CodeFeeInvalidAmount},
43+
{"ErrInvalidTaxRate", service.ErrInvalidTaxRate, errcode.CodeFeeInvalidTaxRate},
44+
{"ErrInvalidParts", service.ErrInvalidParts, errcode.CodeFeeInvalidParts},
45+
46+
// service/swap_service.go
47+
{"ErrInsufficientLiquidity", service.ErrInsufficientLiquidity, errcode.CodeSwapInsufficientLiquidity},
48+
49+
// pagination/limit.go
50+
{"ErrInvalidLimit", pagination.ErrInvalidLimit, errcode.CodeInvalidLimit},
51+
52+
// middleware/idempotency_store.go
53+
{"ErrRequestMismatch", middleware.ErrRequestMismatch, errcode.CodeIdempotencyRequestMismatch},
54+
}
55+
56+
// TestEverySentinelErrorIsRegistered ensures that every service-level
57+
// sentinel error added to registeredSentinelErrors has a matching entry in
58+
// the errcode registry. This test must be updated whenever a new sentinel
59+
// error is introduced — CI will fail otherwise.
60+
func TestEverySentinelErrorIsRegistered(t *testing.T) {
61+
for _, entry := range registeredSentinelErrors {
62+
t.Run(entry.name, func(t *testing.T) {
63+
code, found := errcode.MustLookup(entry.err)
64+
if !found {
65+
t.Errorf("sentinel error %q (%v) is NOT registered in errcode — "+
66+
"add errcode.Register(...) in the package's init() and update registeredSentinelErrors",
67+
entry.name, entry.err)
68+
return
69+
}
70+
if code != entry.code {
71+
t.Errorf("sentinel error %q has code %q, want %q",
72+
entry.name, code, entry.code)
73+
}
74+
})
75+
}
76+
}
77+
78+
// TestAllRegisteredCodesAreUsed ensures no stale codes remain in the
79+
// registry without a matching sentinel error in the enforcement list.
80+
// This catches the case where a code is added but the error definition
81+
// is later removed.
82+
func TestAllRegisteredCodesAreUsed(t *testing.T) {
83+
allCodes := errcode.AllCodes()
84+
if len(allCodes) == 0 {
85+
t.Fatal("expected non-empty code list from AllCodes()")
86+
}
87+
88+
// Build a set of expected codes from the enforcement list.
89+
expected := make(map[errcode.Code]bool)
90+
for _, entry := range registeredSentinelErrors {
91+
expected[entry.code] = true
92+
}
93+
// Plus: the general-purpose codes that aren't tied to specific sentinel errors.
94+
expected[errcode.CodeBadRequest] = true
95+
expected[errcode.CodeUnauthorized] = true
96+
expected[errcode.CodeForbidden] = true
97+
expected[errcode.CodeNotFound] = true
98+
expected[errcode.CodeConflict] = true
99+
expected[errcode.CodeValidationFailed] = true
100+
expected[errcode.CodeUnknownField] = true
101+
expected[errcode.CodeInternalError] = true
102+
expected[errcode.CodeServiceUnavailable] = true
103+
104+
for _, code := range allCodes {
105+
if !expected[code] {
106+
t.Errorf("code %q is registered in errcode but not accounted for in registeredSentinelErrors or general-purpose codes", code)
107+
}
108+
}
109+
}
110+
111+
// TestAllSentinelErrorsHaveNonEmptyCode verifies every sentinel resolves
112+
// to a non-empty code string.
113+
func TestAllSentinelErrorsHaveNonEmptyCode(t *testing.T) {
114+
for _, entry := range registeredSentinelErrors {
115+
if entry.code == "" {
116+
t.Errorf("sentinel error %q has an empty code", entry.name)
117+
}
118+
}
119+
}
120+
121+
// TestSentinelErrorsWrapCorrectly verifies that each sentinel can be
122+
// resolved even when wrapped with fmt.Errorf("...: %w", sentinel).
123+
func TestSentinelErrorsWrapCorrectly(t *testing.T) {
124+
type wrapCase struct {
125+
name string
126+
sentinel error
127+
wantCode errcode.Code
128+
}
129+
cases := []wrapCase{
130+
{"ErrNotFound", service.ErrNotFound, errcode.CodeNotFound},
131+
{"ErrInvalidTransition", service.ErrInvalidTransition, errcode.CodeSubscriptionInvalidTransition},
132+
{"ErrInvalidAmount", service.ErrInvalidAmount, errcode.CodeFeeInvalidAmount},
133+
{"ErrInvalidLimit", pagination.ErrInvalidLimit, errcode.CodeInvalidLimit},
134+
{"ErrRequestMismatch", middleware.ErrRequestMismatch, errcode.CodeIdempotencyRequestMismatch},
135+
}
136+
137+
for _, tc := range cases {
138+
t.Run(tc.name, func(t *testing.T) {
139+
wrapped := fmt.Errorf("wrapped: %w", tc.sentinel)
140+
code := errcode.Lookup(wrapped)
141+
if code != tc.wantCode {
142+
t.Errorf("wrapped %s: got code %q, want %q", tc.name, code, tc.wantCode)
143+
}
144+
})
145+
}
146+
}

internal/service/errors_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package service_test
22

33
import (
44
"errors"
5+
"fmt"
56
"testing"
67

78
"stellarbill-backend/internal/errcode"
@@ -122,7 +123,7 @@ func TestAllServiceErrorsHaveCodes(t *testing.T) {
122123
}
123124

124125
func TestLookupWrapsCorrectly(t *testing.T) {
125-
wrapped := errors.New("wrapped: " + service.ErrNotFound.Error())
126+
wrapped := fmt.Errorf("wrapped: %w", service.ErrNotFound)
126127
code := errcode.Lookup(wrapped)
127128
if code != errcode.CodeNotFound {
128129
t.Errorf("expected %q for wrapped ErrNotFound, got %q", errcode.CodeNotFound, code)

internal/service/fees_service.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"math"
77
"time"
88

9+
"stellarbill-backend/internal/errcode"
10+
911
"github.com/shopspring/decimal"
1012
)
1113

@@ -42,6 +44,12 @@ var ErrInvalidTaxRate = errors.New("tax rate must be between 0 and 1 inclusive")
4244
// ErrInvalidParts is returned when the number of proration parts is ≤ 0.
4345
var ErrInvalidParts = errors.New("parts must be greater than zero")
4446

47+
func init() {
48+
errcode.Register(func(err error) bool { return errors.Is(err, ErrInvalidAmount) }, errcode.CodeFeeInvalidAmount)
49+
errcode.Register(func(err error) bool { return errors.Is(err, ErrInvalidTaxRate) }, errcode.CodeFeeInvalidTaxRate)
50+
errcode.Register(func(err error) bool { return errors.Is(err, ErrInvalidParts) }, errcode.CodeFeeInvalidParts)
51+
}
52+
4553
// MoneyAmount holds a currency-aware decimal amount.
4654
type MoneyAmount struct {
4755
Value decimal.Decimal

0 commit comments

Comments
 (0)