Skip to content

Commit f99770d

Browse files
Merge pull request #15 from checkout/feature/granular-error-handling
Map URLSession errors into CheckoutNetworkError
2 parents b7aa490 + 0a34c34 commit f99770d

File tree

9 files changed

+178
-80
lines changed

9 files changed

+178
-80
lines changed

.github/workflows/codeql-analysis.yml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ concurrency:
2222
jobs:
2323
analyze:
2424
name: Analyze
25-
runs-on: [ macos-latest ]
25+
runs-on: macos-14-xlarge
2626
permissions:
2727
actions: read
2828
contents: read
@@ -39,6 +39,14 @@ jobs:
3939
with:
4040
submodules: recursive
4141

42+
- name: Select Xcode
43+
run: |
44+
sudo xcode-select -switch /Applications/Xcode_15.2.app
45+
46+
- name: Log xcodebuild Version
47+
run: |
48+
xcodebuild -version
49+
4250
# Initializes the CodeQL tools for scanning.
4351
- name: Initialize CodeQL
4452
uses: github/codeql-action/init@v2
@@ -48,7 +56,7 @@ jobs:
4856

4957
- name: Build
5058
run: |
51-
xcodebuild -scheme CheckoutNetwork -destination "platform=iOS Simulator,name=iPhone 14 Pro,OS=latest"
59+
xcodebuild -scheme CheckoutNetwork -destination "platform=iOS Simulator,name=iPhone 15 Pro,OS=latest"
5260
5361
- name: Perform CodeQL Analysis
5462
uses: github/codeql-action/analyze@v2

.github/workflows/verify-pr.yml

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,25 @@ jobs:
2525

2626
verify-pr:
2727
name: Verify PR
28-
runs-on: macos-latest
28+
runs-on: macos-14-xlarge
2929
needs: lint
3030

3131
steps:
3232
- name: Checkout repository
3333
uses: actions/checkout@v3
3434

35+
- name: Select Xcode
36+
run: |
37+
sudo xcode-select -switch /Applications/Xcode_15.2.app
38+
39+
- name: Log xcodebuild Version
40+
run: |
41+
xcodebuild -version
42+
3543
- name: Build the Package
3644
run: |
37-
set -o pipefail && xcodebuild -scheme CheckoutNetwork -destination "platform=iOS Simulator,name=iPhone 14 Pro,OS=latest"
45+
set -o pipefail && xcodebuild -scheme CheckoutNetwork -destination "platform=iOS Simulator,name=iPhone 15 Pro,OS=latest"
3846
3947
- name: Run Tests
4048
run: |
41-
set -o pipefail && xcodebuild -scheme CheckoutNetwork -destination "platform=iOS Simulator,name=iPhone 14 Pro,OS=latest" test
49+
set -o pipefail && xcodebuild -scheme CheckoutNetwork -destination "platform=iOS Simulator,name=iPhone 15 Pro,OS=latest" test

Sources/CheckoutNetwork/CheckoutClientInterface.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ import Foundation
99

1010
/// Interface for a network client that can support Checkout networking requirements
1111
public protocol CheckoutClientInterface {
12-
13-
// MARK: Type Assignments
1412

1513
/// Completion handler that will return a result containing a decodable object or an error
1614
typealias CompletionHandler<T> = ((Result<T, Error>) -> Void)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
//
2+
// CheckoutNetworkClient+AsyncWrappers.swift
3+
//
4+
//
5+
// Created by Okhan Okbay on 20/02/2024.
6+
//
7+
8+
import Foundation
9+
10+
public extension CheckoutNetworkClient {
11+
12+
func runRequest<T: Decodable>(with configuration: RequestConfiguration) async throws -> T {
13+
return try await withCheckedThrowingContinuation { continuation in
14+
runRequest(with: configuration) { (result: Result<T, Error>) in
15+
switch result {
16+
case .success(let response):
17+
continuation.resume(returning: response)
18+
case .failure(let error):
19+
continuation.resume(throwing: error)
20+
}
21+
}
22+
}
23+
}
24+
25+
func runRequest(with configuration: RequestConfiguration) async throws {
26+
return try await withCheckedThrowingContinuation { continuation in
27+
runRequest(with: configuration) { (error: Error?) in
28+
29+
guard let error = error else {
30+
continuation.resume(returning: Void())
31+
return
32+
}
33+
34+
continuation.resume(throwing: error)
35+
}
36+
}
37+
}
38+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//
2+
// CheckoutNetworkClient+ErrorHandling.swift
3+
//
4+
//
5+
// Created by Okhan Okbay on 20/02/2024.
6+
//
7+
8+
import Foundation
9+
10+
extension CheckoutNetworkClient {
11+
func getErrorFromResponse(_ response: URLResponse?, data: Data?) -> Error? {
12+
guard let response = response as? HTTPURLResponse else {
13+
return CheckoutNetworkError.invalidURLResponse
14+
}
15+
16+
guard response.statusCode != 422 else {
17+
do {
18+
let errorReason = try JSONDecoder().decode(ErrorReason.self, from: data ?? Data())
19+
return CheckoutNetworkError.unprocessableContent(reason: errorReason)
20+
} catch {
21+
return CheckoutNetworkError.noDataResponseReceived
22+
}
23+
}
24+
25+
guard (200..<300).contains(response.statusCode) else {
26+
return CheckoutNetworkError.unexpectedHTTPResponse(code: response.statusCode)
27+
}
28+
return nil
29+
}
30+
31+
func convertDataTaskErrorsToCheckoutNetworkError(error: Error) -> CheckoutNetworkError {
32+
let error = error as NSError
33+
34+
switch error.code {
35+
case NSURLErrorNotConnectedToInternet,
36+
NSURLErrorTimedOut,
37+
NSURLErrorNetworkConnectionLost,
38+
NSURLErrorInternationalRoamingOff,
39+
NSURLErrorCannotConnectToHost,
40+
NSURLErrorServerCertificateUntrusted:
41+
42+
return .connectivity
43+
44+
default:
45+
return .other(underlyingError: error)
46+
}
47+
}
48+
}

Sources/CheckoutNetwork/CheckoutNetworkClient.swift

Lines changed: 1 addition & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public class CheckoutNetworkClient: CheckoutClientInterface {
3232
self?.tasks.removeValue(forKey: taskID)
3333
guard let self = self else { return }
3434
if let error = error {
35-
completionHandler(.failure(error))
35+
completionHandler(.failure(convertDataTaskErrorsToCheckoutNetworkError(error: error)))
3636
return
3737
}
3838

@@ -88,56 +88,4 @@ public class CheckoutNetworkClient: CheckoutClientInterface {
8888
}
8989
return taskID
9090
}
91-
92-
private func getErrorFromResponse(_ response: URLResponse?, data: Data?) -> Error? {
93-
guard let response = response as? HTTPURLResponse else {
94-
return CheckoutNetworkError.unexpectedResponseCode(code: 0)
95-
}
96-
97-
guard response.statusCode != 422 else {
98-
do {
99-
let errorReason = try JSONDecoder().decode(ErrorReason.self, from: data ?? Data())
100-
return CheckoutNetworkError.invalidData(reason: errorReason)
101-
} catch {
102-
return CheckoutNetworkError.noDataResponseReceived
103-
}
104-
}
105-
106-
guard response.statusCode >= 200,
107-
response.statusCode < 300 else {
108-
return CheckoutNetworkError.unexpectedResponseCode(code: response.statusCode)
109-
}
110-
return nil
111-
}
112-
}
113-
114-
// MARK: Async Wrappers
115-
public extension CheckoutNetworkClient {
116-
117-
func runRequest<T: Decodable>(with configuration: RequestConfiguration) async throws -> T {
118-
return try await withCheckedThrowingContinuation { continuation in
119-
runRequest(with: configuration) { (result: Result<T, Error>) in
120-
switch result {
121-
case .success(let response):
122-
continuation.resume(returning: response)
123-
case .failure(let error):
124-
continuation.resume(throwing: error)
125-
}
126-
}
127-
}
128-
}
129-
130-
func runRequest(with configuration: RequestConfiguration) async throws {
131-
return try await withCheckedThrowingContinuation { continuation in
132-
runRequest(with: configuration) { (error: Error?) in
133-
134-
guard let error = error else {
135-
continuation.resume(returning: Void())
136-
return
137-
}
138-
139-
continuation.resume(throwing: error)
140-
}
141-
}
142-
}
14391
}
Lines changed: 58 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,67 @@
11
//
22
// CheckoutNetworkError.swift
3-
//
3+
//
44
//
55
// Created by Alex Ioja-Yang on 20/06/2022.
66
//
77

88
import Foundation
99

10-
public enum CheckoutNetworkError: Error, Equatable {
11-
12-
/// Review the url you provided
13-
case invalidURL
14-
15-
/// Network response was not in the 200 range
16-
case unexpectedResponseCode(code: Int)
17-
18-
/// Network call and completion appear valid but no data was returned making the parsing impossible. Use runRequest method with NoDataResponseCompletionHandler if no data is expected (HTTP 204 is a success case with no content being returned)
19-
case noDataResponseReceived
20-
21-
/// Network response returned with HTTP Code 422
22-
case invalidData(reason: ErrorReason)
10+
public enum CheckoutNetworkError: LocalizedError, Equatable {
11+
12+
/// Review the url you provided
13+
case invalidURL
14+
15+
/// When a response could not be bound to an instance of HTTPURLResponse
16+
case invalidURLResponse
17+
18+
/// Network response was not in the 200 range
19+
case unexpectedHTTPResponse(code: Int)
20+
21+
/// Network call and completion appear valid but no data was returned making the parsing impossible. Use runRequest method with NoDataResponseCompletionHandler if no data is expected (HTTP 204 is a success case with no content being returned)
22+
case noDataResponseReceived
23+
24+
/// Network response returned with HTTP Code 422
25+
case unprocessableContent(reason: ErrorReason)
26+
27+
/// Connectivity errors mapped from URLSession.dataTask()'s error
28+
///
29+
/// Only the following are mapped into this error case:
30+
/// NSURLErrorNotConnectedToInternet
31+
/// NSURLErrorTimedOut
32+
/// NSURLErrorNetworkConnectionLost
33+
/// NSURLErrorInternationalRoamingOff
34+
/// NSURLErrorCannotConnectToHost
35+
/// NSURLErrorServerCertificateUntrusted
36+
case connectivity
37+
38+
/// All the other errors that can be received from URLSession.
39+
/// Use the underlying error if you need more granular error handling.
40+
/// Underlying errors here are in NSURLErrorDomain.
41+
case other(underlyingError: NSError)
42+
43+
public var errorDescription: String? {
44+
switch self {
45+
case .invalidURL:
46+
return "Could not instantiate a URL with the provided String value"
47+
48+
case .invalidURLResponse:
49+
return "Could not instantiate an HTTPURLResponse with the received response value"
50+
51+
case .unexpectedHTTPResponse(let code):
52+
return "Received an unexpected HTTP response: \(code)"
53+
54+
case .noDataResponseReceived:
55+
return "No data is received in the response body. Use the method with NoDataResponseCompletionHandler if no data was expected"
56+
57+
case .unprocessableContent(let reason):
58+
return "HTTP response 422 is received with: \(reason)"
59+
60+
case .connectivity:
61+
return "There is a problem with the internet connection"
62+
63+
case .other(let error):
64+
return "An unhandled error is produced: \(error)"
65+
}
66+
}
2367
}

Sources/CheckoutNetwork/Models/ErrorReason.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,9 @@ public struct ErrorReason: Decodable, Equatable {
1818
case errorCodes = "error_codes"
1919
}
2020
}
21+
22+
extension ErrorReason: CustomStringConvertible {
23+
public var description: String {
24+
"\(requestID) \(errorType) \(errorCodes.joined(separator: ", "))"
25+
}
26+
}

Tests/CheckoutNetworkTests/CheckoutNetworkClientTests.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,15 @@ final class CheckoutNetworkClientTests: XCTestCase {
6262
let expectedData = "nothing".data(using: .utf8)
6363
let expectedResponse = URLResponse()
6464
let expectedError = NSError(domain: "fail", code: 12345)
65-
65+
6666
let expect = expectation(description: "Ensure completion handler is called")
6767
client.runRequest(with: testConfig) { (result: Result<FakeObject, Error>) in
6868
expect.fulfill()
6969
switch result {
7070
case .success(_):
7171
XCTFail("Test expects a specific error to be returned")
7272
case .failure(let failure):
73-
XCTAssertEqual(failure as NSError, expectedError)
73+
XCTAssertEqual(failure as? CheckoutNetworkError, CheckoutNetworkError.other(underlyingError: expectedError))
7474
}
7575
}
7676

@@ -100,7 +100,7 @@ final class CheckoutNetworkClientTests: XCTestCase {
100100
case .success(_):
101101
XCTFail("Test expects a specific error to be returned")
102102
case .failure(let failure):
103-
XCTAssertEqual(failure as? CheckoutNetworkError, CheckoutNetworkError.unexpectedResponseCode(code: testResponseCode))
103+
XCTAssertEqual(failure as? CheckoutNetworkError, CheckoutNetworkError.unexpectedHTTPResponse(code: testResponseCode))
104104
}
105105
}
106106

@@ -126,7 +126,7 @@ final class CheckoutNetworkClientTests: XCTestCase {
126126
case .success(_):
127127
XCTFail("Test expects a specific error to be returned")
128128
case .failure(let failure):
129-
XCTAssertEqual(failure as? CheckoutNetworkError, CheckoutNetworkError.unexpectedResponseCode(code: 0))
129+
XCTAssertEqual(failure as? CheckoutNetworkError, CheckoutNetworkError.invalidURLResponse)
130130
}
131131
}
132132

@@ -297,7 +297,7 @@ final class CheckoutNetworkClientTests: XCTestCase {
297297
let expect = expectation(description: "Ensure completion handler is called")
298298
client.runRequest(with: testConfig) {
299299
expect.fulfill()
300-
XCTAssertEqual($0 as? CheckoutNetworkError, CheckoutNetworkError.unexpectedResponseCode(code: testResponseCode))
300+
XCTAssertEqual($0 as? CheckoutNetworkError, CheckoutNetworkError.unexpectedHTTPResponse(code: testResponseCode))
301301
}
302302

303303
XCTAssertFalse(client.tasks.isEmpty)
@@ -318,7 +318,7 @@ final class CheckoutNetworkClientTests: XCTestCase {
318318
let expect = expectation(description: "Ensure completion handler is called")
319319
client.runRequest(with: testConfig) {
320320
expect.fulfill()
321-
XCTAssertEqual($0 as? CheckoutNetworkError, CheckoutNetworkError.unexpectedResponseCode(code: 0))
321+
XCTAssertEqual($0 as? CheckoutNetworkError, CheckoutNetworkError.invalidURLResponse)
322322
}
323323

324324
XCTAssertFalse(client.tasks.isEmpty)

0 commit comments

Comments
 (0)