Skip to content

Commit

Permalink
Merge pull request #15 from checkout/feature/granular-error-handling
Browse files Browse the repository at this point in the history
Map URLSession errors into CheckoutNetworkError
  • Loading branch information
okhan-okbay-cko authored Feb 21, 2024
2 parents b7aa490 + 0a34c34 commit f99770d
Show file tree
Hide file tree
Showing 9 changed files with 178 additions and 80 deletions.
12 changes: 10 additions & 2 deletions .github/workflows/codeql-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ concurrency:
jobs:
analyze:
name: Analyze
runs-on: [ macos-latest ]
runs-on: macos-14-xlarge
permissions:
actions: read
contents: read
Expand All @@ -39,6 +39,14 @@ jobs:
with:
submodules: recursive

- name: Select Xcode
run: |
sudo xcode-select -switch /Applications/Xcode_15.2.app
- name: Log xcodebuild Version
run: |
xcodebuild -version
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
Expand All @@ -48,7 +56,7 @@ jobs:

- name: Build
run: |
xcodebuild -scheme CheckoutNetwork -destination "platform=iOS Simulator,name=iPhone 14 Pro,OS=latest"
xcodebuild -scheme CheckoutNetwork -destination "platform=iOS Simulator,name=iPhone 15 Pro,OS=latest"
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
14 changes: 11 additions & 3 deletions .github/workflows/verify-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,25 @@ jobs:

verify-pr:
name: Verify PR
runs-on: macos-latest
runs-on: macos-14-xlarge
needs: lint

steps:
- name: Checkout repository
uses: actions/checkout@v3

- name: Select Xcode
run: |
sudo xcode-select -switch /Applications/Xcode_15.2.app
- name: Log xcodebuild Version
run: |
xcodebuild -version
- name: Build the Package
run: |
set -o pipefail && xcodebuild -scheme CheckoutNetwork -destination "platform=iOS Simulator,name=iPhone 14 Pro,OS=latest"
set -o pipefail && xcodebuild -scheme CheckoutNetwork -destination "platform=iOS Simulator,name=iPhone 15 Pro,OS=latest"
- name: Run Tests
run: |
set -o pipefail && xcodebuild -scheme CheckoutNetwork -destination "platform=iOS Simulator,name=iPhone 14 Pro,OS=latest" test
set -o pipefail && xcodebuild -scheme CheckoutNetwork -destination "platform=iOS Simulator,name=iPhone 15 Pro,OS=latest" test
2 changes: 0 additions & 2 deletions Sources/CheckoutNetwork/CheckoutClientInterface.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import Foundation

/// Interface for a network client that can support Checkout networking requirements
public protocol CheckoutClientInterface {

// MARK: Type Assignments

/// Completion handler that will return a result containing a decodable object or an error
typealias CompletionHandler<T> = ((Result<T, Error>) -> Void)
Expand Down
38 changes: 38 additions & 0 deletions Sources/CheckoutNetwork/CheckoutNetworkClient+AsyncWrappers.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// CheckoutNetworkClient+AsyncWrappers.swift
//
//
// Created by Okhan Okbay on 20/02/2024.
//

import Foundation

public extension CheckoutNetworkClient {

func runRequest<T: Decodable>(with configuration: RequestConfiguration) async throws -> T {
return try await withCheckedThrowingContinuation { continuation in
runRequest(with: configuration) { (result: Result<T, Error>) in
switch result {
case .success(let response):
continuation.resume(returning: response)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}

func runRequest(with configuration: RequestConfiguration) async throws {
return try await withCheckedThrowingContinuation { continuation in
runRequest(with: configuration) { (error: Error?) in

guard let error = error else {
continuation.resume(returning: Void())
return
}

continuation.resume(throwing: error)
}
}
}
}
48 changes: 48 additions & 0 deletions Sources/CheckoutNetwork/CheckoutNetworkClient+ErrorHandling.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//
// CheckoutNetworkClient+ErrorHandling.swift
//
//
// Created by Okhan Okbay on 20/02/2024.
//

import Foundation

extension CheckoutNetworkClient {
func getErrorFromResponse(_ response: URLResponse?, data: Data?) -> Error? {
guard let response = response as? HTTPURLResponse else {
return CheckoutNetworkError.invalidURLResponse
}

guard response.statusCode != 422 else {
do {
let errorReason = try JSONDecoder().decode(ErrorReason.self, from: data ?? Data())
return CheckoutNetworkError.unprocessableContent(reason: errorReason)
} catch {
return CheckoutNetworkError.noDataResponseReceived
}
}

guard (200..<300).contains(response.statusCode) else {
return CheckoutNetworkError.unexpectedHTTPResponse(code: response.statusCode)
}
return nil
}

func convertDataTaskErrorsToCheckoutNetworkError(error: Error) -> CheckoutNetworkError {
let error = error as NSError

switch error.code {
case NSURLErrorNotConnectedToInternet,
NSURLErrorTimedOut,
NSURLErrorNetworkConnectionLost,
NSURLErrorInternationalRoamingOff,
NSURLErrorCannotConnectToHost,
NSURLErrorServerCertificateUntrusted:

return .connectivity

default:
return .other(underlyingError: error)
}
}
}
54 changes: 1 addition & 53 deletions Sources/CheckoutNetwork/CheckoutNetworkClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public class CheckoutNetworkClient: CheckoutClientInterface {
self?.tasks.removeValue(forKey: taskID)
guard let self = self else { return }
if let error = error {
completionHandler(.failure(error))
completionHandler(.failure(convertDataTaskErrorsToCheckoutNetworkError(error: error)))
return
}

Expand Down Expand Up @@ -88,56 +88,4 @@ public class CheckoutNetworkClient: CheckoutClientInterface {
}
return taskID
}

private func getErrorFromResponse(_ response: URLResponse?, data: Data?) -> Error? {
guard let response = response as? HTTPURLResponse else {
return CheckoutNetworkError.unexpectedResponseCode(code: 0)
}

guard response.statusCode != 422 else {
do {
let errorReason = try JSONDecoder().decode(ErrorReason.self, from: data ?? Data())
return CheckoutNetworkError.invalidData(reason: errorReason)
} catch {
return CheckoutNetworkError.noDataResponseReceived
}
}

guard response.statusCode >= 200,
response.statusCode < 300 else {
return CheckoutNetworkError.unexpectedResponseCode(code: response.statusCode)
}
return nil
}
}

// MARK: Async Wrappers
public extension CheckoutNetworkClient {

func runRequest<T: Decodable>(with configuration: RequestConfiguration) async throws -> T {
return try await withCheckedThrowingContinuation { continuation in
runRequest(with: configuration) { (result: Result<T, Error>) in
switch result {
case .success(let response):
continuation.resume(returning: response)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}

func runRequest(with configuration: RequestConfiguration) async throws {
return try await withCheckedThrowingContinuation { continuation in
runRequest(with: configuration) { (error: Error?) in

guard let error = error else {
continuation.resume(returning: Void())
return
}

continuation.resume(throwing: error)
}
}
}
}
72 changes: 58 additions & 14 deletions Sources/CheckoutNetwork/CheckoutNetworkError.swift
Original file line number Diff line number Diff line change
@@ -1,23 +1,67 @@
//
// CheckoutNetworkError.swift
//
//
//
// Created by Alex Ioja-Yang on 20/06/2022.
//

import Foundation

public enum CheckoutNetworkError: Error, Equatable {

/// Review the url you provided
case invalidURL

/// Network response was not in the 200 range
case unexpectedResponseCode(code: Int)

/// 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)
case noDataResponseReceived

/// Network response returned with HTTP Code 422
case invalidData(reason: ErrorReason)
public enum CheckoutNetworkError: LocalizedError, Equatable {

/// Review the url you provided
case invalidURL

/// When a response could not be bound to an instance of HTTPURLResponse
case invalidURLResponse

/// Network response was not in the 200 range
case unexpectedHTTPResponse(code: Int)

/// 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)
case noDataResponseReceived

/// Network response returned with HTTP Code 422
case unprocessableContent(reason: ErrorReason)

/// Connectivity errors mapped from URLSession.dataTask()'s error
///
/// Only the following are mapped into this error case:
/// NSURLErrorNotConnectedToInternet
/// NSURLErrorTimedOut
/// NSURLErrorNetworkConnectionLost
/// NSURLErrorInternationalRoamingOff
/// NSURLErrorCannotConnectToHost
/// NSURLErrorServerCertificateUntrusted
case connectivity

/// All the other errors that can be received from URLSession.
/// Use the underlying error if you need more granular error handling.
/// Underlying errors here are in NSURLErrorDomain.
case other(underlyingError: NSError)

public var errorDescription: String? {
switch self {
case .invalidURL:
return "Could not instantiate a URL with the provided String value"

case .invalidURLResponse:
return "Could not instantiate an HTTPURLResponse with the received response value"

case .unexpectedHTTPResponse(let code):
return "Received an unexpected HTTP response: \(code)"

case .noDataResponseReceived:
return "No data is received in the response body. Use the method with NoDataResponseCompletionHandler if no data was expected"

case .unprocessableContent(let reason):
return "HTTP response 422 is received with: \(reason)"

case .connectivity:
return "There is a problem with the internet connection"

case .other(let error):
return "An unhandled error is produced: \(error)"
}
}
}
6 changes: 6 additions & 0 deletions Sources/CheckoutNetwork/Models/ErrorReason.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,9 @@ public struct ErrorReason: Decodable, Equatable {
case errorCodes = "error_codes"
}
}

extension ErrorReason: CustomStringConvertible {
public var description: String {
"\(requestID) \(errorType) \(errorCodes.joined(separator: ", "))"
}
}
12 changes: 6 additions & 6 deletions Tests/CheckoutNetworkTests/CheckoutNetworkClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,15 @@ final class CheckoutNetworkClientTests: XCTestCase {
let expectedData = "nothing".data(using: .utf8)
let expectedResponse = URLResponse()
let expectedError = NSError(domain: "fail", code: 12345)

let expect = expectation(description: "Ensure completion handler is called")
client.runRequest(with: testConfig) { (result: Result<FakeObject, Error>) in
expect.fulfill()
switch result {
case .success(_):
XCTFail("Test expects a specific error to be returned")
case .failure(let failure):
XCTAssertEqual(failure as NSError, expectedError)
XCTAssertEqual(failure as? CheckoutNetworkError, CheckoutNetworkError.other(underlyingError: expectedError))
}
}

Expand Down Expand Up @@ -100,7 +100,7 @@ final class CheckoutNetworkClientTests: XCTestCase {
case .success(_):
XCTFail("Test expects a specific error to be returned")
case .failure(let failure):
XCTAssertEqual(failure as? CheckoutNetworkError, CheckoutNetworkError.unexpectedResponseCode(code: testResponseCode))
XCTAssertEqual(failure as? CheckoutNetworkError, CheckoutNetworkError.unexpectedHTTPResponse(code: testResponseCode))
}
}

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

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

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

XCTAssertFalse(client.tasks.isEmpty)
Expand Down

0 comments on commit f99770d

Please sign in to comment.