httperrパッケージ
標準化されたHTTPエラー処理。
概要
httperr パッケージは、コントローラで HTTP ステータスコードを明示的に表現しながら、HTTP レイヤに直接依存しないように設計されたエラータイプを提供します。 Controllerはビジネスロジックの観点からエラーを返し、ErrorReturnHandlerはそれを適切なHTTP応答に変換します。
なぜhttperrなのか?
###問題:コントローラはHTTPを知る必要がありますか?
一般的な方法では、ControllerはHTTP応答を直接作成します。
// ❌ HTTP層に直接依存
func (c *UserController) GetUser(ctx echo.Context) error {
user, err := c.repo.FindByID(id)
if err != nil {
return ctx.JSON(404, map[string]string{"error": "not found"})
}
return ctx.JSON(200, user)
}このアプローチの問題:
- ControllerがHTTPフレームワーク(Echo)に依存
- ステータスコードと応答形式がビジネスロジックと混在する
- テストするのが難しい
解決: 意味タイプによるエラー表現
// ✓ Spine 方式: HTTPを知らなくても意味は明確
func (c *UserController) GetUser(userId path.Int) (User, error) {
user, err := c.repo.FindByID(userId.Value)
if err != nil {
return User{}, httperr.NotFound("ユーザーが見つかりません")
}
return user, nil
}コントローラーは:
- HTTPフレームワークを知らない
- ステータスコードの意味のみ表現(NotFound、BadRequestなど)
- 実際のHTTP変換は
ErrorReturnHandlerが担当
HTTPError構造体
// pkg/httperr/types.go
type HTTPError struct {
Status int // HTTPステータスコード
Message string // エラーメッセージ
Cause error // 原因エラー(任意)
}
// error インターフェース 実装
func (e *HTTPError) Error() string {
return e.Message
}フィールドの説明
| フィールド | タイプ | 説明 |
|---|---|---|
Status | int | HTTPステータスコード(400、401、404、500など) |
Message | string | クライアントに渡すエラーメッセージ |
Cause | error | 原因となるサブエラー(デバッグ/ロギング用) |
errorインターフェース
HTTPErrorはGoのerrorインターフェースを実装します。したがって、通常のエラーのように返して処理できます。
func (c *UserController) GetUser(userId path.Int) (User, error) {
// httperr.NotFound()は error 型を 返却
return User{}, httperr.NotFound("ユーザーが見つかりません")
}ヘルパー関数
よく使用するHTTPステータスコードのヘルパー関数を提供します。
BadRequest
func BadRequest(msg string) error {
return &HTTPError{Status: 400, Message: msg}
}クライアント要求が間違っているときに使用します。
if userId.Value <= 0 {
return User{}, httperr.BadRequest("無効なユーザーIDです")
}Unauthorized
func Unauthorized(msg string) error {
return &HTTPError{Status: 401, Message: msg}
}認証が必要または失敗したときに使用します。
if !c.auth.IsValid(token) {
return User{}, httperr.Unauthorized("認証が必要です")
}NotFound
func NotFound(msg string) error {
return &HTTPError{Status: 404, Message: msg}
}リソースが見つからない場合に使用します。
user, err := c.repo.FindByID(id)
if err != nil {
return User{}, httperr.NotFound("ユーザーが見つかりません")
}InternalServerError
func InternalServerError(msg string) error {
return &HTTPError{Status: 500, Message: msg}
}サーバー内部エラーを明示的に表現するときに使用します。
result, err := c.externalService.Call()
if err != nil {
return Result{}, httperr.InternalServerError("外部サービス 呼び出しに 失敗しました")
}ErrorReturnHandler
Controllerによって返されたエラーをHTTP応答に変換します。
// internal/handler/error_return_handler.go
type ErrorReturnHandler struct{}
func (h *ErrorReturnHandler) Supports(returnType reflect.Type) bool {
errorType := reflect.TypeFor[error]()
return returnType.Implements(errorType)
}
func (h *ErrorReturnHandler) Handle(value any, ctx core.ExecutionContext) error {
rwAny, ok := ctx.Get("spine.response_writer")
if !ok {
return fmt.Errorf("ExecutionContext 内で ResponseWriterを 見つかりません.")
}
rw, ok := rwAny.(core.ResponseWriter)
if !ok {
return fmt.Errorf("ResponseWriter 型が正しくありません.")
}
err, ok := value.(error)
if !ok {
return fmt.Errorf("ErrorReturnHandlerは error 型のみ処理できます: %T", value)
}
status := 500
message := err.Error()
// HTTPErrorなら ステータスコードを抽出
var httpErr *httperr.HTTPError
if errors.As(err, &httpErr) {
status = httpErr.Status
message = httpErr.Message
}
return rw.WriteJSON(status, map[string]any{
"message": message,
})
}動作原理
- Controllerが
errorを返す - Pipelineが
ErrorReturnHandler.Supports()を呼び出す→true ErrorReturnHandler.Handle()の実行errors.As()でHTTPErrorかどうかを確認するHTTPErrorの場合、指定されたステータスコードの使用、または500
HTTPError vs 汎用エラー
// HTTPError → 指定された ステータスコード
return httperr.NotFound("...") // → 404
// 通常のerror → 500 Internal Server Error
return errors.New("something went wrong") // → 500使用例
デフォルトの使用
func (c *UserController) GetUser(userId path.Int) (User, error) {
if userId.Value <= 0 {
return User{}, httperr.BadRequest("無効なユーザーIDです")
}
user, err := c.repo.FindByID(userId.Value)
if err != nil {
return User{}, httperr.NotFound("ユーザーが見つかりません")
}
return user, nil
}# 不正な ID
GET /users/-1
→ 400 {"message": "無効なユーザーIDです"}
# 存在しないは ユーザー
GET /users/999
→ 404 {"message": "ユーザーが見つかりません"}
# 正常
GET /users/123
→ 200 {"id": 123, "name": "john"}認証処理
func (c *OrderController) GetOrder(orderId path.Int) (Order, error) {
order, err := c.repo.FindByID(orderId.Value)
if err != nil {
return Order{}, httperr.NotFound("注文を 見つかりません")
}
if !c.auth.CanAccess(order.UserID) {
return Order{}, httperr.Unauthorized("アクセス 権限が ありません")
}
return order, nil
}ビジネスルールの検証
func (c *PaymentController) Process(req PaymentRequest) (Receipt, error) {
if req.Amount <= 0 {
return Receipt{}, httperr.BadRequest("決済金額は0より大きくする必要があります")
}
if req.Amount > 10000000 {
return Receipt{}, httperr.BadRequest("1回の決済上限を超えました")
}
balance, err := c.wallet.GetBalance(req.UserID)
if err != nil {
return Receipt{}, httperr.NotFound("ウォレットを 見つかりません")
}
if balance < req.Amount {
return Receipt{}, httperr.BadRequest("残高が不足しています")
}
return c.processPayment(req)
}エラーのみを返す場合
成功時に戻り値がない場合にも使用できます。
func (c *UserController) DeleteUser(userId path.Int) error {
exists, err := c.repo.Exists(userId.Value)
if err != nil || !exists {
return httperr.NotFound("ユーザーが見つかりません")
}
if err := c.repo.Delete(userId.Value); err != nil {
return httperr.BadRequest("削除でき ありません")
}
return nil // 成功 時 nil 返却
}拡張する
新しいステータスコードを追加
現在提供されているヘルパーは400、401、404、500です。必要に応じて拡張できます。
// 直接 HTTPError 生成
func Forbidden(msg string) error {
return &httperr.HTTPError{Status: 403, Message: msg}
}
func Conflict(msg string) error {
return &httperr.HTTPError{Status: 409, Message: msg}
}
func UnprocessableEntity(msg string) error {
return &httperr.HTTPError{Status: 422, Message: msg}
}
func TooManyRequests(msg string) error {
return &httperr.HTTPError{Status: 429, Message: msg}
}Causeの活用
原因 エラーを含むデバッグに活用できます。
func WithCause(status int, msg string, cause error) error {
return &httperr.HTTPError{
Status: status,
Message: msg,
Cause: cause,
}
}
// 使用
user, err := c.repo.FindByID(id)
if err != nil {
return User{}, WithCause(404, "ユーザーが見つかりません", err)
}InterceptorのAfterCompletionからCauseをログに記録できます。
func (i *LoggingInterceptor) AfterCompletion(ctx core.ExecutionContext, meta core.HandlerMeta, err error) {
if err != nil {
var httpErr *httperr.HTTPError
if errors.As(err, &httpErr) && httpErr.Cause != nil {
log.Printf("[ERR] %s %s: %s (cause: %v)",
ctx.Method(), ctx.Path(), httpErr.Message, httpErr.Cause)
}
}
}Pipelineでのエラーフロー
2段階のエラー処理
Spine Pipelineはエラーを2つのステップで処理します。
handleReturn(): Controller 戻り値のうち error をErrorReturnHandlerとして扱うhandleExecutionError(): Pipeline 実行中に発生したエラーを最終安全網として処理
Controller
│
│ return (User{}, httperr.NotFound("..."))
▼
┌─────────────────────────────────────┐
│ handleReturn() │
│ │
│ results = [User{}, *HTTPError] │
│ │
│ 1. isNilResult() チェック │
│ 2. error 型 優先 検索 │
│ 3. ErrorReturnHandler.Handle() │
│ → rw.WriteJSON(404, {...}) │
└─────────────────────────────────────┘handleReturn - error 優先処理
Pipeline.handleReturn()はエラータイプを優先します。 isNilResult()でnilかどうかを包括的にチェックします。
// internal/pipeline/pipeline.go
func (p *Pipeline) handleReturn(ctx core.ExecutionContext, results []any) error {
// errorがあればerrorだけ処理して終了
for _, result := range results {
if isNilResult(result) {
continue
}
if _, isErr := result.(error); isErr {
resultType := reflect.TypeOf(result)
for _, h := range p.returnHandlers {
if h.Supports(resultType) {
if err := h.Handle(result, ctx); err != nil {
return err
}
// error 返却値は ここで 消費して終了する.
return nil
}
}
return fmt.Errorf(
"error 返却値を 処理する ReturnValueHandlerが ありません. (%s)",
resultType.String(),
)
}
}
// errorが なければ 最初の non-nil 値 処理
for _, result := range results {
if isNilResult(result) {
continue
}
resultType := reflect.TypeOf(result)
// ...ReturnValueHandlerに 処理
}
return nil
}
isNilResult:nilリテラルだけでなく、タイプ情報はあるが値が nil の場合(interfaceに nil が含まれた場合など)まで包括的に処理します。
したがって、(User, error)を返すとき:
errorがnon-nil→errorのみ処理、Userを無視errorがnil→User処理
handleExecutionError - 最終セーフティネット
パイプラインの実行中にエラーが発生すると、handleExecutionErrorは最終安全ネットワークとして機能します。すでに応答がコミットされている場合は、二重応答を防ぎます。
// internal/pipeline/pipeline.go
func (p *Pipeline) handleExecutionError(ctx core.ExecutionContext, err error) {
rwAny, ok := ctx.Get("spine.response_writer")
if !ok {
return
}
rw, ok := rwAny.(core.ResponseWriter)
if !ok {
return
}
// すでに レスポンスがコミットされた 場合 二重 レスポンス 防止
if rw.IsCommitted() {
return
}
var httpErr *httperr.HTTPError
if errors.As(err, &httpErr) {
rw.WriteJSON(httpErr.Status, map[string]any{
"message": httpErr.Message,
})
return
}
rw.WriteJSON(500, map[string]any{
"message": "Internal server error",
})
}エラー処理全体の流れ
Pipeline.Execute()
│
├── handleReturn() で error 処理 成功
│ └── ErrorReturnHandlerが レスポンス 作成 → 終了
│
├── handleReturn() 自体が error 返却
│ └── handleExecutionError() → セーフティネット レスポンス
│
├── Router/Resolver/Invoker で error 発生
│ └── handleExecutionError() → セーフティネット レスポンス
│
└── handleExecutionError() 条件分岐
├── rw.IsCommitted() → スキップ (二重 レスポンス 防止)
├── HTTPError → 指定された ステータスコードに レスポンス
└── 通常のerror → 500 "Internal server error"フレームワーク内部の使用
httperrは、Controllerだけでなくフレームワーク内でも使用されます。
Routerでの使用
一致するハンドラがない場合はhttperr.NotFoundを返します。
// internal/router/router.go
func (r *DefaultRouter) Route(ctx core.ExecutionContext) (core.HandlerMeta, error) {
for _, route := range r.routes {
// ...マッチングを試行
}
return core.HandlerMeta{}, httperr.NotFound("ハンドラーが ありません.")
}このエラーはhandleExecutionErrorによって404応答に変換されます。
設計原則
1. ControllerはHTTPを知らない
// ✓ 意味だけ 表現
return httperr.NotFound("ユーザーが見つかりません")
// ❌ HTTP 直接 操作
return ctx.JSON(404, ...)2.ステータスコードは意味タイプ
// ✓ 関数名は 意味を 表現
httperr.BadRequest(...)
httperr.Unauthorized(...)
httperr.NotFound(...)
httperr.InternalServerError(...)
// ❌ 数値 コード 直接 使用
return &HTTPError{Status: 404, ...} // 可能ですがだけ 推奨し しない3. エラーも戻り値
Goの慣例通り、エラーを戻り値として処理します。例外を投げません。
// ✓ 明示的 返却
func GetUser(id path.Int) (User, error) {
if ... {
return User{}, httperr.NotFound(...)
}
return user, nil
}
// ❌ panic (Spineは は 方式を 使用し しない)
func GetUser(id path.Int) User {
if ... {
panic(httperr.NotFound(...))
}
return user
}4. 二重応答の防止
PipelineのhandleExecutionErrorはrw.IsCommitted()をチェックし、すでに応答が作成されている場合の追加応答を防ぎます。これは、Interceptorが直接応答を作成した後にErrAbortPipelineを返すパターンとも安全に共存します。
まとめ
| 機能 | ステータスコード | 用途 |
|---|---|---|
BadRequest(msg) | 400 | 間違った要求 |
Unauthorized(msg) | 401 | 認証が必要/失敗 |
NotFound(msg) | 404 | リソースが見つかりません |
InternalServerError(msg) | 500 | サーバー内部エラー |
|コンポーネント役割| |----------|------| | HTTPError |ステータスコードとメッセージを含むエラータイプ| | ErrorReturnHandler | Controller戻りエラー→HTTP応答変換| | handleExecutionError |パイプラインエラー最終安全ネットワーク(二重応答防止)| | isNilResult |包括的なnilチェック(インタフェースnilを含む)|
核心哲学: Controllerは「404を返す」ではなく「見つからない」を表現します。 HTTPステータスコードへの変換はパイプラインが担当します。これがSpineの関心の分離原則です。
