├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── LICENSE ├── Package.swift ├── README.md └── Sources └── Alley ├── Alley.swift ├── NetworkError-Localized.swift ├── NetworkError-Retries.swift └── NetworkError.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Aleksandar Vacić 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Alley", 8 | platforms: [ 9 | .iOS(.v15), 10 | .tvOS(.v15), 11 | .watchOS(.v10), 12 | .macOS(.v12), 13 | .visionOS(.v1) 14 | ], 15 | products: [ 16 | .library( 17 | name: "Alley", 18 | targets: ["Alley"] 19 | ), 20 | ], 21 | targets: [ 22 | .target( 23 | name: "Alley", 24 | swiftSettings: [ 25 | .enableExperimentalFeature("StrictConcurrency") 26 | ] 27 | ) 28 | ], 29 | swiftLanguageModes: [.v6] 30 | ) 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![](https://img.shields.io/github/tag/radianttap/Alley.svg?label=current)](https://github.com/radianttap/Alley/releases) 2 | [![](https://img.shields.io/github/license/radianttap/Alley.svg)](https://github.com/radianttap/Alley/blob/master/LICENSE) 3 | ![](https://img.shields.io/badge/swift-6.0-223344.svg?logo=swift&labelColor=FA7343&logoColor=white) 4 | \ 5 | ![platforms: iOS|tvOS|watchOS|macOS|visionOS](https://img.shields.io/badge/platform-iOS_15_·_tvOS_15_·_watchOS_10_·_macOS_12_·_visionOS_1-blue.svg) 6 | 7 | # Alley 8 | 9 | Essential `URLSessionDataTask` micro-wrapper for communication with HTTP(S) web services. 10 | 11 | ## Why 12 | 13 | In most cases where you need to fetch something from the internet, you: 14 | 15 | 1. Want to get the data at the URL you are targeting, no matter what 16 | 2. In case when it’s simply not possible, display some useful error to the end-customer *and* display / log what error actually happened so you can troubleshoot and debug 17 | 18 | Second point is nice to have. First one is vastly more important since that data is the reason you are doing this at all. 19 | 20 | > Thus main feature of Alley is **automatic request retries** for predefined conditions. 21 | 22 | ## Integration 23 | 24 | Just drag `Alley` folder into your project. 25 | 26 | Or just add this repo’s URL through Swift Package Manager. 27 | 28 | - Version 2.x supports old school stuff with completion handlers. 29 | - Version 3.x is pure `async`/`await`. 30 | - Version 4.x has strict concurrency checking turned ON and Swift 6 language mode. 31 | 32 | ## Usage 33 | 34 | You would already have some `URLSession` instance to work with. Then instead of this: 35 | 36 | ```swift 37 | let urlRequest = URLRequest(...) 38 | 39 | do { 40 | let data = try await urlSession.data(for: urlRequest) 41 | } catch let err { 42 | //...process error 43 | } 44 | ``` 45 | 46 | with _Alley_ you will do this: 47 | 48 | ```swift 49 | let urlRequest = URLRequest(...) 50 | 51 | do { 52 | let data = try await urlSession.alleyData(for: urlRequest) 53 | } catch let err { 54 | //...process NetworkError 55 | } 56 | ``` 57 | 58 | In case the request was successful, you would get the `Data` instance returned from the service which you can convert into whatever you expected it to be. 59 | 60 | In case of failure you will get an instance of `NetworkError`. 61 | 62 | ### NetworkError 63 | 64 | This is custom Error (implemented by an enum) which – for starters – wraps stuff returned by `URLSessionDataTask`. Thus first few possible options are: 65 | 66 | ```swift 67 | /// `URLSession` errors are passed-through, handle as appropriate. 68 | case urlError(URLError) 69 | 70 | /// URLSession returned an `Error` object which is not `URLError` 71 | case generalError(Swift.Error) 72 | ``` 73 | 74 | Next, if the returned `URLResponse` is not `HTTPURLResponse`: 75 | 76 | ```swift 77 | case invalidResponseType(URLResponse) 78 | ``` 79 | 80 | Now, if it is `HTTPURLResponse` but status code is `400` or higher, this is an error returned by the web service endpoint you are communicating with. Hence you get the entire `HTTPURLResponse` and `Data` (if it exists) so caller can figure out what happened. 81 | 82 | ```swift 83 | case endpointError(HTTPURLResponse, Data?) 84 | ``` 85 | 86 | In the calling object, you can use these values and try to build instances of strongly-typed custom errors related to the given specific web service. 87 | 88 | If status code is in `2xx` range, you may have a case of missing response body. 89 | 90 | ```swift 91 | case noResponseData(HTTPURLResponse) 92 | ``` 93 | 94 | This may or may not be an error. If you perform `PUT` or `DELETE` or even `POST` requests, your service may not return any data as valid response (just `200 OK` or whatever). In that case, prevent this error by calling perform like this: 95 | 96 | ```swift 97 | let urlRequest = URLRequest(...) 98 | 99 | let data = try await urlSession.alleyData(for: urlRequest, allowEmptyData: true) 100 | ``` 101 | 102 | where you will get empty `Data()`. 103 | 104 | There’s one more possible `NetworkError` value, which is related to... 105 | 106 | ## Automatic retries 107 | 108 | Default number of retries is `10`. 109 | 110 | This value is automatically used for all networking calls but you can adjust it per call by simply supplying appropriate number to `maxRetries` argument: 111 | 112 | ```swift 113 | let urlRequest = URLRequest(...) 114 | 115 | let data = try await urlSession.alleyData(for: urlRequest, maxRetries: 5) 116 | ``` 117 | 118 | How automatic retries work? 119 | 120 | In case of a `NetworkError` being raised, _Alley_ will check its `shouldRetry` property and – if `true` – it will increment retry counter by 1 and perform `URLSessionDataTask` again. And again. And again...until it reaches `maxRetries` value when it will return `NetworkError.inaccessible` as result. 121 | 122 | Each retry is delayed by half a second but you can supply any value you want (including `0`) in the call to `alleyData`, argument `retryInterval`. 123 | 124 | ```swift 125 | let urlRequest = URLRequest(...) 126 | 127 | let data = try await urlSession.alleyData(for: urlRequest, retryInterval: 0.3) 128 | ``` 129 | 130 | You can customize the behavior by changing the implementation of `shouldRetry` property (in this case I recommend to manually copy Alley folder into your project). 131 | 132 | * * * 133 | 134 | That’s about it. _Alley_ is intentionally simple to encourage writing as little code as possible, hiding away often-repeated boilerplate. 135 | 136 | ## License 137 | 138 | [MIT License,](https://github.com/radianttap/Alley/blob/v2/LICENSE) like all my open source code. 139 | 140 | ## Give back 141 | 142 | If you found this code useful, please consider [buying me a coffee](https://www.buymeacoffee.com/radianttap) or two. ☕️😋 143 | -------------------------------------------------------------------------------- /Sources/Alley/Alley.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLSession-Extensions.swift 3 | // Alley 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import Foundation 10 | 11 | @available(macOS 12, iOS 15, watchOS 10.0, tvOS 15, visionOS 1, *) 12 | extension URLSession { 13 | /// Executes given `URLRequest` instance, possibly retrying the said number of times. Returns `Data` from the response or throws some `NetworkError` instance. 14 | /// 15 | /// If any authentication needs to be done, it's handled internally by this methods and its derivatives. 16 | /// 17 | /// - Parameters: 18 | /// - urlRequest: `URLRequest` instance to execute. 19 | /// - maxRetries: Number of automatic retries (default is 10). 20 | /// - allowEmptyData: Should empty response `Data` be treated as failure (this is default) even if no other errors are returned by `URLSession`. Default is `false`. 21 | public func alleyData(for urlRequest: URLRequest, maxRetries: Int = 10, retryInterval: TimeInterval = 0.5, allowEmptyData: Bool = false) async throws(NetworkError) -> Data { 22 | let networkRequest = RetriableRequest( 23 | urlRequest, 24 | 1, 25 | maxRetries, 26 | allowEmptyData 27 | ) 28 | 29 | return try await execute(networkRequest, retryInterval: retryInterval) 30 | } 31 | } 32 | 33 | @available(macOS 12, iOS 15, watchOS 10.0, tvOS 15, visionOS 1, *) 34 | private extension URLSession { 35 | 36 | typealias RetriableRequest = ( 37 | urlRequest: URLRequest, 38 | currentRetries: Int, 39 | maxRetries: Int, 40 | allowEmptyData: Bool 41 | ) 42 | 43 | /// 44 | func execute(_ networkRequest: RetriableRequest, retryInterval: TimeInterval) async throws(NetworkError) -> Data { 45 | let urlRequest = networkRequest.urlRequest 46 | 47 | do { 48 | let (data, urlResponse) = try await data(for: urlRequest) 49 | try verify(data, urlResponse, for: networkRequest, retryInterval: retryInterval) 50 | return data 51 | 52 | } catch let err as NetworkError { 53 | return try await retry(networkRequest, ifPossibleFor: err, retryInterval: retryInterval) 54 | 55 | } catch let err as URLError { 56 | return try await retry(networkRequest, ifPossibleFor: NetworkError.urlError(err), retryInterval: retryInterval) 57 | 58 | } catch let err { 59 | return try await retry(networkRequest, ifPossibleFor: NetworkError.generalError(err), retryInterval: retryInterval) 60 | } 61 | } 62 | 63 | /// 64 | func verify(_ data: Data, _ urlResponse: URLResponse, for networkRequest: RetriableRequest, retryInterval: TimeInterval) throws(NetworkError) { 65 | 66 | guard let httpURLResponse = urlResponse as? HTTPURLResponse else { 67 | throw NetworkError.invalidResponseType(urlResponse) 68 | } 69 | 70 | if httpURLResponse.statusCode >= 400 { 71 | throw NetworkError.endpointError(httpURLResponse, data) 72 | } 73 | 74 | if data.isEmpty, !networkRequest.allowEmptyData { 75 | throw NetworkError.noResponseData(httpURLResponse) 76 | } 77 | } 78 | 79 | /// 80 | func retry(_ networkRequest: RetriableRequest, ifPossibleFor err: NetworkError, retryInterval: TimeInterval) async throws(NetworkError) -> Data { 81 | guard err.shouldRetry else { 82 | throw err 83 | } 84 | 85 | // update retries count 86 | var newRequest = networkRequest 87 | newRequest.currentRetries += 1 88 | 89 | if newRequest.currentRetries >= newRequest.maxRetries { 90 | throw NetworkError.inaccessible 91 | } 92 | 93 | if retryInterval > 0 { 94 | do { 95 | try await Task.sleep(nanoseconds: UInt64(retryInterval * 1_000_000_000)) 96 | } catch { 97 | // if Task.sleep fails for whatever impossible reason, 98 | // then return our last NetworkError instance 99 | throw err 100 | } 101 | } 102 | 103 | return try await execute(newRequest, retryInterval: retryInterval) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Sources/Alley/NetworkError-Localized.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkError-Localized.swift 3 | // Alley 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import Foundation 10 | 11 | extension NetworkError: CustomStringConvertible { 12 | public var description: String { 13 | switch self { 14 | case .generalError(let error): 15 | return String(describing: error) 16 | 17 | case .urlError(let urlError): 18 | return String(describing: urlError) 19 | 20 | case .invalidResponseType: 21 | return "Unexpected response received or no response at all." 22 | 23 | case .noResponseData: 24 | return "Response body is empty." 25 | 26 | case .endpointError(let httpURLResponse, _): 27 | let s = "Web service network error: \( httpURLResponse.statusCode ) \( HTTPURLResponse.localizedString(forStatusCode: httpURLResponse.statusCode) )" 28 | return s 29 | 30 | case .inaccessible: 31 | return "Service not accessible" 32 | } 33 | } 34 | } 35 | 36 | extension NetworkError: CustomDebugStringConvertible { 37 | public var debugDescription: String { 38 | switch self { 39 | case .generalError(let error): 40 | return String(reflecting: error) 41 | 42 | case .urlError(let urlError): 43 | return String(reflecting: urlError) 44 | 45 | case .invalidResponseType(let response): 46 | return "Unexpected response type (not HTTP)\n\( String(reflecting: response) )" 47 | 48 | case .noResponseData: 49 | return "Response body is empty." 50 | 51 | case .endpointError(let httpURLResponse, let data): 52 | return "\( httpURLResponse.formattedHeaders )\n\n\( data?.utf8StringRepresentation ?? "" )" 53 | 54 | case .inaccessible: 55 | return "Service not accessible" 56 | } 57 | } 58 | } 59 | 60 | extension NetworkError: LocalizedError { 61 | public var errorDescription: String? { 62 | switch self { 63 | case .generalError(let error): 64 | return error.localizedDescription 65 | 66 | case .urlError(let urlError): 67 | return urlError.localizedDescription 68 | 69 | case .invalidResponseType: 70 | return NSLocalizedString("Internal error", comment: "") 71 | 72 | case .noResponseData: 73 | return NSLocalizedString("Response body is empty.", comment: "") 74 | 75 | case .endpointError(let httpURLResponse, _): 76 | let s = "\( httpURLResponse.statusCode ) \( HTTPURLResponse.localizedString(forStatusCode: httpURLResponse.statusCode) )" 77 | return s 78 | 79 | case .inaccessible: 80 | return NSLocalizedString("Service is not accessible", comment: "") 81 | } 82 | } 83 | 84 | public var failureReason: String? { 85 | switch self { 86 | case .generalError(let error): 87 | return (error as NSError).localizedFailureReason 88 | 89 | case .urlError(let urlError): 90 | return (urlError as NSError).localizedFailureReason 91 | 92 | case .invalidResponseType(let response): 93 | return String(format: NSLocalizedString("Response is not HTTP response.\n\n%@", comment: ""), response) 94 | 95 | case .inaccessible: 96 | return nil 97 | 98 | case .noResponseData: 99 | return NSLocalizedString("Request succeeded, no response body received", comment: "") 100 | 101 | case .endpointError(let httpURLResponse, let data): 102 | let s = "\( httpURLResponse.formattedHeaders )\n\n\( data?.utf8StringRepresentation ?? "" )" 103 | return s 104 | } 105 | } 106 | } 107 | 108 | private extension HTTPURLResponse { 109 | var formattedHeaders: String { 110 | return allHeaderFields.map { "\( $0.key ) : \( $0.value )" }.joined(separator: "\n") 111 | } 112 | } 113 | 114 | private extension Data { 115 | var utf8StringRepresentation: String? { 116 | guard 117 | let str = String(data: self, encoding: .utf8) 118 | else { return nil } 119 | 120 | return str 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Sources/Alley/NetworkError-Retries.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkError-Retries.swift 3 | // Alley 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import Foundation 10 | 11 | public extension NetworkError { 12 | /// Returns `true` if `URLRequest` should be retried for the given `NetworkError` instance. 13 | /// 14 | /// At the lowest network levels, it makes sense to retry for cases of (possible) temporary outage. Things like timeouts, can't connect to host, network connection lost. 15 | /// In mobile context, this can happen as you move through the building or traffic and may not represent serious or more permanent connection issues. 16 | /// 17 | /// Upper layers of the app architecture may build on this to add more specific cases when the request should be retried. 18 | var shouldRetry: Bool { 19 | switch self { 20 | case .urlError(let urlError): 21 | // if temporary network issues, retry 22 | switch urlError.code { 23 | case .timedOut, 24 | .cannotFindHost, 25 | .cannotConnectToHost, 26 | .networkConnectionLost, 27 | .dnsLookupFailed, 28 | .notConnectedToInternet: 29 | return true 30 | 31 | default: 32 | break 33 | } 34 | 35 | case .endpointError(let httpURLResponse, _): 36 | switch httpURLResponse.statusCode { 37 | case 408, // Request Timeout 38 | 444, // Connection Closed Without Response 39 | 503, // Service Unavailable 40 | 504, // Gateway Timeout 41 | 599: // Network Connect Timeout Error 42 | return true 43 | 44 | default: 45 | break 46 | } 47 | 48 | default: 49 | break 50 | } 51 | 52 | return false 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/Alley/NetworkError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkError.swift 3 | // Alley 4 | // 5 | // Copyright © 2019 Radiant Tap 6 | // MIT License · http://choosealicense.com/licenses/mit/ 7 | // 8 | 9 | import Foundation 10 | 11 | /** 12 | Declaration of errors that Alley can throw/return. 13 | 14 | Since this is all about networking, it should pass-through any `URLError`s that happen but also add its own 15 | */ 16 | public enum NetworkError: Error { 17 | /// When network conditions are so bad that after `maxRetries` the request did not succeed. 18 | case inaccessible 19 | 20 | /// `URLSession` errors are passed-through, handle as appropriate. 21 | case urlError(URLError) 22 | 23 | /// URLSession returned an `Error` object which is not `URLError` 24 | case generalError(Swift.Error) 25 | 26 | /// When `URLResponse` is not `HTTPURLResponse`. 27 | case invalidResponseType(URLResponse) 28 | 29 | /// Status code is in `200...299` range, but response body is empty. This can be both valid and invalid, depending on HTTP method and/or specific behavior of the service being called. 30 | case noResponseData(HTTPURLResponse) 31 | 32 | /// Status code is `400` or higher thus return the entire `HTTPURLResponse` and `Data` so caller can figure out what happened. 33 | case endpointError(HTTPURLResponse, Data?) 34 | } 35 | --------------------------------------------------------------------------------