Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,221 @@ import Alamofire
import OSLog

struct DefaultAPIProvider: APIProvider {
func request<T: APIRequest>(_ request: T, completion: @escaping (Result<T.Response, Error>) -> Void) {
AF.request(request).responseDecodable(of: T.Response.self) { data in
switch data.result {
case .success(let response):
completion(.success(response))

// MARK: - Configuration
struct RetryConfiguration {
/// 최대 재시도 횟수
let maxRetries: Int
/// 기본 지연 시간 (초)
let baseDelay: TimeInterval
/// 최대 지연 시간 (초)
let maxDelay: TimeInterval
/// 네트워크 복구 시 자동 재시도 활성화
let enableAutoRetryOnReconnect: Bool

static let `default` = RetryConfiguration(
maxRetries: 3,
baseDelay: 1.0,
maxDelay: 30.0,
enableAutoRetryOnReconnect: true
)
}

private let networkMonitor: NetworkMonitoring
private let configuration: RetryConfiguration

// MARK: - Initialization
init(
networkMonitor: NetworkMonitoring = NetworkMonitor.shared,
configuration: RetryConfiguration = .default
) {
self.networkMonitor = networkMonitor
self.configuration = configuration
}

// MARK: - Backoff Calculation

/// 지수 백오프 지연 시간 계산 (RetryConfiguration 사용)
private func calculateBackoffDelay(retryCount: Int) -> TimeInterval {
let exponentialDelay = configuration.baseDelay * pow(2.0, Double(retryCount))
// 무작위 지터(jitter) 추가로 thundering herd 문제 방지
let jitter = Double.random(in: 0...0.5) * exponentialDelay
return min(exponentialDelay + jitter, configuration.maxDelay)
}

// MARK: - APIProvider
func request<T: APIRequest>(
_ request: T,
completion: @escaping (Result<T.Response, Error>) -> Void
) {
requestWithRetry(request, retryCount: 0, completion: completion)
}

// MARK: - Private Methods

/// 재시도 로직이 포함된 요청 메서드
private func requestWithRetry<T: APIRequest>(
_ request: T,
retryCount: Int,
completion: @escaping (Result<T.Response, Error>) -> Void
) {
// 네트워크 연결 확인
guard networkMonitor.isConnected else {
handleNetworkUnavailable(request, completion: completion)
return
}

AF.request(request).responseDecodable(of: T.Response.self) { response in
switch response.result {
case .success(let data):
completion(.success(data))

case .failure(let error):
debugPrint(error)
self.handleRequestFailure(
request: request,
error: error,
retryCount: retryCount,
completion: completion
)
}
}
}

/// 네트워크 연결이 없을 때 처리
/// - Note: enableAutoRetryOnReconnect가 true인 경우, 요청을 대기열에 추가하고 네트워크 복구 시 자동으로 재시도합니다.
/// 이 경우 completion은 재시도 후 결과로만 호출됩니다.
/// enableAutoRetryOnReconnect가 false인 경우, 즉시 networkUnavailable 에러로 completion을 호출합니다.
private func handleNetworkUnavailable<T: APIRequest>(
_ request: T,
completion: @escaping (Result<T.Response, Error>) -> Void
) {
os_log(
.info,
log: .default,
"Network unavailable - %@: %@",
configuration.enableAutoRetryOnReconnect ? "enqueueing request for retry" : "failing immediately",
String(describing: T.self)
)

if configuration.enableAutoRetryOnReconnect {
// 네트워크 복구 시 자동 재시도를 위해 대기 큐에 추가
// completion은 재시도 후에만 호출됨 (이중 호출 방지)
// Note: struct이므로 캡처 시 self의 복사본이 사용됨
let provider = self
networkMonitor.enqueuePendingRequest(
maxRetries: configuration.maxRetries,
onExecute: {
provider.request(request, completion: completion)
},
onFailure: { error in
// 대기 요청이 만료되거나 재시도 횟수를 초과한 경우
completion(.failure(error))
}
)
} else {
// 자동 재시도가 비활성화된 경우에만 즉시 에러 반환
completion(.failure(APIError.networkUnavailable))
}
}

/// 요청 실패 처리
private func handleRequestFailure<T: APIRequest>(
request: T,
error: AFError,
retryCount: Int,
completion: @escaping (Result<T.Response, Error>) -> Void
) {
let apiError = mapToAPIError(error)

// 재시도 가능한 에러인지 확인
guard shouldRetry(error: apiError, retryCount: retryCount) else {
logFailure(request: request, error: error, retryCount: retryCount, willRetry: false)

if retryCount > 0 {
completion(.failure(APIError.retryExhausted(originalError: error)))
} else {
completion(.failure(error))
}
return
}

logFailure(request: request, error: error, retryCount: retryCount, willRetry: true)

// 지수 백오프 적용 (RetryConfiguration의 설정 사용)
let delay = calculateBackoffDelay(retryCount: retryCount)

// 메인 스레드에서 completion이 호출되도록 보장
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
self.requestWithRetry(request, retryCount: retryCount + 1, completion: completion)
}
}

/// 재시도 여부 결정
private func shouldRetry(error: APIError?, retryCount: Int) -> Bool {
guard retryCount < configuration.maxRetries else { return false }
guard let error = error else { return false }
return error.isRetryable
}

/// AFError를 APIError로 변환
/// - Note: 재시도 가능한 에러를 적절히 분류하여 재시도 로직이 동작하도록 합니다.
private func mapToAPIError(_ error: AFError) -> APIError? {
switch error {
case .sessionTaskFailed(let urlError as URLError):
switch urlError.code {
case .notConnectedToInternet, .networkConnectionLost, .cannotConnectToHost,
.dataNotAllowed, .internationalRoamingOff:
return .networkUnavailable
case .timedOut:
return .statusCode(code: 408, message: "Request Timeout")
case .dnsLookupFailed, .cannotFindHost:
// DNS 문제는 일시적일 수 있으므로 재시도 가능
return .networkUnavailable
default:
return nil
}

case .responseValidationFailed(let reason):
if case .unacceptableStatusCode(let code) = reason {
return .statusCode(code: code, message: "HTTP \(code)")
}
return nil

case .responseSerializationFailed:
// 응답 직렬화 실패는 서버 문제일 수 있으므로 재시도 가능하도록 500으로 처리
// 단, 반복적인 실패를 피하기 위해 재시도는 제한됨
return .statusCode(code: 500, message: "Response Serialization Failed")

case .requestRetryFailed(let retryError, _):
// 재시도 자체가 실패한 경우, 원래 에러를 기반으로 처리
if let afError = retryError.asAFError {
return mapToAPIError(afError)
}
return nil

default:
return nil
}
}

/// 실패 로그 기록
private func logFailure<T: APIRequest>(
request: T,
error: Error,
retryCount: Int,
willRetry: Bool
) {
let retryInfo = willRetry
? "Retrying (\(retryCount + 1)/\(configuration.maxRetries))..."
: "No more retries."

os_log(
.error,
log: .default,
"Request failed: %@ - Error: %@ - %@",
String(describing: T.self),
error.localizedDescription,
retryInfo
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ enum APIError: LocalizedError {
case invalidData
case invalidURL(_ url: String)
case parsingError
case networkUnavailable
case retryExhausted(originalError: Error)

var errorDescription: String? {
switch self {
Expand All @@ -29,6 +31,29 @@ enum APIError: LocalizedError {
return String(format: NSLocalizedString("api_error_invalid_url", comment: "Invalid URL"), url)
case .parsingError:
return NSLocalizedString("api_error_parsing", comment: "JSON parsing error")
case .networkUnavailable:
return NSLocalizedString("api_error_network_unavailable", comment: "Network unavailable")
case .retryExhausted(let originalError):
return String(
format: NSLocalizedString("api_error_retry_exhausted", comment: "Retry exhausted"),
originalError.localizedDescription
)
}
}
}

// MARK: - Retry Helper
extension APIError {
/// 재시도 가능한 에러인지 확인합니다.
var isRetryable: Bool {
switch self {
case .networkUnavailable:
return true
case .statusCode(let code, _):
// 5xx 서버 에러, 408 Request Timeout, 429 Too Many Requests는 재시도 가능
return (500...599).contains(code) || code == 408 || code == 429
default:
return false
}
}
}
Loading