Skip to content

エラー処理

HTTPC はすべてのエラーを ClientError として統一的にカプセル化し、タイプ分類、リトライ可否の判定、豊富なコンテキスト情報を提供します。Go 標準ライブラリの errors.Is/errors.As と組み合わせることで、センチネルエラーを正確にマッチングしたり、分類ごとに柔軟に処理したりできます。

ErrorType 完全リファレンス

HTTPC はネットワーク層からアプリケーション層までのすべての失敗シナリオをカバーする 12 種類のエラータイプを定義しています:

ErrorTypeCode()意味典型シナリオリトライ可
ErrorTypeNetworkNETWORK_ERRORネットワーク層エラー接続拒否、接続リセット、パイプ破損原因による
ErrorTypeTimeoutTIMEOUTタイムアウトダイヤルタイムアウト、リクエストタイムアウト、context デッドラインはい
ErrorTypeContextCanceledCONTEXT_CANCELEDコンテキストキャンセルctx.Cancel() が呼び出されたいいえ
ErrorTypeDNSDNS_ERRORDNS 解決失敗ドメインが存在しない、DNS サーバー障害一時的/タイムアウト時は可
ErrorTypeTLSTLS_ERRORTLS ハンドシェイクエラープロトコルバージョン非対応、アルゴリズムネゴシエーション失敗いいえ
ErrorTypeCertificateCERTIFICATE_ERROR証明書検証失敗証明書期限切れ、署名無効、CA 非信頼いいえ
ErrorTypeTransportTRANSPORT_ERRORHTTP トランスポート層エラープロトコルエラー、トランスポート異常中断はい
ErrorTypeResponseReadRESPONSE_READ_ERRORレスポンスボディ読み取りエラー接続中断による EOF、読み取りタイムアウト原因による
ErrorTypeRetryExhaustedRETRY_EXHAUSTEDリトライ枯渇MaxRetries 上限に達しても失敗いいえ
ErrorTypeValidationVALIDATION_ERRORリクエスト検証失敗URL 形式が不正、HTTP ヘッダーに制御文字を含むいいえ
ErrorTypeHTTPHTTP_ERRORHTTP ステータスコードエラー4xx/5xx レスポンスステータスコードによる
ErrorTypeUnknownUNKNOWN_ERROR未分類エラーその他のマッチしない例外いいえ

リトライ可否判定の完全なルール

IsRetryable() の判定ロジックは表より細粒度です:ErrorTypeDNSnet.DNSError が一時的またはタイムアウトとマークされている場合のみリトライ可能;ErrorTypeNetworksyscall.ErrnoECONNREFUSED/ECONNRESET/EPIPE/ETIMEDOUT/ENETUNREACH/EHOSTUNREACH)とエラーメッセージパターンの検査で判定;ErrorTypeResponseRead は読み取り操作(read/readfrom)のネットワークエラー時のみリトライ。詳細は下記「リトライ可否判定」を参照。

ClientError フィールド詳細

ClientError 構造体はリクエスト失敗の完全なコンテキストを保持します:

フィールドタイプ用途
TypeErrorTypeエラー分類、switch 分岐処理に使用
Messagestring人間が読めるエラー記述
Causeerror基底の元エラー、errors.Unwrap チェーンをサポート
URLstringリクエストの URL(マスク済み、下記参照)
MethodstringHTTP メソッド(GET/POST/...)
Attemptsint試行回数(初回含む)、リトライ枯渇時は > 1
StatusCodeintHTTP ステータスコード(ErrorTypeHTTP のみ値あり)
Hoststringターゲットホスト名(サーキットブレーカーなどに使用)

エラータイプ判定

go
package main

import (
    "errors"
    "fmt"
    "log"

    "github.com/cybergodev/httpc"
)

func main() {
    client, err := httpc.NewDefault()
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

    result, err := client.Get("https://api.example.com/data")
    if err != nil {
        var clientErr *httpc.ClientError
        if errors.As(err, &clientErr) {
            switch clientErr.Type {
            case httpc.ErrorTypeTimeout:
                log.Printf("リクエストタイムアウト(%d 回試行済み): %v", clientErr.Attempts, err)
            case httpc.ErrorTypeNetwork:
                log.Printf("ネットワークエラー: %v", err)
            case httpc.ErrorTypeDNS:
                log.Printf("DNS 解決失敗: %v", err)
            case httpc.ErrorTypeTLS:
                log.Printf("TLS ハンドシェイク失敗: %v", err)
            case httpc.ErrorTypeCertificate:
                log.Printf("証明書検証失敗: %v", err)
            case httpc.ErrorTypeRetryExhausted:
                log.Printf("%d 回リトライ後も失敗: %v", clientErr.Attempts, err)
            case httpc.ErrorTypeValidation:
                log.Printf("リクエスト検証失敗: %v", err)
            case httpc.ErrorTypeContextCanceled:
                log.Printf("リクエストがキャンセルされました: %v", err)
            default:
                log.Printf("その他のエラー [%s]: %v", clientErr.Code(), err)
            }
        }
        return
    }
    fmt.Printf("成功: %d\n", result.StatusCode())
}

リトライ可否判定

IsRetryable() はエラータイプと基底原因を総合的に考慮し、リトライすべきかどうかを返します:

go
package main

import (
    "errors"
    "fmt"
    "log"

    "github.com/cybergodev/httpc"
)

func main() {
    client, err := httpc.NewDefault()
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

    _, err = client.Get("https://api.example.com/data")
    if err != nil {
        var clientErr *httpc.ClientError
        if errors.As(err, &clientErr) {
            if clientErr.IsRetryable() {
                fmt.Println("リトライ可能なエラー、上位ロジックで後ほどリトライ可能")
            } else {
                fmt.Printf("リトライ不可のエラー [%s]、人手での介入が必要\n", clientErr.Code())
            }
        }
    }
}

IsRetryable と自動リトライの違い

IsRetryable() は「このエラーがリトライに値するか」を判定し、HTTPC 内部のリトライエンジンでも使用されます。Retry.MaxRetries で自動リトライを設定済みの場合、エラー処理コードに到達した時点でネットワーク/タイムアウト系エラーであれば、リトライはすでに枯渇しています。IsRetryable() は主に上位層(サーキットブレーカー、タスクキューなど)での意思決定に使用されます。

センチネルエラー完全リファレンス

HTTPC は以下のセンチネルエラー変数を定義しており、errors.Is で正確にマッチングできます:

センチネル変数トリガー条件推奨処理
ErrClientClosedclient.Close() 後に当該クライアントを使用新規 Client を初期化、またはライフサイクル管理を修正
ErrNilConfigNew() に渡した Config ポインタが nilDefaultConfig() でデフォルト値を取得
ErrInvalidHeaderHTTP ヘッダー検証失敗(制御文字を含む、または形式が不正)Header 値を修正してからリトライ
ErrInvalidTimeoutタイムアウト値が負の数または 30 分の上限を超過正当な範囲 [0, 30min] に調整
ErrInvalidRetryリトライ設定が不正(MaxRetries が 0-10 の範囲外、BackoffFactor が 1.0-10.0 の範囲外)リトライパラメータを修正
ErrInvalidConnection接続設定が不正(コネクションプールサイズが範囲外、プロキシ URL 形式エラー)接続パラメータを修正
ErrInvalidSecurityセキュリティ設定が不正(レスポンスボディサイズ制限が範囲外)セキュリティパラメータを修正
ErrInvalidMiddlewareミドルウェア設定が不正(リダイレクト回数が 50 超過、UserAgent が長すぎるか制御文字を含む)ミドルウェアパラメータを修正
ErrEmptyFilePathダウンロード時にファイルパスが未指定DownloadConfig.FilePath を設定
ErrFileExistsターゲットファイルが既存で Overwrite=falseResumeDownload=false上書きまたはレジュームを設定、またはパスを変更
ErrResponseBodyEmptyレスポンスボディが空の状態で Unmarshal() などの解析メソッドを呼び出しRawBody を先にチェックしてから解析
ErrResponseBodyTooLargeレスポンスボディが MaxResponseBodySize 制限を超過制限を増やすか、インターフェースを変更してページネーションで取得

設定系エラー vs 実行時エラー

ErrInvalid* シリーズ(ErrInvalidHeader/ErrInvalidTimeout/ErrInvalidRetry/ErrInvalidConnection/ErrInvalidSecurity/ErrInvalidMiddleware)は設定検証エラーで、New() 呼び出し時に返却され、リクエストのホットパスには現れません。実行時エラーは ClientError で分類処理されます。

go
package main

import (
    "errors"
    "fmt"
    "log"

    "github.com/cybergodev/httpc"
)

func main() {
    client, err := httpc.NewDefault()
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

    result, err := client.Get("https://api.example.com/data")

    switch {
    case errors.Is(err, httpc.ErrClientClosed):
        fmt.Println("クライアントがクローズ済み、再作成が必要")
    case errors.Is(err, httpc.ErrResponseBodyTooLarge):
        fmt.Println("レスポンスボディが大きすぎます、MaxResponseBodySize の増大を検討")
    case errors.Is(err, httpc.ErrResponseBodyEmpty):
        fmt.Println("レスポンスボディが空、解析メソッド呼び出し前に RawBody をチェック")
    case errors.Is(err, httpc.ErrInvalidHeader):
        fmt.Println("リクエストヘッダーが無効、修正後にリトライ")
    }

    if result != nil {
        fmt.Printf("ステータスコード: %d\n", result.StatusCode())
    }
}

URL の自動マスク

ClientError.Error() は URL 内の機密情報を自動的に削除します。ユーザー名とパスワードを含む URL(例:https://user:pass@host/path)は https://***:***@host/path にマスクされ、ログやエラーメッセージに認証情報が漏洩しません:

go
package main

import (
    "fmt"
    "log"

    "github.com/cybergodev/httpc"
)

func main() {
    client, err := httpc.NewDefault()
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

    // URL に認証情報を含む
    result, err := client.Get("https://admin:[email protected]/data")
    if err != nil {
        // エラーメッセージ内の認証情報は自動的にマスク:
        // "GET https://***:***@api.example.com/data: network error occurred"
        fmt.Println(err)
    }
    if result != nil {
        fmt.Println(result.StatusCode())
    }
}

マスクの適用範囲

マスクは user:pass@host 形式の認証情報を削除するだけでなく、機密クエリパラメータ(tokenkeysecret など)も処理します。認証情報や機密パラメータを含まない URL は高速パスを通過して解析をスキップし、不要な url.Parse のオーバーヘッドを回避します。

panic リカバリのセーフティネット

HTTPC は Request()Download() メソッドに panic セーフティネットを内蔵しています。エンジン、トランスポート層、TLS ライブラリ、ミドルウェアからの予期しない panic はすべて捕捉され、呼び出し元プロセスをクラッシュさせる代わりに ClientError に変換されます:

go
// client.go 内部実装(概念説明)
func (c *clientImpl) Request(ctx context.Context, method, url string, ...) (*Result, error) {
    defer func() {
        if r := recover(); r != nil {
            result = nil
            err = panicToError(r) // ClientError に変換
        }
    }()
    // ... 通常のリクエストロジック
}

セーフティネットはミドルウェアリカバリの代わりにならない

内蔵セーフティネットは最後の防衛線で、panic をクラッシュではなくエラーに変換します。しかしミドルウェア内で panic が発生する可能性がある場合、RecoveryMiddleware() の追加使用を推奨します——これはミドルウェアチェーン内でより早く panic を捕捉し、より完全なログコンテキストを提供できます:

go
cfg := httpc.DefaultConfig()
cfg.Middleware.Middlewares = []httpc.MiddlewareFunc{
    httpc.RecoveryMiddleware(),       // ミドルウェア層の panic リカバリ
    httpc.LoggingMiddleware(nil),     // ログ
    httpc.MetricsMiddleware(nil),     // メトリクス
}

自動リトライとエラーの関係

HTTPC のリトライエンジンはリトライ可能なエラーを内部で自動処理します。どのエラーが自動リトライされるかを理解することで、アプリケーション層での重複リトライを避けられます:

自動リトライされるエラー

条件リトライ説明
ネットワークエラー(接続拒否、リセット、EOF)はいisRetryableNetworkMessage がマッチ
ダイヤル/リクエストタイムアウトはいErrorTypeTimeout
一時的/タイムアウト系 DNS 失敗はいdnsErr.IsTemporary || dnsErr.IsTimeout
レスポンスボディ読み取りのネットワークエラーはい読み取り操作の net.OpError
リトライ可能な HTTP ステータスコードはい408/429/500/502/503/504
ProxyRotateOnStatus 指定のステータスコードはい例:403 でプロキシローテーション

リトライされないエラー

条件リトライ説明
context.Canceledいいえ高速パスで即時リターン
context.DeadlineExceededいいえ高速パスで即時リターン
TLS ハンドシェイク失敗いいえErrorTypeTLS はリトライ不可
証明書検証失敗いいえErrorTypeCertificate はリトライ不可
設定検証エラーいいえErrorTypeValidation はリトライ不可
その他の 4xx クライアントエラーいいえ例:400/401/403/404

context キャンセルは高速パス

isRetryableError は判定前に context.Canceledcontext.DeadlineExceeded を先にチェックします——マッチすれば即座に false を返し、完全なエラー分類をスキップします。これにより context が既にキャンセルされた状態での無駄なリトライ判定を回避します。

go
package main

import (
    "context"
    "errors"
    "fmt"
    "log"
    "time"

    "github.com/cybergodev/httpc"
)

func main() {
    client, err := httpc.NewDefault()
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    result, err := client.Request(ctx, "GET", "https://api.example.com/slow")
    if err != nil {
        var clientErr *httpc.ClientError
        if errors.As(err, &clientErr) {
            if clientErr.Type == httpc.ErrorTypeContextCanceled {
                // context タイムアウトまたは手動キャンセル、自動リトライされない
                fmt.Println("リクエストがキャンセルされました(タイムアウトまたは手動キャンセル)、リトライなし")
            } else if clientErr.Type == httpc.ErrorTypeTimeout {
                fmt.Println("リクエストタイムアウト、自動リトライ済みも失敗")
            }
        }
        return
    }
    fmt.Println(result.StatusCode())
}

エラー処理のベストプラクティス

1. クライアントエラーとサーバーエラーの区別

go
package main

import (
    "fmt"
    "log"

    "github.com/cybergodev/httpc"
)

func main() {
    client, err := httpc.NewDefault()
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

    result, err := client.Get("https://api.example.com/data")
    if err != nil {
        // ネットワーク層エラー——接続、TLS、DNS などの問題
        log.Printf("ネットワーク層エラー: %v", err)
        return
    }

    // HTTP 層エラー——レスポンスは受信したがステータスコードが 2xx でない
    if result.IsClientError() {
        // 4xx:クライアントのリクエストに誤り(パラメータエラー、権限不足など)
        log.Printf("クライアントエラー: %d", result.StatusCode())
    } else if result.IsServerError() {
        // 5xx:サーバー障害(リトライ枯渇、上流が利用不可)
        log.Printf("サーバーエラー: %d", result.StatusCode())
    } else {
        fmt.Printf("成功: %d\n", result.StatusCode())
    }
}

2. サーキットブレーカーパターン

あるサービスが継続的に失敗する場合、サーキットブレーカーが一時的にリクエストを停止し、カスケード障害とリソースの浪費を回避します:

go
type CircuitBreaker struct {
    mu           sync.Mutex
    failures     int
    threshold    int           // 連続失敗の閾値
    cooldown     time.Duration // 熔断冷却時間
    trippedAt    time.Time
}

func (cb *CircuitBreaker) Allow() bool {
    cb.mu.Lock()
    defer cb.mu.Unlock()
    if cb.failures >= cb.threshold {
        if time.Since(cb.trippedAt) < cb.cooldown {
            return false // 熔断中、リクエスト拒否
        }
        cb.failures = 0 // 冷却期間経過、リセット
    }
    return true
}

func (cb *CircuitBreaker) Record(err error) {
    cb.mu.Lock()
    defer cb.mu.Unlock()
    if err != nil {
        cb.failures++
        if cb.failures >= cb.threshold {
            cb.trippedAt = time.Now()
        }
    } else {
        cb.failures = 0 // 成功時はリセット
    }
}

// 使用時に IsRetryable で判定を組み合わせる
func requestWithBreaker(client httpc.Client, cb *CircuitBreaker, url string) error {
    if !cb.Allow() {
        return fmt.Errorf("circuit breaker open")
    }
    result, err := client.Get(url)
    cb.Record(err)
    if err != nil {
        var clientErr *httpc.ClientError
        if errors.As(err, &clientErr) && !clientErr.IsRetryable() {
            cb.Record(nil) // リトライ不可エラーはサーバー障害とみなさない
        }
        return err
    }
    _ = result
    return nil
}

3. フォールバック

主サービスが利用不可時にキャッシュやデフォルト値にフォールバックします:

go
package main

import (
    "errors"
    "log"

    "github.com/cybergodev/httpc"
)

func fetchWithFallback(client httpc.Client, url string, fallback []byte) []byte {
    result, err := client.Get(url)
    if err != nil {
        var clientErr *httpc.ClientError
        if errors.As(err, &clientErr) {
            switch clientErr.Type {
            case httpc.ErrorTypeTimeout, httpc.ErrorTypeRetryExhausted:
                log.Printf("主サービスが利用不可、フォールバックデータを使用: %v", err)
                return fallback
            case httpc.ErrorTypeValidation:
                // 検証エラーはローカルバグ、フォールバックすべきでない
                log.Fatalf("リクエスト設定エラー: %v", err)
            }
        }
        log.Printf("不明なエラー、フォールバックデータを使用: %v", err)
        return fallback
    }
    return result.RawBody()
}

4. ミドルウェアによる統一処理

go
package main

import (
    "log"
    "time"

    "github.com/cybergodev/httpc"
)

func main() {
    cfg := httpc.DefaultConfig()
    cfg.Middleware.Middlewares = []httpc.MiddlewareFunc{
        httpc.RecoveryMiddleware(),
        httpc.LoggingMiddleware(&httpc.LoggingConfig{
            LogFunc: func(format string, args ...any) {
                log.Printf("[HTTP] "+format, args...)
            },
        }),
        httpc.MetricsMiddleware(&httpc.MetricsConfig{
            OnMetrics: func(method, url string, statusCode int, duration time.Duration, err error) {
                if err != nil {
                    log.Printf("[METRICS] %s %s 失敗: %v (所要 %v)", method, url, err, duration)
                } else {
                    log.Printf("[METRICS] %s %s -> %d (所要 %v)", method, url, statusCode, duration)
                }
            },
        }),
    }

    client, err := httpc.New(cfg)
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

    result, err := client.Get("https://api.example.com/data")
    if err != nil {
        log.Fatal(err)
    }
    log.Printf("ステータスコード: %d", result.StatusCode())
}

5. タイムアウトの階層化

HTTPC は粗いものから細かいものまで複数レベルのタイムアウト制御を提供します:

go
// 第 1 層:クライアントデフォルトタイムアウト(全リクエストのグローバル上限)
cfg := httpc.DefaultConfig()
cfg.Timeouts.Request = 30 * time.Second

// 第 2 層:ミドルウェア強制タイムアウト(デフォルトを上書き)
timeoutMW := httpc.TimeoutMiddleware(&httpc.TimeoutMiddlewareConfig{
    Duration: 30 * time.Second,
})

// 第 3 層:単一リクエスト上書き(WithTimeout はミドルウェアとデフォルトを上書き)
result, err := client.Get(url, httpc.WithTimeout(10*time.Second))

// 第 4 層:context タイムアウト(最も精密、クリティカルパスに推奨)
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
result, err := client.Request(ctx, "GET", url)

ResponseHeader タイムアウトと WithTimeout の相互作用

Timeouts.ResponseHeader = 0(デフォルト)の場合、トランスポート層はレスポンスヘッダータイムアウトを強制せず、WithTimeout が完全な制御権を持ちます。しかし正の値(SecureConfig() の 10s など)に設定すると、トランスポート層ですべてのリクエストに強制適用され、WithTimeout で延長できません——これは slowloris 攻撃防御のための設計です。AI API などの長いレスポンスシナリオでは ResponseHeader = 0 を維持してください。

次のステップ