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.Errno(ECONNREFUSED/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=false, ResumeDownload=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 형식의 자격 증명뿐 아니라 민감한 쿼리 매개변수(token, key, secret 등)도 처리합니다. 자격 증명이나 민감 매개변수가 없는 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을 유지하세요.

다음 단계