// // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation // This file contains common interfaces for dealing with // HTTP request responses, failures and errors in a consistent // way without concern for whether the request is made via // // * REST (e.g. AFNetworking, OWSURLSession, URLSession, etc.). // * a Websocket (e.g. OWSChatConnection). // A common protocol for responses from OWSUrlSession, NetworkManager, ChatConnectionManager, etc. @objc public protocol HTTPResponse { var requestUrl: URL { get } var responseStatusCode: Int { get } var responseHeaders: [String: String] { get } var responseBodyData: Data? { get } var responseBodyJson: Any? { get } var responseBodyString: String? { get } } // MARK: - // A common protocol for errors from OWSUrlSession, NetworkManager, ChatConnectionManager, etc. public protocol HTTPError { var requestUrl: URL? { get } // status is zero by default, if request never made or failed. var responseStatusCode: Int { get } var responseHeaders: OWSHttpHeaders? { get } // TODO: Can we eventually eliminate responseError? var responseError: Error? { get } var responseBodyData: Data? { get } var customRetryAfterDate: Date? { get } var isNetworkConnectivityError: Bool { get } } // MARK: - public struct HTTPErrorServiceResponse { let requestUrl: URL let responseStatus: Int let responseHeaders: OWSHttpHeaders let responseError: Error? let responseData: Data? let customRetryAfterDate: Date? let customLocalizedDescription: String? let customLocalizedRecoverySuggestion: String? } // MARK: - public enum OWSHTTPError: Error, CustomDebugStringConvertible, IsRetryableProvider, UserErrorDescriptionProvider { case missingRequest case invalidAppState(requestUrl: URL) case invalidRequest(requestUrl: URL) case invalidResponse(requestUrl: URL) // Request failed without a response from the service. case networkFailure(requestUrl: URL) // Request failed with a response from the service. case serviceResponse(serviceResponse: HTTPErrorServiceResponse) // The first 5 parameters are required (even if nil). // The custom parameters are optional. public static func forServiceResponse(requestUrl: URL, responseStatus: Int, responseHeaders: OWSHttpHeaders, responseError: Error?, responseData: Data?, customRetryAfterDate: Date? = nil, customLocalizedDescription: String? = nil, customLocalizedRecoverySuggestion: String? = nil) -> OWSHTTPError { let serviceResponse = HTTPErrorServiceResponse(requestUrl: requestUrl, responseStatus: responseStatus, responseHeaders: responseHeaders, responseError: responseError, responseData: responseData, customRetryAfterDate: customRetryAfterDate, customLocalizedDescription: customLocalizedDescription, customLocalizedRecoverySuggestion: customLocalizedRecoverySuggestion) return .serviceResponse(serviceResponse: serviceResponse) } // NSError bridging: the domain of the error. public static var errorDomain: String { return "OWSHTTPError" } // NSError bridging: the error code within the given domain. public var errorUserInfo: [String: Any] { var result = [String: Any]() result[NSUnderlyingErrorKey] = responseError result[NSLocalizedDescriptionKey] = localizedDescription result[NSLocalizedRecoverySuggestionErrorKey] = customLocalizedRecoverySuggestion return result } public var localizedDescription: String { if let customLocalizedDescription { return customLocalizedDescription } switch self { case .missingRequest, .invalidAppState, .invalidRequest, .networkFailure: return OWSLocalizedString("ERROR_DESCRIPTION_REQUEST_FAILED", comment: "Error indicating that a socket request failed.") case .invalidResponse, .serviceResponse: return OWSLocalizedString("ERROR_DESCRIPTION_RESPONSE_FAILED", comment: "Error indicating that a socket response failed.") } } public var debugDescription: String { switch self { case .missingRequest: return "missingRequest" case .invalidAppState(let requestUrl): return "invalidAppState: \(requestUrl.absoluteString)" case .invalidRequest(let requestUrl): return "invalidRequest: \(requestUrl.absoluteString)" case .invalidResponse(let requestUrl): return "invalidResponse: \(requestUrl.absoluteString)" case .networkFailure(let requestUrl): return "networkFailure: \(requestUrl.absoluteString)" case .serviceResponse(let serviceResponse): return "HTTP \(serviceResponse.responseStatus); \(serviceResponse.responseHeaders); \(serviceResponse.requestUrl.absoluteString); \(String(describing: serviceResponse.responseError))" } } // MARK: - IsRetryableProvider public var isRetryableProvider: Bool { if isNetworkConnectivityError { return true } switch self { case .missingRequest, .invalidAppState, .invalidRequest: return false case .invalidResponse: return true case .networkFailure: return true case .serviceResponse: // TODO: We might eventually special-case 413 Rate Limited errors. let responseStatus = self.responseStatusCode // We retry 5xx. if responseStatus >= 400, responseStatus <= 499 { return false } else { return true } } } } // MARK: - extension OWSHTTPError: HTTPError { public var requestUrl: URL? { switch self { case .missingRequest: return nil case .invalidAppState(let requestUrl): return requestUrl case .invalidRequest(let requestUrl): return requestUrl case .invalidResponse(let requestUrl): return requestUrl case .networkFailure(let requestUrl): return requestUrl case .serviceResponse(let serviceResponse): return serviceResponse.requestUrl } } // NOTE: This function should only be called from NetworkManager.swiftHTTPStatusCodeForError. public var responseStatusCode: Int { switch self { case .missingRequest, .invalidAppState, .invalidRequest, .invalidResponse, .networkFailure: return 0 case .serviceResponse(let serviceResponse): return Int(serviceResponse.responseStatus) } } public var responseHeaders: OWSHttpHeaders? { switch self { case .missingRequest, .invalidAppState, .invalidRequest, .invalidResponse, .networkFailure: return nil case .serviceResponse(let serviceResponse): return serviceResponse.responseHeaders } } public var responseError: Error? { switch self { case .missingRequest, .invalidAppState, .invalidRequest, .invalidResponse, .networkFailure: return nil case .serviceResponse(let serviceResponse): return serviceResponse.responseError } } public var responseBodyData: Data? { switch self { case .missingRequest, .invalidAppState, .invalidRequest, .invalidResponse, .networkFailure: return nil case .serviceResponse(let serviceResponse): return serviceResponse.responseData } } public var customRetryAfterDate: Date? { if let responseHeaders = self.responseHeaders, let retryAfterDate = responseHeaders.retryAfterDate { return retryAfterDate } switch self { case .missingRequest, .invalidAppState, .invalidRequest, .invalidResponse, .networkFailure: return nil case .serviceResponse(let serviceResponse): return serviceResponse.customRetryAfterDate } } fileprivate var customLocalizedDescription: String? { switch self { case .missingRequest, .invalidAppState, .invalidRequest, .invalidResponse, .networkFailure: return nil case .serviceResponse(let serviceResponse): return serviceResponse.customLocalizedDescription } } fileprivate var customLocalizedRecoverySuggestion: String? { switch self { case .missingRequest, .invalidAppState, .invalidRequest, .invalidResponse, .networkFailure: return nil case .serviceResponse(let serviceResponse): return serviceResponse.customLocalizedRecoverySuggestion } } // NOTE: This function should only be called from NetworkManager.isSwiftNetworkConnectivityError. public var isNetworkConnectivityError: Bool { switch self { case .missingRequest, .invalidAppState, .invalidRequest, .invalidResponse: return false case .networkFailure: return true case .serviceResponse: if 0 == self.responseStatusCode { // statusCode should now be nil, not zero, in this // case, but there might be some legacy code that is // still using zero. owsFailDebug("Unexpected status code.") return true } if let responseError = responseError { return responseError.isNetworkFailureOrTimeout } return false } } } // MARK: - @objc public class HTTPResponseImpl: NSObject { @objc public let requestUrl: URL @objc public let status: Int public let headers: OWSHttpHeaders @objc public let bodyData: Data? public let stringEncoding: String.Encoding private struct JSONValue { let json: Any? } // This property should only be accessed with unfairLock acquired. private var jsonValue: JSONValue? private static let unfairLock = UnfairLock() public init(requestUrl: URL, status: Int, headers: OWSHttpHeaders, bodyData: Data?, stringEncoding: String.Encoding = .utf8) { self.requestUrl = requestUrl self.status = status self.headers = headers self.bodyData = bodyData self.stringEncoding = stringEncoding } public static func build(requestUrl: URL, httpUrlResponse: HTTPURLResponse, bodyData: Data?) -> HTTPResponse { let headers = OWSHttpHeaders(response: httpUrlResponse) let stringEncoding: String.Encoding = httpUrlResponse.parseStringEncoding() ?? .utf8 return HTTPResponseImpl(requestUrl: requestUrl, status: httpUrlResponse.statusCode, headers: headers, bodyData: bodyData, stringEncoding: stringEncoding) } @objc public var bodyJson: Any? { Self.unfairLock.withLock { if let jsonValue = self.jsonValue { return jsonValue.json } let jsonValue = Self.parseJSON(data: bodyData) self.jsonValue = jsonValue return jsonValue.json } } private static func parseJSON(data: Data?) -> JSONValue { guard let data = data, !data.isEmpty else { return JSONValue(json: nil) } do { let json = try JSONSerialization.jsonObject(with: data, options: []) return JSONValue(json: json) } catch { owsFailDebug("Could not parse JSON: \(error).") return JSONValue(json: nil) } } } // MARK: - extension HTTPResponseImpl: HTTPResponse { @objc public var responseStatusCode: Int { Int(status) } @objc public var responseHeaders: [String: String] { headers.headers } @objc public var responseBodyData: Data? { bodyData } @objc public var responseBodyJson: Any? { bodyJson } @objc public var responseBodyString: String? { guard let data = bodyData, let string = String(data: data, encoding: stringEncoding) else { Logger.warn("Invalid body string.") return nil } return string } } // MARK: - extension HTTPURLResponse { fileprivate func parseStringEncoding() -> String.Encoding? { guard let encodingName = textEncodingName else { return nil } let encoding = CFStringConvertIANACharSetNameToEncoding(encodingName as CFString) guard encoding != kCFStringEncodingInvalidId else { return nil } return String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(encoding)) } } // MARK: - // Temporary obj-c wrapper for OWSHTTPError until // OWSChatConnection, etc. have been ported to Swift. @objc public class OWSHTTPErrorWrapper: NSObject { public let error: OWSHTTPError @objc public var asNSError: NSError { error as NSError } public init(error: OWSHTTPError) { self.error = error } }