Skip to content

Commit 15a51c5

Browse files
committed
Extract RESTClient with automatic retry attempts on rate-limits
1 parent 083cc30 commit 15a51c5

File tree

1 file changed

+262
-0
lines changed

1 file changed

+262
-0
lines changed
+262
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import Foundation
2+
#if canImport(FoundationNetworking)
3+
import FoundationNetworking
4+
#endif
5+
6+
/// A client to consume a REST API. Uses JSON to encode/decode body data.
7+
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
8+
public final class RESTClient: Sendable {
9+
public enum RequestError: Error, LocalizedError, Sendable {
10+
public typealias Context = String
11+
12+
case failedToEncodeBody(Error, Context?)
13+
case failedToLoadData(Error, Context?)
14+
case failedToDecodeSuccessBody(Error, Context?)
15+
case failedToDecodeClientErrorBody(Error, Context?)
16+
case clientError(String, Context?)
17+
case unexpectedResponseType(URLResponse, Context?)
18+
case unexpectedStatusCode(Int, Context?)
19+
20+
public var errorDescription: String? {
21+
switch self {
22+
case .failedToEncodeBody(let error, _):
23+
"\(self.errorContext) Failed to encode body: \(error.localizedDescription)"
24+
case .failedToLoadData(let error, _):
25+
"\(self.errorContext) Failed to load data: \(error.localizedDescription)"
26+
case .failedToDecodeSuccessBody(let error, _):
27+
"\(self.errorContext) Failed to decode success body: \(error.localizedDescription)"
28+
case .failedToDecodeClientErrorBody(let error, _):
29+
"\(self.errorContext) Failed to decode client error body: \(error.localizedDescription)"
30+
case .clientError(let string, _):
31+
"\(self.errorContext) \(string)"
32+
case .unexpectedResponseType(let urlResponse, _):
33+
"\(self.errorContext) Unexpected response type (non-HTTP): \(String(describing: type(of: urlResponse)))"
34+
case .unexpectedStatusCode(let int, _):
35+
"\(self.errorContext) Unexpected status code: \(int)"
36+
}
37+
}
38+
39+
private var errorContext: String {
40+
switch self {
41+
case .failedToEncodeBody(_, let context), .failedToLoadData(_, let context), .failedToDecodeSuccessBody(_, let context),
42+
.failedToDecodeClientErrorBody(_, let context), .clientError(_, let context):
43+
if let context {
44+
return "[\(context): Client Error]"
45+
} else {
46+
return "[Client Error]"
47+
}
48+
49+
case .unexpectedResponseType(_, let context), .unexpectedStatusCode(_, let context):
50+
if let context {
51+
return "[\(context): Server Error]"
52+
} else {
53+
return "[Server Error]"
54+
}
55+
}
56+
}
57+
}
58+
59+
public enum HTTPMethod: String, Sendable {
60+
case get = "GET"
61+
case post = "POST"
62+
case put = "PUT"
63+
case patch = "PATCH"
64+
case delete = "DELETE"
65+
}
66+
67+
public enum Body: Sendable {
68+
case binary(Data)
69+
case json(Encodable & Sendable)
70+
case string(String)
71+
case form([URLQueryItem])
72+
73+
var contentType: String {
74+
switch self {
75+
case .binary: return "application/octet-stream"
76+
case .json: return "application/json"
77+
case .string: return "text/plain"
78+
case .form: return "application/x-www-form-urlencoded"
79+
}
80+
}
81+
82+
func httpData() throws -> Data {
83+
switch self {
84+
case .binary(let data):
85+
return data
86+
87+
case .json(let json):
88+
return try JSONEncoder().encode(json)
89+
90+
case .string(let string):
91+
return Data(string.utf8)
92+
93+
case .form(let queryItems):
94+
var urlComponents = URLComponents(string: "https://example.com")!
95+
urlComponents.queryItems = queryItems
96+
return Data(urlComponents.percentEncodedQuery!.utf8)
97+
}
98+
}
99+
}
100+
101+
let baseURL: URL
102+
let baseHeaders: [String: String]
103+
let baseQueryItems: [URLQueryItem]
104+
let jsonEncoder: JSONEncoder
105+
let jsonDecoder: JSONDecoder
106+
let baseErrorContext: String?
107+
let errorBodyToMessage: @Sendable (Data) throws -> String
108+
109+
// no need to pass 'application/json' to `baseHeaders`, it'll automatically be added of a body is sent
110+
public init(
111+
baseURL: URL,
112+
baseHeaders: [String: String] = [:],
113+
baseQueryItems: [URLQueryItem] = [],
114+
jsonEncoder: JSONEncoder = .init(),
115+
jsonDecoder: JSONDecoder = .init(),
116+
baseErrorContext: String? = nil,
117+
errorBodyToMessage: @Sendable @escaping (Data) throws -> String
118+
) {
119+
self.baseURL = baseURL
120+
self.baseHeaders = baseHeaders
121+
self.baseQueryItems = baseQueryItems
122+
self.jsonEncoder = jsonEncoder
123+
self.jsonDecoder = jsonDecoder
124+
self.baseErrorContext = baseErrorContext
125+
self.errorBodyToMessage = errorBodyToMessage
126+
}
127+
128+
public func requestObject<ResponseBodyType: Decodable>(
129+
method: HTTPMethod,
130+
path: String,
131+
body: Body? = nil,
132+
extraHeaders: [String: String] = [:],
133+
extraQueryItems: [URLQueryItem] = [],
134+
errorContext: String? = nil
135+
) async throws(RequestError) -> ResponseBodyType {
136+
let responseData = try await self.requestData(method: method, path: path, body: body, extraHeaders: extraHeaders, extraQueryItems: extraQueryItems)
137+
138+
do {
139+
return try self.jsonDecoder.decode(ResponseBodyType.self, from: responseData)
140+
} catch {
141+
throw .failedToDecodeSuccessBody(error, self.errorContext(requestContext: errorContext))
142+
}
143+
}
144+
145+
@discardableResult
146+
public func requestData(
147+
method: HTTPMethod,
148+
path: String,
149+
body: Body? = nil,
150+
extraHeaders: [String: String] = [:],
151+
extraQueryItems: [URLQueryItem] = [],
152+
errorContext: String? = nil
153+
) async throws(RequestError) -> Data {
154+
let url = self.baseURL
155+
.appending(path: path)
156+
.appending(queryItems: self.baseQueryItems + extraQueryItems)
157+
158+
var request = URLRequest(url: url)
159+
request.httpMethod = method.rawValue
160+
161+
for (field, value) in self.baseHeaders.merging(extraHeaders, uniquingKeysWith: { $1 }) {
162+
request.setValue(value, forHTTPHeaderField: field)
163+
}
164+
165+
if let body {
166+
do {
167+
request.httpBody = try body.httpData()
168+
} catch {
169+
throw RequestError.failedToEncodeBody(error, self.errorContext(requestContext: errorContext))
170+
}
171+
172+
request.setValue(body.contentType, forHTTPHeaderField: "Content-Type")
173+
}
174+
175+
request.setValue("application/json", forHTTPHeaderField: "Accept")
176+
177+
let (data, response) = try await self.performRequest(request, errorContext: errorContext)
178+
return try await self.handle(data: data, response: response, for: request, errorContext: errorContext)
179+
}
180+
181+
private func errorContext(requestContext: String?) -> String? {
182+
let context = [self.baseErrorContext, requestContext].compactMap { $0 }.joined(separator: "->")
183+
guard !context.isEmpty else { return nil }
184+
return context
185+
}
186+
187+
private func performRequest(_ request: URLRequest, errorContext: String?) async throws(RequestError) -> (Data, URLResponse) {
188+
self.logRequestIfDebug(request)
189+
190+
let data: Data
191+
let response: URLResponse
192+
do {
193+
(data, response) = try await URLSession.shared.data(for: request)
194+
} catch {
195+
throw RequestError.failedToLoadData(error, self.errorContext(requestContext: errorContext))
196+
}
197+
198+
self.logResponseIfDebug(response, data: data)
199+
return (data, response)
200+
}
201+
202+
private func handle(data: Data, response: URLResponse, for request: URLRequest, errorContext: String?, attempt: Int = 1) async throws(RequestError) -> Data {
203+
guard let httpResponse = response as? HTTPURLResponse else {
204+
throw .unexpectedResponseType(response, self.errorContext(requestContext: errorContext))
205+
}
206+
207+
switch httpResponse.statusCode {
208+
case 200..<300:
209+
return data
210+
211+
case 429:
212+
guard attempt < 5 else { fallthrough }
213+
214+
#if DEBUG
215+
print("Received Status Code 429 'Too Many Requests'. Retrying in \(attempt) second(s)...")
216+
#endif
217+
218+
try? await Task.sleep(for: .seconds(attempt))
219+
220+
let (newData, newResponse) = try await self.performRequest(request, errorContext: errorContext)
221+
return try await self.handle(data: newData, response: newResponse, for: request, errorContext: errorContext, attempt: attempt + 1)
222+
223+
case 400..<500:
224+
guard !data.isEmpty else {
225+
throw .clientError("Unexpected status code \(httpResponse.statusCode) without a response body.", self.errorContext(requestContext: errorContext))
226+
}
227+
228+
let clientErrorMessage: String
229+
do {
230+
clientErrorMessage = try self.errorBodyToMessage(data)
231+
} catch {
232+
throw .failedToDecodeClientErrorBody(error, self.errorContext(requestContext: errorContext))
233+
}
234+
throw .clientError(clientErrorMessage, self.errorContext(requestContext: errorContext))
235+
236+
default:
237+
throw .unexpectedStatusCode(httpResponse.statusCode, self.errorContext(requestContext: errorContext))
238+
}
239+
}
240+
241+
private func logRequestIfDebug(_ request: URLRequest) {
242+
#if DEBUG
243+
var requestBodyString: String?
244+
if let bodyData = request.httpBody {
245+
requestBodyString = String(data: bodyData, encoding: .utf8)
246+
}
247+
248+
print("[\(self)] Sending \(request.httpMethod!) request to '\(request.url!)': \(request)\n\nHeaders:\n\(request.allHTTPHeaderFields ?? [:])\n\nBody:\n\(requestBodyString ?? "No body")")
249+
#endif
250+
}
251+
252+
private func logResponseIfDebug(_ response: URLResponse, data: Data?) {
253+
#if DEBUG
254+
var responseBodyString: String?
255+
if let data = data {
256+
responseBodyString = String(data: data, encoding: .utf8)
257+
}
258+
259+
print("[\(self)] Received response & body from '\(response.url!)': \(response)\n\nResponse headers:\n\((response as? HTTPURLResponse)?.allHeaderFields ?? [:])\n\nResponse body:\n\(responseBodyString ?? "No body")")
260+
#endif
261+
}
262+
}

0 commit comments

Comments
 (0)