---
id: 982178b2b9c8c091685bdc7fbf942c43
author: snowmerak
title: より煩わしさを減らしてHTTPエラーを処理する + RFC7807
description: Go API のエラー処理が面倒だと感じていませんか? RFC7807 を使えば、より簡潔かつ明確にエラー処理を行えます。
language: ja
date: 2025-05-19T20:56:37.945722346Z
path: /blog/posts/handling-http-errors-more-conveniently-+-rfc7807-z862df968
category: api
---

## 概要

Go言語でHTTP APIを作成する際、最も煩わしいのはエラー処理です。代表的には次のようなコードがあります。

```go
func(w http.ResponseWriter, r *http.Request) {
    err := doSomething()
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        log.Printf("error occurred: %v", err)
        return
    }
    // ...
}
```

APIが少数であれば、このように記述しても特に不便はないでしょう。しかし、APIの数が増え、内部ロジックが複雑になるにつれて、次の三つが気になってくるようになります。

1. 適切なエラーコードの返却
2. 多数の結果ログの記述
3. 明確なエラーメッセージの送信

## 本論

### 適切なエラーコードの返却

もちろん1番目の、適切なエラーコードの返却は私個人の不満点ではあります。熟練した開発者であれば、適切なコードを探し出して毎回正しく記入するでしょうが、私を含めまだ未熟な開発者は、ロジックが複雑になり、呼び出し箇所が多くなるほど、適切なエラーコードを一貫して記述することに困難を覚えるかもしれません。これに対しては様々な方法があるでしょうし、最も代表的なものとしては、事前にAPIのロジックの流れを設計し、適切なエラーを返すようにコードを作成することがあるでしょう。~~そうしてください~~

しかし、これはIDE（あるいはLanguage Server）の助けを借りる人間の開発者にとって最適な方法には思えません。また、REST API自体がエラーコードに込められた意味を最大限活用するものである以上、別の方式を提案することができるでしょう。`HttpError`という、`error`インターフェースの実装体を新たに作り、`StatusCode`と`Message`を保存するようにします。そして次のようなヘルパー関数を提供します。

```go
err := httperror.BadRequest("wrong format")
```

`BadRequest`ヘルパー関数は、`StatusCode`を400、`Message`を引数で受け取った値に設定した`HttpError`を返すことになるでしょう。このほかにも当然、`NotImplement`、`ServiceUnavailable`、`Unauthorized`、`PaymentRequired`などのヘルパー関数を、自動補完機能で参照および追加できるはずです。これは用意された設計書を毎回確認するよりも速く、毎回数値でエラーコードを入力するよりも安定しているでしょう。~~`http.StatusCode`定数に全部あるですって？シッ~~

### 多数の結果ログの記述

エラーが発生した場合は、当然ログを残すことになります。APIが呼び出され、リクエストが成功したか失敗したかについてログを残す際、最初から予想されるすべての終了地点にログを記述すると、書くべきコードの量が多くなります。ハンドラー自体を一度ラップすることで、これを中央で管理できるようになります。

以下は`chi`ルーターをラップする例です。

```go
package chiwrap

import (
	"errors"
	"net/http"

	"github.com/go-chi/chi/v5"

	"github.com/gosuda/httpwrap/httperror"
)

type Router struct {
	router      chi.Router
	errCallback func(err error)
}

func NewRouter(errCallback func(err error)) *Router {
	if errCallback == nil {
		errCallback = func(err error) {}
	}
	return &Router{
		router:      chi.NewRouter(),
		errCallback: errCallback,
	}
}

type HandlerFunc func(writer http.ResponseWriter, request *http.Request) error

func (r *Router) Get(pattern string, handler HandlerFunc) {
	r.router.Get(pattern, func(writer http.ResponseWriter, request *http.Request) {
		if err := handler(writer, request); err != nil {
			he := &httperror.HttpError{}
			switch errors.As(err, &he) {
			case true:
				http.Error(writer, he.Message, he.Code)
			case false:
				http.Error(writer, err.Error(), http.StatusInternalServerError)
			}
			r.errCallback(err)
		}
	})
}
```

ルーター構造体は内部に`chi.Router`を保持しており、`chi.Router`の機能をそのまま利用できるように構成されています。`Get`メソッドを見ると、先ほど提案したヘルパー関数が返す`HttpError`構造体が返されたかどうかを確認し、適切に返却します。そして`error`の場合は、一括してエラーコールバック関数に渡します。このコールバックはコンストラクタを通じて受け取ります。

以下は、このパッケージを活用して作成したコードです。

```go
package main

import (
	"bytes"
	"context"
	"errors"
	"io"
	"log"
	"net/http"

	"github.com/gosuda/httpwrap/httperror"
	"github.com/gosuda/httpwrap/wrapper/chiwrap"
)

func main() {
    ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer cancel()

	r := chiwrap.NewRouter(func(err error) {
		log.Printf("Router log test: Error occured: %v", err)
	})
	r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
		name := request.URL.Query().Get("name")
		if name == "" {
			return httperror.BadRequest("name is required")
		}

		writer.Write([]byte("Hello " + name))
		return nil
	})

	svr := http.Server{
		Addr:    ":8080",
		Handler: r,
	}
	go func() {
		if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("Failed to start server: %v", err)
		}
	}()

    <-ctx.Done()
    svr.Shutdown(context.Background())
}
```

いかがでしょうか。単に`HttpError`だけをヘルパー関数で作成して返すなら、上位のスコープで適切なエラーコードとメッセージによる応答を返しつつ、サービスごとに適切なログを残せるよう、コールバックを登録して処理することができます。必要に応じてさらに拡張し、`RequestID`などを用いれば、詳細なロギングも可能になるでしょう。

### 明確なエラーメッセージの送信

これのための文書としてRFC7807があります。RFC7807は主に次のような要素を定義して使用します。

- `type`：エラーの種類を識別するURI。主にエラーを説明する文書です。
- `title`：どのようなエラーであるかについての一行の説明です。
- `status`：HTTPステータスコードと同一です。
- `detail`：当該エラーについての、人が読むことのできる詳細な説明です。
- `instance`：エラーが発生したURIです。例えば`GET /user/info`でエラーが発生した場合、`/user/info`がその値になるでしょう。
- `extensions`：JSONオブジェクトの形で構成される、エラーを説明するための付加的な要素です。
  - 例えば、`BadRequest`の場合にはユーザーの入力が含まれることがあります。
  - あるいは`TooManyRequest`の場合には、直近のリクエスト時点を含めることもできます。

これを簡単に使えるように、`HttpError`と同じ位置にある`httperror`パッケージに新しいファイルを作成し、`RFC7807Error`構造体を作成して、メソッドチェーンのパターンで生成できるようにします。

```go
func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
	return &RFC7807Error{
		Type:   "about:blank", // Default type as per RFC7807
		Title:  title,
		Status: status,
		Detail: detail,
	}
}

func BadRequestProblem(detail string, title ...string) *RFC7807Error {
	t := "Bad Request"
	if len(title) > 0 && title[0] != "" {
		t = title[0]
	}
	return NewRFC7807Error(http.StatusBadRequest, t, detail)
}

func (p *RFC7807Error) WithType(typeURI string) *RFC7807Error { ... }
func (p *RFC7807Error) WithInstance(instance string) *RFC7807Error { ... }
func (p *RFC7807Error) WithExtension(key string, value interface{}) *RFC7807Error { ... }
```

`Type`の`"about:blank"`はデフォルト値です。存在しないページを意味します。以下は不正なリクエストに対するエラー生成の例です。

```go
problem := httperror.BadRequestProblem("invalid user id format", "Bad User Input")

problem = problem.WithType("https://example.com/errors/validation")
         .WithInstance("/api/users/abc")
         .WithExtension("invalid_field", "user_id")
         .WithExtension("expected_format", "numeric")
```

簡単なメソッドチェーンにより、ユーザーに対する構造化されたエラーメッセージを生成できます。また、先ほど作成した中央化されたルーターを利用するために、次のメソッドを提供することができます。

```go
func (p *RFC7807Error) ToHttpError() *HttpError {
	jsonBytes, err := json.Marshal(p)
	if err != nil {
		// If marshaling fails, fall back to just using the detail
		return New(p.Status, p.Detail)
	}
	return New(p.Status, string(jsonBytes))
}
```

これをそのまま使用して、上の例を修正すると次のようになります。

```go
package main

import (
	"bytes"
	"context"
	"errors"
	"io"
	"log"
	"net/http"

	"github.com/gosuda/httpwrap/httperror"
	"github.com/gosuda/httpwrap/wrapper/chiwrap"
)

func main() {
    ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer cancel()

	r := chiwrap.NewRouter(func(err error) {
		log.Printf("Router log test: Error occured: %v", err)
	})
	r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
		name := request.URL.Query().Get("name")
		if name == "" {
			return httperror.BadRequestProblem("name is required", "Bad User Input").
                WithType("https://example.com/errors/validation").
                WithInstance("/api/echo").
                WithExtension("invalid_field", "name").
                WithExtension("expected_format", "string").
                WithExtension("actual_value", name).
                ToHttpError()
		}

		writer.Write([]byte("Hello " + name))
		return nil
	})

	svr := http.Server{
		Addr:    ":8080",
		Handler: r,
	}
	go func() {
		if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("Failed to start server: %v", err)
		}
	}()

    <-ctx.Done()
    svr.Shutdown(context.Background())
}
```

## 結論

このように中央化されたルーターを用いてエラーを処理すれば、毎回エラーコードを確認し、適切なエラーメッセージを作成する負担を減らすことができます。また、RFC7807を活用して構造化されたエラーメッセージを提供することで、クライアントがエラーを理解し処理するのを助けることができます。こうした方法により、Go言語で書かれたHTTP APIのエラー処理をより簡潔かつ一貫したものにすることができます。

本記事のコードは[gosuda/httpwrap](https://github.com/gosuda/httpwrap)リポジトリで確認いただけます。
