This repository has been archived by the owner on Aug 30, 2022. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 35
/
EosioRpcProvider.swift
423 lines (384 loc) · 18.8 KB
/
EosioRpcProvider.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
//
// EosioRpcProvider.swift
// EosioSwift
//
// Created by Ben Martell on 4/22/19.
// Copyright (c) 2017-2019 block.one and its contributors. All rights reserved.
//
import Foundation
import PromiseKit
#if SWIFT_PACKAGE
import PMKFoundation
#endif
/// Default RPC Provider implementation. Conforms to `EosioRpcProviderProtocol`.
/// RPC Reference: https://developers.eos.io/eosio-nodeos/reference
public class EosioRpcProvider {
private let getInfoRpc = "chain/get_info"
// How to handle error conditions for retry / failover.
private enum NextAction {
case returnError
case retry
case failover
case retryOnceThenFailover
}
/// The blockchain ID that all RPC calls for an active instance of EosioRpcProvider should be interacting with.
public var chainId: String?
private var originalChainId: String?
private var origEndpoints: [URL]
private let retries: Int
private let dispatchTimeInterval: DispatchTimeInterval
private var currentEndpoint: URL?
private var endPointQueue = Queue<URL>()
/// Initialize the default RPC Provider implementation with one RPC node endpoint.
///
/// - Parameters:
/// - endpoint: A node URL.
/// - retries: Number of times to retry an endpoint before failing (default is 3 tries).
/// - delayBeforeRetry: Number of seconds to wait between each retry (default is 1 second).
public convenience init(endpoint: URL, retries: Int = 3, delayBeforeRetry: Int = 1) {
self.init(endpoints: [endpoint], retries: retries, delayBeforeRetry: delayBeforeRetry)
}
/// Initialize the default RPC Provider implementation with a list of RPC node endpoints. Extra endpoints will be used for failover purposes.
/// - Parameters:
/// - endpoints: A list of node URLs.
/// - retries: Number of times to retry an endpoint before failing over to the next (default is 3 tries).
/// - delayBeforeRetry: Number of seconds to wait between each retry (default is 1 second).
public init(endpoints: [URL], retries: Int = 3, delayBeforeRetry: Int = 1) {
assert(endpoints.count > 0, "Assertion Failure: The endpoints array cannot be empty.")
self.origEndpoints = endpoints
self.retries = retries
self.dispatchTimeInterval = .seconds(delayBeforeRetry)
setUpEndPoints()
}
private func setUpEndPoints() {
for url in origEndpoints {
endPointQueue.enqueue(url)
}
self.currentEndpoint = endPointQueue.dequeue()
}
// This is based on the retry/polling pattern found at:
// https://github.com/mxcl/PromiseKit/blob/master/Documentation/CommonPatterns.md#retry--polling
//
// The 'body' here refers to a promise func that is wrapped by this retry promise.
// The inner attempt() func is dispatched for every promise try.
// Usage of this func will look like:
// retry {
// someFunc() -> Promise<T>
// }
//
private func retry<T>(_ body: @escaping () -> Promise<T>) -> Promise<T> {
var attempts = 0
func attempt() -> Promise<T> {
attempts += 1
return body().recover { error -> Promise<T> in
// We only want to retry for specific errors!
guard (attempts < self.retries) && self.isRetryable(error: error, tries: attempts) else {
throw error
}
return after(self.dispatchTimeInterval).then(on: nil, attempt)
}
}
return attempt()
}
private func isRetryable(error: Error, tries: Int) -> Bool {
let nextAction = nextActionFor(error: error)
if nextAction == .retry || (nextAction == .retryOnceThenFailover && tries == 1 ) {
return true
} else {
return false
}
}
private func nextActionFor(error: Error) -> NextAction {
if let theError = error as? PMKHTTPError {
switch theError {
case .badStatusCode(let code, _, _) :
if code == 500 ||
code == 401 ||
code == 418 {
return NextAction.returnError
} else {
return NextAction.retry
}
}
} else if let theError = error as? EosioError {
if theError.errorCode == .rpcProviderFatalError {
return NextAction.returnError
} else {
return NextAction.failover
}
} else {
return handleNSError(error: (error as NSError))
}
}
// swiftlint:disable function_body_length
// swiftlint:disable cyclomatic_complexity
private func handleNSError(error: NSError) -> NextAction {
switch error.code {
case NSURLErrorAppTransportSecurityRequiresSecureConnection:
return NextAction.returnError
case NSURLErrorBackgroundSessionInUseByAnotherProcess:
return NextAction.returnError
case NSURLErrorBadServerResponse:
return NextAction.failover
case NSURLErrorBadURL:
return NextAction.returnError
case NSURLErrorCallIsActive:
return NextAction.returnError
case NSURLErrorCannotConnectToHost:
return NextAction.retry
case NSURLErrorCannotDecodeContentData:
return NextAction.failover
case NSURLErrorCannotDecodeRawData:
return NextAction.failover
case NSURLErrorCannotFindHost:
return NextAction.retry
case NSURLErrorCannotParseResponse:
return NextAction.failover
case NSURLErrorClientCertificateRejected:
return NextAction.failover
case NSURLErrorClientCertificateRequired:
return NextAction.failover
case NSURLErrorDNSLookupFailed:
return NextAction.retry
case NSURLErrorDataLengthExceedsMaximum:
return NextAction.failover
case NSURLErrorDataNotAllowed:
return NextAction.returnError
case NSURLErrorHTTPTooManyRedirects:
return NextAction.failover
case NSURLErrorInternationalRoamingOff:
return NextAction.returnError
case NSURLErrorNetworkConnectionLost:
return NextAction.retry
case NSURLErrorNotConnectedToInternet:
return NextAction.returnError
case NSURLErrorRedirectToNonExistentLocation:
return NextAction.failover
case NSURLErrorRequestBodyStreamExhausted:
return NextAction.returnError
case NSURLErrorResourceUnavailable:
return NextAction.failover
case NSURLErrorSecureConnectionFailed:
return NextAction.failover
case NSURLErrorServerCertificateHasBadDate:
return NextAction.failover
case NSURLErrorServerCertificateHasUnknownRoot:
return NextAction.failover
case NSURLErrorServerCertificateNotYetValid:
return NextAction.failover
case NSURLErrorServerCertificateUntrusted:
return NextAction.failover
case NSURLErrorTimedOut:
return NextAction.retry
case NSURLErrorUnknown:
return NextAction.returnError
case NSURLErrorUnsupportedURL:
return NextAction.returnError
case NSURLErrorUserAuthenticationRequired:
return NextAction.failover
case NSURLErrorUserCancelledAuthentication:
return NextAction.returnError
case NSURLErrorZeroByteResource:
return NextAction.failover
default:
return NextAction.returnError
}
}
// swiftlint:enable function_body_length
// swiftlint:enable cyclomatic_complexity
private func canErrorFailOverToNewEndpoint(error: Error) -> Bool {
let errorAction = nextActionFor(error: error)
if errorAction == NextAction.returnError {
return false
}
// Any endpoints to try?
guard let newEndpoint = self.endPointQueue.dequeue() else {
// All endpoints have been exhausted.
// Set endpoint to original one so the RPC provider instance is not DOA for subsequent calls.
self.currentEndpoint = self.origEndpoints[0]
return false
}
// Set up for failover run. Will force a get and compare of new endpoint's chainId.
self.currentEndpoint = newEndpoint
self.originalChainId = self.chainId
self.chainId = nil
return true
}
/// Creates an RPC request, makes the network call, and handles the response returning a Promise.
///
/// - Parameters:
/// - _: Differentiates call signature from that of non-promise-returning endpoint method. Pass in `.promise` as the first parameter to call this method.
/// - rpc: String representing endpoint path. E.g., `chain/get_account`.
/// - requestParameters: The request object.
/// - Returns: A Promise fulfilling with a response object conforming to the `EosioRpcResponseProtocol` and rejecting with an Error.
func getResource<T: Decodable & EosioRpcResponseProtocol>(_: PMKNamespacer, rpc: String, requestParameters: Encodable?) -> Promise<T> {
/*
Logic for retry and failover implementation:
1) First call to an endpoint needs to call getInfo to get the blockchain ID which is stored to ensure all
calls and all endpoints are running on the same blockchain.
2) An endpoint call is retried on failures up to the number of times specified by the RPCProvider's
retries property. Retry only occurs for specific failures. E.g., no network connection
is an error that will bubble up so the calling app can deal with it. See nextActionFor(error: Error) -> NextAction.
3) Failover. After all retries fail then try the process again with a subsequent endpoint.
a) Subsequent endpoints not having the same blockchain ID as the first should be
discarded and the next tried if one is available. Otherwise, bubble up the failure.
b) Certain failures are considered fatal and will not failover to a new endpoint. E.g., no network connection, etc.
See nextActionFor(error: Error) -> NextAction.
*/
return runWithFailover {
self.processRequest(rpc: rpc, requestParameters: requestParameters)
}
}
// This is based on the retry/polling pattern found at:
// https://github.com/mxcl/PromiseKit/blob/master/Documentation/CommonPatterns.md#retry--polling
//
// The 'body' here refers to a promise func that is wrapped by this runWithFailover promise.
// The inner failover() func is dispatched for every promise try.
// Usage of this func will look like:
// runWithFailover {
// someFunc() -> Promise<T>
// }
//
// This particular implementation is typing the return promise to a Decodable & EosioRpcResponseProtocol object.
private func runWithFailover<T: Decodable & EosioRpcResponseProtocol>(_ body: @escaping () -> Promise<T>) -> Promise<T> {
func failOver() -> Promise<T> {
return body().recover { error -> Promise<T> in
// See if we can failover this error to a new endpoint!
guard self.canErrorFailOverToNewEndpoint(error: error) else {
throw EosioError(.rpcProviderError, reason: error.localizedDescription, originalError: error as NSError)
}
return failOver()
}
}
return after(seconds: 0).then(on: nil, failOver)
}
private func processRequest<T: Decodable & EosioRpcResponseProtocol>(rpc: String, requestParameters: Encodable?) -> Promise<T> {
// This promise var is used for the return of Promise<T> expected in this function.
var promise: Promise<T>
promise = captureChainId(rpc: rpc).then { (response: EosioRpcInfoResponse) -> Promise<T> in
if rpc == self.getInfoRpc, let resp = response as? T {
return Promise.value(resp)
} else {
return self.runRequestWithRetry(rpc: rpc, requestParameters: requestParameters)
}
}
return promise
}
private func captureChainId(rpc: String) -> Promise<EosioRpcInfoResponse> {
var promise: Promise<EosioRpcInfoResponse>
if rpc != self.getInfoRpc && self.chainId != nil {
// Need to return a dummy response object there to satisfy the promise expectation.
let response = EosioRpcInfoResponse(chainId: "", headBlockNum: EosioUInt64.uint64(0),
lastIrreversibleBlockNum: EosioUInt64.uint64(0),
lastIrreversibleBlockId: "", headBlockId: "", headBlockTime: "")
return Promise.value(response)
}
promise = runRequestWithRetry(rpc: self.getInfoRpc, requestParameters: nil)
return promise.then { (response: EosioRpcInfoResponse) -> Promise<EosioRpcInfoResponse> in
if self.chainId == nil && self.originalChainId == nil {
// Very first setting of chainId
self.chainId = response.chainId
self.originalChainId = response.chainId
return Promise.value(response)
} else if self.chainId == nil {
if self.originalChainId == response.chainId {
// This check would occur if failover is happening.
// The new endpoint blockchain ID matches the original blockchain ID for previous valid endpoints running the same blockchain.
self.chainId = response.chainId
return Promise.value(response)
} else {
let error = EosioError(.rpcProviderChainIdError, reason: "New endpoint chain ID does not match previous endpoint chain ID.")
return Promise(error: error)
}
}
return Promise.value(response)
}
}
private func runRequestWithRetry<T: Decodable & EosioRpcResponseProtocol>(rpc: String, requestParameters: Encodable?) -> Promise<T> {
return retry {
self.runRequest(rpc: rpc, requestParameters: requestParameters)
}
}
private func runRequest<T: Decodable & EosioRpcResponseProtocol>(rpc: String, requestParameters: Encodable?) -> Promise<T> {
guard let endpoint = currentEndpoint else {
let error = EosioError(.rpcProviderError, reason: "No current endpoint is set.")
return Promise(error: error)
}
return buildRequest(rpc: rpc, endpoint: endpoint, requestParameters: requestParameters)
.then {
URLSession.shared.dataTask(.promise, with: $0).validate()
}.then { (data, _) in
self.decodeResponse(data: data)
}
}
private func buildRequest(rpc: String, endpoint: URL, requestParameters: Encodable?) -> Promise<URLRequest> {
let url = URL(string: "v1/" + rpc, relativeTo: endpoint)!
var request = URLRequest(url: url)
request.httpMethod = "POST"
if let requestParameters = requestParameters {
do {
let jsonData = try requestParameters.toJsonData(convertToSnakeCase: true)
#if DEBUG
print("Request JSON: \(String(data: jsonData, encoding: .utf8) ?? "Could not convert from Data to String.")")
#endif
request.httpBody = jsonData
} catch let error {
let eosioError = EosioError(.rpcProviderFatalError, reason: "Error while encoding request parameters.", originalError: error as NSError)
return Promise(error: eosioError)
}
}
return Promise.value(request)
}
private func decodeResponse<T: Decodable & EosioRpcResponseProtocol>(data: Data) -> Promise<T> {
let errorReasonPrefix = "Error occurred in decoding/serializing returned data."
let decoder = JSONDecoder()
do {
var resource = try decoder.decode(T.self, from: data)
resource._rawResponse = try JSONSerialization.jsonObject(with: data, options: .allowFragments)
#if DEBUG
if let response = resource._rawResponse {
print("EosioRpcProvider.decodeResponse: \(String.jsonString(jsonObject: response, writingOptions: [.sortedKeys, .prettyPrinted]) ?? "No Response")")
}
#endif
return Promise.value(resource)
} catch DecodingError.dataCorrupted(let context) {
let errorReason = "\(errorReasonPrefix) DataCorrupted: \(context.debugDescription)."
return makeFatalEosioErrorPromiseError(reason: errorReason)
} catch DecodingError.keyNotFound(let key, let context) {
let errorReason = "\(errorReasonPrefix) KeyNotFound: \(key.stringValue) \(context.debugDescription)."
return makeFatalEosioErrorPromiseError(reason: errorReason)
} catch DecodingError.typeMismatch(let type, let context) {
let errorReason = "\(errorReasonPrefix) TypeMismatch: \(type) was expected, \(context.debugDescription)."
return makeFatalEosioErrorPromiseError(reason: errorReason)
} catch DecodingError.valueNotFound(let type, let context) {
let errorReason = "\(errorReasonPrefix) ValueNotFound: no value was found for \(type), \(context.debugDescription)."
return makeFatalEosioErrorPromiseError(reason: errorReason)
} catch let error {
return makeFatalEosioErrorPromiseError(reason: errorReasonPrefix, originialError: error as NSError)
}
}
private func makeFatalEosioErrorPromiseError<T>(reason: String, originialError: NSError? = nil) -> Promise<T> {
let error = EosioError(.rpcProviderFatalError, reason: reason, originalError: originialError)
return Promise(error: error)
}
/// Creates an RPC request, makes the network call, and handles the response. Calls the callback when complete.
///
/// - Parameters:
/// - rpc: String representing endpoint path. E.g., `chain/get_account`.
/// - requestParameters: The request object.
/// - callback: Callback.
func getResource<T: Decodable & EosioRpcResponseProtocol>(rpc: String, requestParameters: Encodable?, callback: @escaping (T?, EosioError?) -> Void) {
getResource(.promise, rpc: rpc, requestParameters: requestParameters)
.done {
callback($0, nil)
}.catch { error in
var eosioError: EosioError
if let error = error as? EosioError {
eosioError = error
} else {
eosioError = EosioError(.rpcProviderError, reason: "Other error.", originalError: error as NSError)
}
callback(nil, eosioError)
}
}
}