현재 내가 개발중인 앱 볼레또에서는 에러코드핸들링을 오로지 401 즉 토큰 만료될때에만
final class RequestTokenInterceptor: RequestInterceptor {
func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, any Error>) -> Void) {
guard let accessToken = KeyChainManager.shared.read(key: .accessToken) else {
return
}
var urlRequest = urlRequest
urlRequest.setValue("Bearer " + accessToken, forHTTPHeaderField: "Authorization")
completion(.success(urlRequest))
}
func retry(_ request: Request, for session: Session, dueTo error: any Error, completion: @escaping (RetryResult) -> Void) {
// accessToken만료되었을때 코드 작성 RefreshTokenAPI.refreshToken
guard
let response = request.response,
response.statusCode == 401, // 401 상태 코드인지 확인
let refreshToken = KeyChainManager.shared.read(key: .refreshToken)
else {
completion(.doNotRetry) // 다른 경우는 재시도하지 않음
return
}
_ = API.session.request(AccountRouter.postRefreshToken(refreshToken: refreshToken))
.validate()
.responseDecodable(of: GeneralResponse<TokenResponse>.self) { res in
switch res.result {
case .success(let data):
guard let accessToken = data.data?.accessToken, let refreshToken = data.data?.refreshToken else{
completion(.doNotRetryWithError(CustomError.expiredRefreshToken))
return
}
if let error = data.error {
if error.code == 40101 {
completion(.doNotRetryWithError(CustomError.expiredRefreshToken))
return
}
}
KeyChainManager.shared.save(key: .accessToken, token: accessToken)
KeyChainManager.shared.save(key: .refreshToken, token: refreshToken)
completion(.retry)
case .failure(let err):
completion(.doNotRetryWithError(err))
}
}
}
}
intecerceptor에서 statusCode가 401일때만 retry에서 가지고 있는 리프레쉬 토큰을 보내 AccessToken을 갱신하여 다시 해당 API를 요청하도록 구현을 하였다.
하지만 밑의 서버에서 주는 에러코드를 보면 겁나게 많다....
response 찍어보면
StatusCode: 401
Data: {
"success" : false,
"data" : null,
"error" : {
"message" : "만료된 토큰입니다.",
"code" : 40101
}
}
이런식으로 도착한다.
public enum ErrorCode {
// Method Not Allowed Error
METHOD_NOT_ALLOWED(40500, HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 HTTP 메소드입니다."),
// Not Found Error
NOT_FOUND_END_POINT(40400, HttpStatus.NOT_FOUND, "존재하지 않는 API 엔드포인트입니다."),
NOT_FOUND_RESOURCE(40400, HttpStatus.NOT_FOUND, "해당 리소스가 존재하지 않습니다."),
NOT_FOUND_LOGIN_USER(40401, HttpStatus.NOT_FOUND, "로그인한 사용자가 존재하지 않습니다."),
NOT_FOUND_AUTHORIZATION_HEADER(40401, HttpStatus.NOT_FOUND, "Authorization 헤더가 존재하지 않습니다."),
NOT_FOUND_USER(40401, HttpStatus.NOT_FOUND, "해당 사용자가 존재하지 않습니다."),
NOT_FOUND_TRAVEL(40401, HttpStatus.NOT_FOUND, "해당 여행이 존재하지 않습니다."),
NOT_FOUND_FRIEND_CODE(40401, HttpStatus.NOT_FOUND, "해당 친구 코드가 존재하지 않습니다."),
NOT_FOUND_MEMORY(40401, HttpStatus.NOT_FOUND, "해당 메모리가 존재하지 않습니다."),
NOT_FOUND_SYS_FRAME(40490, HttpStatus.NOT_FOUND, "해당 시스템 프레임이 존재하지 않습니다,"),
NOT_FOUND_SYS_STICKER(40091, HttpStatus.NOT_FOUND, "해당 시스템 스티커가 존재하지 않습니다."),
NOT_FOUND_TICKET(40492, HttpStatus.NOT_FOUND, "사용 가능한 티켓이 존재하지 않습니다."),
// Invalid Argument Error
MISSING_REQUEST_PARAMETER(40000, HttpStatus.BAD_REQUEST, "필수 요청 파라미터가 누락되었습니다."),
INVALID_ARGUMENT(40001, HttpStatus.BAD_REQUEST, "요청에 유효하지 않은 인자입니다."),
INVALID_PARAMETER_FORMAT(40002, HttpStatus.BAD_REQUEST, "요청에 유효하지 않은 인자 형식입니다."),
INVALID_HEADER_ERROR(40003, HttpStatus.BAD_REQUEST, "유효하지 않은 헤더입니다."),
MISSING_REQUEST_HEADER(40004, HttpStatus.BAD_REQUEST, "필수 요청 헤더가 누락되었습니다."),
BAD_REQUEST_PARAMETER(40005, HttpStatus.BAD_REQUEST, "잘못된 요청 파라미터입니다."),
INVALID_APPLE_TOKEN(40006, HttpStatus.BAD_REQUEST, "유효하지 않는 Apple Token입니다."),
BAD_REQUEST_JSON(40007, HttpStatus.BAD_REQUEST, "잘못된 JSON 형식입니다."),
SEARCH_SHORT_LENGTH_ERROR(40008, HttpStatus.BAD_REQUEST, "검색어는 2글자 이상이어야 합니다."),
INVALID_PROVIDER(40009, HttpStatus.BAD_REQUEST, "유효하지 않은 Provider입니다."),
EXPIRED_FRIEND_CODE(40010, HttpStatus.BAD_REQUEST, "만료된 친구 코드입니다."),
USED_FRIEND_CODE(40011, HttpStatus.BAD_REQUEST, "이미 사용된 친구 코드입니다."),
SELF_FRIEND_CODE(40012, HttpStatus.BAD_REQUEST, "자신의 친구 코드는 사용할 수 없습니다."),
TRAVEL_OVERLAP(40013, HttpStatus.BAD_REQUEST, "여행 일정이 중복됩니다."),
ALREADY_ACCEPTED_TRAVEL(40014, HttpStatus.BAD_REQUEST, "이미 수락한 여행입니다."),
EVENT_EXPIRED(40015, HttpStatus.BAD_REQUEST, "만료된 이벤트입니다."),
// Access Denied Error
ACCESS_DENIED(40300, HttpStatus.FORBIDDEN, "접근 권한이 없습니다."),
NOT_MATCH_AUTH_CODE(40301, HttpStatus.FORBIDDEN, "인증 코드가 일치하지 않습니다."),
NOT_MATCH_USER(40302, HttpStatus.FORBIDDEN, "해당 사용자가 일치하지 않습니다."),
SIGN_OUT_USER(40303, HttpStatus.FORBIDDEN, "탈퇴한 사용자는 24시간 이내 가입할 수 없습니다."),
TRAVEL_ALREADY_LOCKED(40304, HttpStatus.FORBIDDEN, "이미 잠긴 여행입니다."),
TRAVEL_ALREADY_EDITING(40305, HttpStatus.FORBIDDEN, "이미 편집 중인 여행입니다."),
// Conflict Error
ALREADY_COLLECTED_FRAME(40901, HttpStatus.CONFLICT, "이미 수집한 프레임 입니다."),
ALREADY_COLLECTED_STICKER(40902, HttpStatus.CONFLICT, "이미 수집한 스티커 입니다."),
DUPLICATED_SYS_STICKER_CODE(40903, HttpStatus.CONFLICT, "중복된 시스템 스티커 코드입니다."),
DUPLICATED_SYS_FRAME_CODE(40903, HttpStatus.CONFLICT, "중복된 시스템 프레임 코드입니다."),
// Unauthorized Error
FAILURE_LOGIN(40100, HttpStatus.UNAUTHORIZED, "잘못된 아이디 또는 비밀번호입니다."),
EXPIRED_TOKEN_ERROR(40101, HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."),
INVALID_TOKEN_ERROR(40102, HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."),
TOKEN_MALFORMED_ERROR(40103, HttpStatus.UNAUTHORIZED, "토큰이 올바르지 않습니다."),
TOKEN_TYPE_ERROR(40104, HttpStatus.UNAUTHORIZED, "토큰 타입이 일치하지 않거나 비어있습니다."),
TOKEN_UNSUPPORTED_ERROR(40105, HttpStatus.UNAUTHORIZED, "지원하지않는 토큰입니다."),
TOKEN_GENERATION_ERROR(40106, HttpStatus.UNAUTHORIZED, "토큰 생성에 실패하였습니다."),
TOKEN_UNKNOWN_ERROR(40107, HttpStatus.UNAUTHORIZED, "알 수 없는 토큰입니다."),
// Internal Server Error
INTERNAL_SERVER_ERROR(50000, HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 에러입니다."),
UPLOAD_FILE_ERROR(50001, HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패하였습니다.");
private final Integer code;
private final HttpStatus httpStatus;
private final String message;
그런데 문제는 내 코드를 보면
struct NetworkManager {
static func request<T: Decodable>(
endpoint: URLRequestConvertible,
responseType: T.Type
) async throws (CustomError) -> T {
let task = API.session.request(endpoint, interceptor: RequestTokenInterceptor())
.validate(statusCode: 200..<300)
.serializingDecodable(T.self)
do {
let response = try await task.value
return response
} catch let error as AFError{
switch error {
case .requestRetryFailed(let retryError, _ ):
if let customError = retryError as? CustomError {
throw customError
} else {
throw CustomError.unknownError
}
default:
throw CustomError.unknownError
}
} catch {
throw CustomError.unknownError
}
}
// static func uploadMultipart
}
동작 원리가 200-300에 속하는 statusCode가 아니면 에러를 뱉고 그게 retry로 넘어가 statusCode를 확인하고 해당 동작을 하는 원리이다. 하지만 문제는 이렇게 되면 저기 error영역에 있는 code와 message에 따라 에러 핸들링이 불가능하고 크게크게 ClientIssue, SeverIssue등으로 밖에 하지 못했다. 그래서 처음에 생각해 낸 방법은
1. 검증을 없애고 전체를 받아서 statusCode에 따라 핸들링
struct NetworkManager {
static func request<T: Decodable>(
endpoint: URLRequestConvertible,
responseType: T.Type
) async throws (CustomError) -> T {
let task = API.session.request(endpoint, interceptor: RequestTokenInterceptor())
.serializingDecodable(GeneralResponse<T>.self)
let result = await task.result
let response = await task.response.response?.statusCode
guard let generalResponse = await task.response.value else {
throw CustomError.invalidResponse
}
switch response {
case 200:
guard let data = generalResponse.data else {
throw CustomError.invalidResponse
}
return data
case 400:
guard let error = generalResponse.error else {
throw CustomError.invalidResponse
}
throw CustomError.badRequest(message: error.message, code: error.code)
case 403:
guard let error = generalResponse.error else {
throw CustomError.unknownError
}
throw CustomError.forbidden(message: error.message, code: error.code)
case 409:
guard let error = generalResponse.error else {
throw CustomError.unknownError
}
throw CustomError.conflict(message: error.message, code: error.code)
case 401:
// guard let error = generalResponse.error else {
// throw CustomError.unknownError
// }
// throw CustomError.unauthorized(message: error.message, code: error.code)
case 404:
guard let error = generalResponse.error else {
throw CustomError.unknownError
}
throw CustomError.notFound(message: error.message, code: error.code)
default:
throw CustomError.unknownError
}
}
}
보면validate가 없어서 statusCode에 따라 에러부분을 디코딩해서 코드와 메시지를 넘겨주는 방식으로 진행을 해보았다. 잘되는줄 알았다..문제도 없었꾸..하지만 가장 큰 문제가 생겼다.retry를 안한다. 그래서 validate를 어떻게 하지 고민을 하다가 커스텀하여 만드는 것으로 결정했다.
//
// DataRequest+.swift
// Boleto
//
// Created by Sunho on 12/16/24.
//
import Alamofire
import Foundation
extension DataRequest {
func customValidate() -> Self {
validate {request, response, data in
let statusCode = response.statusCode
switch statusCode {
case 200...299:
return .success(())
case 401:
return .failure(AFError.responseValidationFailed(reason: .unacceptableStatusCode(code: statusCode)))
default:
if let data = data {
do {
// 서버 에러 응답 디코딩
let apiResponse = try JSONDecoder().decode( GeneralResponse<EmptyData>.self, from: data)
if let apiError = apiResponse.error {
let customError = CustomErrorInfo.from(code: apiError.code, message: apiError.message)
return .failure(customError)
}
return .failure(CustomErrorInfo.unknownError("몰라디코딩"))
} catch {
return .failure(CustomErrorInfo.unknownError("Unknown error"))
}
}
return .failure(CustomErrorInfo.unknownError("no error data"))
}
}
}
}
코드를 보면서 이해 드가자. 1. validate는 서버 응답(req,res,data)기반으로 특정 조건을 확인 후 실패시 AFError가 아닌 내가 지정한 에러를 반환하도록 하였다. 기본적인 상태코드 200-299는 성공으로 하고 401은 토큰 관련 이슈이기 때문에 AFError.responseValidationFailed를 뱉도록 하였다. 그 외의 경우는 해당 서버 데이터형식으로 디코딩을 진행한다.
enum CustomErrorInfo: Error {
case methodNotAllowed(String)
case notFound(String)
case badRequest(String)
case accessDenied(String)
case conflict(String)
case unauthorized(String)
case internalServerError(String)
case unknownError(String)
// 서버 에러 코드 기반 매핑
static func from(code: Int, message: String) -> Self {
switch code {
case 40500:
return .methodNotAllowed(message)
case 40400...40499:
return .notFound(message)
case 40000...40099:
return .badRequest(message)
case 40300...40399:
return .accessDenied(message)
case 40900...40999:
return .conflict(message)
case 40100...40199:
return .unauthorized(message)
case 50000...50099:
return .internalServerError(message)
default:
return .unknownError(message)
}
}
}
서버와 협의한대로 에러코드의 범위를 나누고 API응답의 에러 정보를 사용자 정의 에러 객체로 변환하였다.
struct NetworkManager {
static func request<T: Decodable>(
endpoint: URLRequestConvertible,
responseType: T.Type
) async throws (CustomErrorInfo) -> T {
let task = API.session.request(endpoint, interceptor: RequestTokenInterceptor())
.customValidate()
.serializingDecodable(GeneralResponse<T>.self)
let result = await task.result
switch result {
case .success(let data):
guard let data = data.data else{
throw CustomErrorInfo.unknownError("모르겟다냥")
}
return data
case .failure(let err):
if case let .responseValidationFailed(reason) = err,
case let .customValidationFailed(error) = reason,
let customError = error as? CustomErrorInfo {
throw customError
}
throw CustomErrorInfo.unknownError("HI오냠냐냐")
}
}
}
저기서 타입캐스팅을 한이유는 기본 err는 AFError type으로 에러를 찍어보면
Result: failure(Alamofire.AFError.responseValidationFailed(reason: Alamofire.AFError.ResponseValidationFailureReason.customValidationFailed(error: Boleto.CustomErrorInfo.badRequest("여행 일정이 중복됩니다."))))
이런 식으로 오기에 내가 원하는건 결국 customErrorInfo만 필요하여 타입캐스팅을 통해 필요한 에러 객태만 추출할 수 있다.
이렇게 함으로써 200, 401뿐만 아니라 다양한 에러코드를 원하는 방식으로 확장할 수 있다.
또한 에러여도 해당 에러에 대한 정보를 디코딩 할 수 있어 alert를 띄워줄때 메시지를 자동으로 서버로부터 받아와서 프론트에서도 확장성이 좋아진다.
'iOS' 카테고리의 다른 글
Image(Pixel, asset, memory....)등등을 포함한 & HIG (1) | 2024.11.24 |
---|---|
CoreLocation과 Battery의 관계 (1) | 2024.04.21 |
CoreLocation & MapKit (1) | 2024.04.19 |
Uniform type Identifiers (1) | 2024.04.18 |
Coremotion2편- 걷기데이터 & HealthKit (0) | 2024.04.17 |