Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
07d35d2
Enhance NetworkError with better response parsing and convenience pro…
joshheald Aug 25, 2025
29a205b
Update Remote.mapNetworkError to preserve real HTTP status codes from…
joshheald Aug 25, 2025
b7584b1
Update ProductStore to use NetworkError consistently across all error…
joshheald Aug 25, 2025
b0467f2
Update ProductStore FilterProducts tests to use NetworkError instead …
joshheald Aug 25, 2025
da9775e
Fix whitespace issues
joshheald Aug 25, 2025
c70df8e
Update CouponsError, OrderStore, and PaymentsError to use NetworkErro…
joshheald Aug 25, 2025
2112515
Complete DotcomError to NetworkError migration across all remaining S…
joshheald Aug 25, 2025
b2124e9
Complete DotcomError to NetworkError migration across WooCommerce app…
joshheald Aug 25, 2025
19511d8
Reduce access levels
joshheald Aug 25, 2025
191fa17
Fix whitespace
joshheald Aug 25, 2025
b751707
Merge branch 'trunk' into woomob-93-use-NetworkError-for-all-API-errors
joshheald Sep 1, 2025
1a3222d
Use NetworkError wrapper in CardReaderConfigTests
joshheald Sep 1, 2025
7b4c620
Update Yosemite tests to use wrapped dotcomerror
joshheald Sep 1, 2025
1c169e2
Fix code for `other` error
joshheald Sep 2, 2025
00232b3
Ensure “success” path errors are NetworkErrors
joshheald Sep 2, 2025
a106c00
Remove unnecessary tests
joshheald Sep 2, 2025
78cb337
Update account creation to use wrapped errors
joshheald Sep 2, 2025
cb09853
Update tests to use NetworkError, not DotcomError
joshheald Sep 2, 2025
c936d02
Fix lint
joshheald Sep 2, 2025
5d1374b
Remove unused code
joshheald Sep 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 16 additions & 19 deletions Modules/Sources/Networking/Remote/AccountRemote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,11 @@ public class AccountRemote: Remote, AccountRemoteProtocol {
let result: CreateAccountResult = try await enqueue(request)
return .success(result)
} catch {
guard let dotcomError = error as? DotcomError else {
if let networkError = error as? NetworkError {
return .failure(CreateAccountError(error: networkError))
} else {
return .failure(.unknown(error: error as NSError))
}
return .failure(CreateAccountError(dotcomError: dotcomError))
}
}

Expand Down Expand Up @@ -247,26 +248,22 @@ public enum CreateAccountError: Error, Equatable {
case invalidUsername
case invalidEmail
case invalidPassword(message: String?)
case unexpected(error: DotcomError)
case unexpected(error: NetworkError)
case unknown(error: NSError)

/// Decodable Initializer.
/// NetworkError Initializer.
///
init(dotcomError error: DotcomError) {
if case let .unknown(code, message) = error {
switch code {
case Constants.emailExists:
self = .emailExists
case Constants.invalidEmail:
self = .invalidEmail
case Constants.invalidPassword:
self = .invalidPassword(message: message)
case Constants.invalidUsername, Constants.usernameExists:
self = .invalidUsername
default:
self = .unexpected(error: error)
}
} else {
init(error: NetworkError) {
switch error.apiErrorCode {
case Constants.emailExists:
self = .emailExists
case Constants.invalidEmail:
self = .invalidEmail
case Constants.invalidPassword:
self = .invalidPassword(message: error.apiErrorMessage)
case Constants.invalidUsername, Constants.usernameExists:
self = .invalidUsername
default:
self = .unexpected(error: error)
}
}
Expand Down
4 changes: 2 additions & 2 deletions Modules/Sources/Networking/Remote/WordPressThemeRemote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ public enum InstallThemeError: Error {
case themeAlreadyInstalled

init?(_ error: Error) {
guard let dotcomError = error as? DotcomError,
case let .unknown(code, _) = dotcomError else {
guard let networkError = error as? NetworkError,
let code = networkError.apiErrorCode else {
return nil
}

Expand Down
80 changes: 80 additions & 0 deletions Modules/Sources/NetworkingCore/Model/DotcomError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ public enum DotcomError: Error, Decodable, Equatable, GeneratedFakeable {
static let noStatsPermission = "user cannot view stats"
static let resourceDoesNotExist = "Resource does not exist."
static let jetpackNotConnected = "This blog does not have Jetpack connected"
static let unauthorized = "Missing or invalid authorization"
static let invalidToken = "Invalid authentication token"
static let requestFailed = "Request failed"
static let noRestRoute = "No matching REST route"
}
}

Expand Down Expand Up @@ -163,3 +167,79 @@ public func ==(lhs: DotcomError, rhs: DotcomError) -> Bool {
return false
}
}

// MARK: - NetworkError Conversion
//
public extension NetworkError {
/// Creates a NetworkError from a DotcomError, preserving the original NetworkError's status code and response data
/// This is used in Remote.mapNetworkError to maintain real HTTP status codes while adding parsed API error details
static func from(dotcomError: DotcomError, originalNetworkError: NetworkError? = nil) -> NetworkError {
guard let originalNetworkError = originalNetworkError else {
// No original NetworkError - this is likely from successful HTTP response with API error content
let (code, message) = dotcomError.getCodeAndMessage()
let errorData = dotcomError.createEnhancedErrorResponseData()
return .apiError(code: code, message: message, response: errorData)
}

let enhancedErrorData = dotcomError.createEnhancedErrorResponseData()

switch originalNetworkError {
case .notFound:
return .notFound(response: enhancedErrorData)
case .timeout:
return .timeout(response: enhancedErrorData)
case let .unacceptableStatusCode(statusCode, _):
return .unacceptableStatusCode(statusCode: statusCode, response: enhancedErrorData)
case let .apiError(_, _, response):
let (code, message) = dotcomError.getCodeAndMessage()
return .apiError(code: code, message: message, response: response)
case .invalidURL:
return .invalidURL
case .invalidCookieNonce:
return .invalidCookieNonce
}
}
}

// MARK: - Helper for NetworkError creation
//
private extension DotcomError {
/// Creates enhanced JSON error response data that preserves original response while adding structured error details
func createEnhancedErrorResponseData() -> Data? {
let (code, message) = getCodeAndMessage()

let errorResponse: [String: Any] = [
"code": code,
"message": message
]

// Return the enhanced structured error data (the original response is already parsed)
return try? JSONSerialization.data(withJSONObject: errorResponse)
}

/// Extracts the appropriate error code and message for each DotcomError case
func getCodeAndMessage() -> (code: String, message: String) {
switch self {
case .empty:
return ("empty", "Empty response")
case .unauthorized:
return (Constants.unauthorized, ErrorMessages.unauthorized)
case .noStatsPermission:
return (Constants.unauthorized, ErrorMessages.noStatsPermission)
case .invalidToken:
return (Constants.invalidToken, ErrorMessages.invalidToken)
case .requestFailed:
return (Constants.requestFailed, ErrorMessages.requestFailed)
case .noRestRoute:
return (Constants.noRestRoute, ErrorMessages.noRestRoute)
case .jetpackNotConnected:
return (Constants.unknownToken, ErrorMessages.jetpackNotConnected)
case .statsModuleDisabled:
return (Constants.invalidBlog, ErrorMessages.statsModuleDisabled)
case .resourceDoesNotExist:
return (Constants.restTermInvalid, ErrorMessages.resourceDoesNotExist)
case .unknown(let code, let message):
return (code, message ?? "Unknown error")
}
}
}
124 changes: 116 additions & 8 deletions Modules/Sources/NetworkingCore/Network/NetworkError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,20 @@ public enum NetworkError: Error, Equatable {
/// Error for REST API requests with invalid cookie nonce
case invalidCookieNonce

/// API-level error parsed from a successful HTTP response
case apiError(code: String, message: String?, response: Data? = nil)

/// The HTTP response code of the network error, for cases that are deducted from the status code.
public var responseCode: Int? {
switch self {
case .notFound:
return StatusCode.notFound
case .timeout:
return StatusCode.timeout
case let .unacceptableStatusCode(statusCode, _):
return statusCode
default:
return nil
case .notFound:
return StatusCode.notFound
case .timeout:
return StatusCode.timeout
case let .unacceptableStatusCode(statusCode, _):
return statusCode
case .apiError, .invalidURL, .invalidCookieNonce:
return nil
}
}

Expand All @@ -45,10 +48,38 @@ public enum NetworkError: Error, Equatable {
return response
case .unacceptableStatusCode(_, let response):
return response
case .apiError(_, _, let response):
return response
case .invalidURL, .invalidCookieNonce:
return nil
}
}

/// Parsed API error details from response data
var apiErrorDetails: APIErrorDetails? {
guard let data = response else { return nil }
return try? JSONDecoder().decode(APIErrorDetails.self, from: data)
}

/// API error code from response, if available
public var apiErrorCode: String? {
switch self {
case .apiError(let code, _, _):
return code
default:
return apiErrorDetails?.code
}
}

/// API error message from response, if available
public var apiErrorMessage: String? {
switch self {
case .apiError(_, let message, _):
return message
default:
return apiErrorDetails?.message
}
}
}


Expand Down Expand Up @@ -118,6 +149,83 @@ extension NetworkError: CustomStringConvertible {
"NetworkError.invalidCookieNonce",
value: "Sorry, your session has expired. Please log in again.",
comment: "Error message when session cookie has expired.")
case let .apiError(code, message, _):
let messageText = message ?? ""
let format = NSLocalizedString(
"NetworkError.apiError",
value: "API Error: [%1$@] %2$@",
comment: "Error message for API-level errors. %1$@ is the error code, %2$@ is the error message")
return String.localizedStringWithFormat(format, code, messageText)
}
}
}

// MARK: - Convenience Properties for Common Error Conditions

public extension NetworkError {
/// Returns true if this error represents a "not found" condition
// periphery:ignore - TODO: remove this ignore when we merge NetworkError use
var isNotFound: Bool {
switch self {
case .notFound:
return true
case .unacceptableStatusCode(let statusCode, _):
return statusCode == 404
default:
return false
}
}

/// Returns true if this error represents an authorization issue
// periphery:ignore - TODO: remove this ignore when we merge NetworkError use
var isUnauthorized: Bool {
switch self {
case .invalidCookieNonce:
return true
case .unacceptableStatusCode(let statusCode, _):
return statusCode == 401 || statusCode == 403
default:
// Check for specific unauthorized error codes in API response
return apiErrorCode?.contains("unauthorized") == true ||
apiErrorCode?.contains("invalid_token") == true
}
}

/// Returns true if this error represents a timeout
// periphery:ignore - TODO: remove this ignore when we merge NetworkError use
var isTimeout: Bool {
switch self {
case .timeout:
return true
case .unacceptableStatusCode(let statusCode, _):
return statusCode == 408
default:
return false
}
}

/// Returns true if this error represents invalid input/parameters
// periphery:ignore - TODO: remove this ignore when we merge NetworkError use
var isInvalidInput: Bool {
switch self {
case .unacceptableStatusCode(let statusCode, _):
return statusCode == 400
default:
return apiErrorCode?.contains("invalid_param") == true
}
}

/// Returns a user-friendly error message, preferring API error message over generic description
// periphery:ignore - TODO: remove this ignore when we merge NetworkError use
var userFriendlyMessage: String {
return apiErrorMessage ?? localizedDescription
}
}

// MARK: - Supporting Types

/// Represents error details from API response JSON
struct APIErrorDetails: Codable {
let code: String
let message: String?
}
31 changes: 31 additions & 0 deletions Modules/Sources/NetworkingCore/Remote/Remote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ open class Remote: NSObject {
let validator = request.responseDataValidator()
try validator.validate(data: data)
continuation.resume()
} catch let dotcomError as DotcomError {
self.handleResponseError(error: dotcomError, for: request)
continuation.resume(throwing: NetworkError.from(dotcomError: dotcomError))
} catch {
self.handleResponseError(error: error, for: request)
continuation.resume(throwing: error)
Expand Down Expand Up @@ -62,6 +65,9 @@ open class Remote: NSObject {
try validator.validate(data: data)
let document = try JSONDecoder().decode(T.self, from: data)
continuation.resume(returning: document)
} catch let dotcomError as DotcomError {
self.handleResponseError(error: dotcomError, for: request)
continuation.resume(throwing: NetworkError.from(dotcomError: dotcomError))
} catch {
self.handleResponseError(error: error, for: request)
self.handleDecodingError(error: error, for: request, entityName: "\(T.self)")
Expand Down Expand Up @@ -102,6 +108,10 @@ open class Remote: NSObject {
try validator.validate(data: data)
let parsed = try mapper.map(response: data)
completion(parsed, nil)
} catch let dotcomError as DotcomError {
self.handleResponseError(error: dotcomError, for: request)
let networkError = NetworkError.from(dotcomError: dotcomError)
completion(nil, networkError)
} catch {
self.handleResponseError(error: error, for: request)
self.handleDecodingError(error: error, for: request, entityName: "\(M.Output.self)")
Expand Down Expand Up @@ -135,6 +145,10 @@ open class Remote: NSObject {
try validator.validate(data: data)
let parsed = try mapper.map(response: data)
completion(.success(parsed))
} catch let dotcomError as DotcomError {
self.handleResponseError(error: dotcomError, for: request)
let networkError = NetworkError.from(dotcomError: dotcomError)
completion(.failure(networkError))
} catch {
self.handleResponseError(error: error, for: request)
self.handleDecodingError(error: error, for: request, entityName: "\(M.Output.self)")
Expand Down Expand Up @@ -183,6 +197,14 @@ open class Remote: NSObject {
self?.handleDecodingError(error: decodingError, for: request, entityName: "\(M.Output.self)")
}
})
.map { (result: Result<M.Output, Error>) -> Result<M.Output, Error> in
guard case let .failure(error) = result,
let dotcomError = error as? DotcomError
else {
return result
}
return .failure(NetworkError.from(dotcomError: dotcomError))
}
.eraseToAnyPublisher()
}

Expand Down Expand Up @@ -256,6 +278,10 @@ private extension Remote {
do {
try validator.validate(data: data)
return try mapper.map(response: data)
} catch let dotcomError as DotcomError {
DDLogError("<> Mapping Error: \(dotcomError)")
handleResponseError(error: dotcomError, for: request)
throw NetworkError.from(dotcomError: dotcomError)
} catch {
DDLogError("<> Mapping Error: \(error)")
handleDecodingError(error: error, for: request, entityName: "\(M.Output.self)")
Expand Down Expand Up @@ -295,6 +321,7 @@ private extension Remote {
}

/// Maps an error from `network.responseData` so that the request's corresponding error can be returned.
/// Returns a NetworkError with preserved status codes and enhanced API error details.
///
func mapNetworkError(error: Error, for request: Request) -> Error {
guard let networkError = error as? NetworkError else {
Expand All @@ -317,7 +344,11 @@ private extension Remote {
let validator = request.responseDataValidator()
try validator.validate(data: response)
return networkError
} catch let dotcomError as DotcomError {
// Convert DotcomError back to NetworkError while preserving original status code
return NetworkError.from(dotcomError: dotcomError, originalNetworkError: networkError)
} catch {
// For non-DotcomError validation failures, return the original error
return error
}
}
Expand Down
Loading