Error Handling
GantryCD uses typed domain errors plus handler-side HTTP mapping.
Domain Errors
Primary typed errors live in pkg/domain/errors.go:
ValidationErrorUnauthorizedErrorForbiddenErrorNotFoundErrorConflictErrorInternalErrorUnsupportedRuntimeProviderErrorServiceUnavailableError
Rules:
- services and repositories return domain errors
- handlers inspect them with
errors.As - do not add
IsXxxhelper functions
Sentinels
Some packages also expose sentinels for infrastructure concerns, for example storage-layer not-found cases. Translate those to domain errors at the appropriate boundary instead of leaking infrastructure semantics outward.
Returning Errors
Prefer typed returns such as:
return domain.NewValidationError("field", "reason")
return domain.NewForbiddenError("access denied")
return domain.NewConflictError("runner group still has active runners")
Wrap unexpected lower-level failures with context:
return fmt.Errorf("failed to list pull requests: %w", err)
Use InternalError only when the caller should see a domain-level internal failure rather than a plain wrapped infrastructure error.
Checking Errors
Use errors.As, not string matching:
var notFoundErr *domain.NotFoundError
if errors.As(err, ¬FoundErr) {
...
}
HTTP Mapping
Handlers own error-to-status mapping.
Typical mapping:
- validation →
400 - unauthenticated →
401 - forbidden →
403 - not found →
404 - conflict →
409 - unsupported runtime provider →
422 - unexpected/internal →
500 - service unavailable →
503
Transient PostgreSQL conflicts are mapped centrally too: lock_timeout
(SQLSTATE 55P03) and deadlock_detected (40P01) return 503, while
statement_timeout (57014) returns 504.
Runner-facing handlers still follow the same pattern, but a few JWT/path mismatches are rejected directly with 403 before service invocation.
Logging
Log with context, then return the error upward. Avoid double-wrapping or emitting multiple noisy logs for the same expected domain failure.