// // 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() /// 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 // } // private func retry(_ body: @escaping () -> Promise) -> Promise { var attempts = 0 func attempt() -> Promise { attempts += 1 return body().recover { error -> Promise 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(_: PMKNamespacer, rpc: String, requestParameters: Encodable?) -> Promise { /* 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 // } // // This particular implementation is typing the return promise to a Decodable & EosioRpcResponseProtocol object. private func runWithFailover(_ body: @escaping () -> Promise) -> Promise { func failOver() -> Promise { return body().recover { error -> Promise 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(rpc: String, requestParameters: Encodable?) -> Promise { // This promise var is used for the return of Promise expected in this function. var promise: Promise promise = captureChainId(rpc: rpc).then { (response: EosioRpcInfoResponse) -> Promise 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 { var promise: Promise 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 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(rpc: String, requestParameters: Encodable?) -> Promise { return retry { self.runRequest(rpc: rpc, requestParameters: requestParameters) } } private func runRequest(rpc: String, requestParameters: Encodable?) -> Promise { 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 { 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(data: Data) -> Promise { 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(reason: String, originialError: NSError? = nil) -> Promise { 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(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) } } }