Skip to content

Commit c60274a

Browse files
committed
your commit message
1 parent 6eedf8c commit c60274a

10 files changed

Lines changed: 639 additions & 37 deletions

File tree

docs/error-codes.md

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# Error Code Registry
2+
3+
## Overview
4+
5+
The Stellabill backend uses a structured error code registry (`internal/errcode`) to assign stable, domain-prefixed identifiers to every error response. Clients branch on error codes rather than message strings, enabling reliable error handling across API versions.
6+
7+
## Format
8+
9+
Error codes follow the pattern `<domain>/<snake-case-slug>`:
10+
11+
```
12+
<domain>/<descriptive-identifier>
13+
```
14+
15+
Examples: `subscription/invalid-state-transition`, `client/not-found`, `system/internal-error`.
16+
17+
## Registry
18+
19+
Error codes are registered in `internal/errcode/registry.go`. Each sentinel error in `internal/service/errors.go` and other service packages is mapped to a stable code via `errcode.Register` at `init()` time.
20+
21+
### Available Domains
22+
23+
| Domain | Description |
24+
|--------|-------------|
25+
| `client` | Client-side errors (bad request, validation, auth, etc.) |
26+
| `subscription` | Subscription lifecycle and billing errors |
27+
| `export` | Tenant data export errors |
28+
| `swap` | Token swap errors |
29+
| `system` | Internal server and service errors |
30+
31+
### Code Reference
32+
33+
#### Client Errors
34+
35+
| Code | HTTP Status | Description |
36+
|------|-------------|-------------|
37+
| `client/bad-request` | 400 | Invalid request parameters or format |
38+
| `client/validation-failed` | 400 | Input validation failed (details in `details`) |
39+
| `client/unauthorized` | 401 | Missing or invalid authentication credentials |
40+
| `client/forbidden` | 403 | Authenticated user lacks permission |
41+
| `client/not-found` | 404 | Requested resource does not exist |
42+
| `client/conflict` | 409 | Request conflicts with current resource state |
43+
| `client/unknown-field` | 400 | Unknown field in request body |
44+
45+
#### Subscription Errors
46+
47+
| Code | HTTP Status | Description |
48+
|------|-------------|-------------|
49+
| `subscription/not-found` | 404 | Subscription not found |
50+
| `subscription/deleted` | 410 | Subscription has been soft-deleted |
51+
| `subscription/forbidden` | 403 | Caller does not own the subscription |
52+
| `subscription/invalid-state-transition` | 409 | Subscription status transition is not allowed |
53+
| `subscription/unknown-state` | 409 | Current subscription status is not a known value |
54+
| `subscription/invalid-status` | 422 | Target status value is not a known subscription status |
55+
| `subscription/billing-parse-error` | 500 | Subscription amount cannot be parsed |
56+
57+
#### Export Errors
58+
59+
| Code | HTTP Status | Description |
60+
|------|-------------|-------------|
61+
| `export/in-progress` | 409 | An export is already in progress for this tenant |
62+
63+
#### Swap Errors
64+
65+
| Code | HTTP Status | Description |
66+
|------|-------------|-------------|
67+
| `swap/insufficient-liquidity` | 422 | Swap cannot be fulfilled due to insufficient liquidity |
68+
69+
#### System Errors
70+
71+
| Code | HTTP Status | Description |
72+
|------|-------------|-------------|
73+
| `system/internal-error` | 500 | Unexpected server error |
74+
| `system/service-unavailable` | 503 | Service temporarily unavailable |
75+
76+
## API Response Envelope
77+
78+
All error responses use the `ErrorEnvelope` structure:
79+
80+
```json
81+
{
82+
"code": "subscription/invalid-state-transition",
83+
"message": "Human-readable error description",
84+
"trace_id": "550e8400-e29b-41d4-a716-446655440000",
85+
"details": {}
86+
}
87+
```
88+
89+
## Using Error Codes in Code
90+
91+
### Handler helpers
92+
93+
```go
94+
// Generic error response
95+
RespondWithError(c, http.StatusNotFound, errcode.CodeNotFound, "Resource not found")
96+
97+
// Error with additional details
98+
RespondWithErrorDetails(c, http.StatusBadRequest, errcode.CodeValidationFailed,
99+
"Invalid input", map[string]interface{}{
100+
"field": "email",
101+
"reason": "invalid format",
102+
})
103+
104+
// Specialized helpers
105+
RespondWithAuthError(c, "Missing authentication credentials")
106+
RespondWithValidationError(c, "Field validation failed", details)
107+
RespondWithNotFoundError(c, "subscription")
108+
RespondWithInternalError(c, "Database connection failed")
109+
```
110+
111+
### Service layer
112+
113+
Service errors are automatically mapped via the registry:
114+
115+
```go
116+
// Service returns sentinel errors; handler calls MapServiceErrorToResponse.
117+
statusCode, code, message := MapServiceErrorToResponse(err)
118+
RespondWithError(c, statusCode, code, message)
119+
```
120+
121+
Or directly use `errcode.Lookup` for custom error handling:
122+
123+
```go
124+
code := errcode.Lookup(err)
125+
if code == errcode.CodeSubscriptionInvalidTransition {
126+
// Handle invalid transition specifically
127+
}
128+
```
129+
130+
### Feature flag errors
131+
132+
Feature flag middleware returns its own structured response:
133+
134+
```json
135+
{
136+
"error": "feature_unavailable",
137+
"message": "This feature is currently unavailable",
138+
"feature_flag": "new_billing_flow"
139+
}
140+
```
141+
142+
## Adding New Error Codes
143+
144+
1. Add the `Code` constant in `internal/errcode/registry.go`
145+
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
148+
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.
150+
151+
## Testing
152+
153+
Run the error code tests:
154+
155+
```bash
156+
go test ./internal/errcode/... -v
157+
```
158+
159+
Test coverage must be >= 95%.
160+
161+
## Security Notes
162+
163+
- Error codes are stable identifiers; do not include sensitive data in code strings.
164+
- Error messages are redacted via `security.MaskPII` before being sent to clients.
165+
- The `details` field should never contain passwords, tokens, secrets, or PII.
166+
- Trace IDs are included in all error responses for correlation but contain no sensitive data.

internal/errcode/registry.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package errcode
2+
3+
import (
4+
"fmt"
5+
)
6+
7+
// Code is a stable, structured error identifier for API responses.
8+
type Code string
9+
10+
const (
11+
// Client errors
12+
CodeBadRequest Code = "client/bad-request"
13+
CodeValidationFailed Code = "client/validation-failed"
14+
CodeUnauthorized Code = "client/unauthorized"
15+
CodeForbidden Code = "client/forbidden"
16+
CodeNotFound Code = "client/not-found"
17+
CodeConflict Code = "client/conflict"
18+
CodeUnknownField Code = "client/unknown-field"
19+
20+
// Subscription errors
21+
CodeSubscriptionNotFound Code = "subscription/not-found"
22+
CodeSubscriptionDeleted Code = "subscription/deleted"
23+
CodeSubscriptionForbidden Code = "subscription/forbidden"
24+
CodeSubscriptionInvalidTransition Code = "subscription/invalid-state-transition"
25+
CodeSubscriptionUnknownState Code = "subscription/unknown-state"
26+
CodeSubscriptionInvalidStatus Code = "subscription/invalid-status"
27+
CodeSubscriptionBillingParse Code = "subscription/billing-parse-error"
28+
29+
// Export errors
30+
CodeExportInProgress Code = "export/in-progress"
31+
32+
// Swap errors
33+
CodeSwapInsufficientLiquidity Code = "swap/insufficient-liquidity"
34+
35+
// System errors
36+
CodeInternalError Code = "system/internal-error"
37+
CodeServiceUnavailable Code = "system/service-unavailable"
38+
)
39+
40+
// entry maps an error to a code via a matcher function.
41+
type entry struct {
42+
matcher func(error) bool
43+
code Code
44+
}
45+
46+
var registry = make(map[Code]struct{})
47+
var matchers []entry
48+
49+
// Register adds a matcher-to-code mapping. Panics if the code is
50+
// already registered or if the matcher is nil.
51+
func Register(matcher func(error) bool, code Code) {
52+
if matcher == nil {
53+
panic("errcode: nil matcher")
54+
}
55+
if _, exists := registry[code]; exists {
56+
panic(fmt.Sprintf("errcode: code %q is already registered", code))
57+
}
58+
registry[code] = struct{}{}
59+
matchers = append(matchers, entry{matcher: matcher, code: code})
60+
}
61+
62+
// Lookup returns the registered code for err. If no matcher matches,
63+
// CodeInternalError is returned.
64+
func Lookup(err error) Code {
65+
if err == nil {
66+
return ""
67+
}
68+
for _, e := range matchers {
69+
if e.matcher(err) {
70+
return e.code
71+
}
72+
}
73+
return CodeInternalError
74+
}
75+
76+
// MustLookup returns the registered code for err along with a found flag.
77+
func MustLookup(err error) (Code, bool) {
78+
if err == nil {
79+
return "", true
80+
}
81+
for _, e := range matchers {
82+
if e.matcher(err) {
83+
return e.code, true
84+
}
85+
}
86+
return "", false
87+
}
88+
89+
// AllCodes returns every registered code.
90+
func AllCodes() []Code {
91+
codes := make([]Code, 0, len(matchers))
92+
for _, e := range matchers {
93+
codes = append(codes, e.code)
94+
}
95+
return codes
96+
}
97+
98+
// IsRegistered reports whether the given code has been registered.
99+
func IsRegistered(code Code) bool {
100+
_, exists := registry[code]
101+
return exists
102+
}
103+
104+
// Count returns the number of registered error codes.
105+
func Count() int {
106+
return len(matchers)
107+
}

0 commit comments

Comments
 (0)