This commit is contained in:
Daniel Arantes Loverde
2026-02-06 09:51:53 -03:00
parent 379bb35cfa
commit 92c2a67736
13 changed files with 574 additions and 138 deletions

View File

@@ -7,8 +7,10 @@ enum NetworkError: Error, LocalizedError {
case invalidURL
case invalidResponse
case httpError(Int, String?)
case decodeError
case rateLimited
case unauthorized(String?)
case decodeError(String?)
case rateLimited(Int?)
case transportError(String)
var errorDescription: String? {
switch self {
@@ -16,8 +18,20 @@ enum NetworkError: Error, LocalizedError {
case .invalidResponse: return "Resposta invalida do servidor"
case .httpError(let code, let message):
return message ?? "Erro HTTP (\(code))"
case .decodeError: return "Erro ao interpretar dados"
case .rateLimited: return "Muitas requisicoes. Tente novamente."
case .unauthorized(let message):
return message ?? "Sessao expirada. Faca login novamente."
case .decodeError(let payload):
if let payload, payload.isEmpty == false {
return "Erro ao interpretar dados: \(payload)"
}
return "Erro ao interpretar dados"
case .rateLimited(let retryAfter):
if let retryAfter {
return "Muitas requisicoes. Tente novamente em \(retryAfter)s."
}
return "Muitas requisicoes. Tente novamente."
case .transportError(let message):
return "Erro de rede: \(message)"
}
}
}
@@ -48,6 +62,8 @@ struct ApiRequest {
final class ApiClient {
private let session: URLSession
private let tokenStore: TokenStore
private let maxAttempts = 3
private let baseBackoffNanoseconds: UInt64 = 300_000_000
init(session: URLSession = .shared, tokenStore: TokenStore = DefaultTokenStore()) {
self.session = session
@@ -69,27 +85,93 @@ final class ApiClient {
urlRequest.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization")
}
let (data, response) = try await session.data(for: urlRequest)
var attempt = 1
while attempt <= maxAttempts {
do {
return try await perform(urlRequest, as: T.self)
} catch let error as NetworkError {
guard shouldRetry(error), attempt < maxAttempts else {
throw error
}
try await Task.sleep(nanoseconds: backoff(for: attempt, error: error))
attempt += 1
} catch {
let wrapped = NetworkError.transportError(error.localizedDescription)
guard attempt < maxAttempts else { throw wrapped }
try await Task.sleep(nanoseconds: backoff(for: attempt, error: wrapped))
attempt += 1
}
}
throw NetworkError.invalidResponse
}
private func perform<T: Decodable>(_ request: URLRequest, as type: T.Type) async throws -> T {
let (data, response): (Data, URLResponse)
do {
(data, response) = try await session.data(for: request)
} catch {
throw NetworkError.transportError(error.localizedDescription)
}
guard let http = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
if http.statusCode == 429 {
throw NetworkError.rateLimited
let retryAfter = Int(http.value(forHTTPHeaderField: "Retry-After") ?? "")
throw NetworkError.rateLimited(retryAfter)
}
if http.statusCode == 401 || http.statusCode == 403 {
throw NetworkError.unauthorized(serverMessage(from: data))
}
if !(200...299).contains(http.statusCode) {
let message = String(data: data, encoding: .utf8)
throw NetworkError.httpError(http.statusCode, message)
throw NetworkError.httpError(http.statusCode, serverMessage(from: data))
}
do {
return try JSONDecoder().decode(T.self, from: data)
return try JSONDecoder().decode(type, from: data)
} catch {
throw NetworkError.decodeError
throw NetworkError.decodeError(String(data: data, encoding: .utf8))
}
}
private func shouldRetry(_ error: NetworkError) -> Bool {
switch error {
case .rateLimited, .transportError:
return true
case .httpError(let statusCode, _):
return statusCode >= 500
case .invalidURL, .invalidResponse, .decodeError, .unauthorized:
return false
}
}
private func backoff(for attempt: Int, error: NetworkError) -> UInt64 {
if case .rateLimited(let retryAfter) = error, let retryAfter {
return UInt64(retryAfter) * 1_000_000_000
}
let multiplier = UInt64(max(1, attempt))
return min(baseBackoffNanoseconds * multiplier, 2_000_000_000)
}
private func serverMessage(from data: Data) -> String? {
if let envelope = try? JSONDecoder().decode(ApiEnvelope<EmptyResult>.self, from: data) {
return envelope.message
}
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
if let message = object["message"] as? String {
return message
}
if let code = object["code"] as? String {
return "Erro: \(code)"
}
}
return String(data: data, encoding: .utf8)
}
private func buildURL(path: String, query: [URLQueryItem]) throws -> URL {
guard var components = URLComponents(url: ApiConfig.baseURL, resolvingAgainstBaseURL: false) else {
throw NetworkError.invalidURL