Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Alamofire
import Foundation

protocol RequestProcessorDelegate: AnyObject {
func didFailToAuthenticateRequestWithAppPassword(siteID: Int64)
func didFailToAuthenticateRequestWithAppPassword(siteID: Int64, error: Error)
}

/// Authenticates and retries requests
Expand Down Expand Up @@ -85,7 +85,7 @@ private extension RequestProcessor {
generateApplicationPassword()
} else {
isAuthenticating = false
completeRequests(false)
completeRequests(false, error: error)
if let currentSiteID {
notifyFailureIfNeeded(error, for: currentSiteID)
}
Expand Down Expand Up @@ -131,7 +131,7 @@ private extension RequestProcessor {
}
}()
if appPasswordNotSupported {
delegate?.didFailToAuthenticateRequestWithAppPassword(siteID: siteID)
delegate?.didFailToAuthenticateRequestWithAppPassword(siteID: siteID, error: error)
}
}

Expand All @@ -146,8 +146,16 @@ private extension RequestProcessor {
}
}

func completeRequests(_ shouldRetry: Bool) {
let result: RetryResult = shouldRetry ? .retryWithDelay(0) : .doNotRetry
func completeRequests(_ shouldRetry: Bool, error: Error? = nil) {
let result: RetryResult = {
if shouldRetry {
.retryWithDelay(0)
} else if let error {
.doNotRetryWithError(error)
} else {
.doNotRetry
}
}()
requestsToRetry.forEach { (completion) in
completion(result)
}
Expand All @@ -165,4 +173,12 @@ public extension NSNotification.Name {
/// Posted when generating an application password fails
///
static let ApplicationPasswordsGenerationFailed = NSNotification.Name(rawValue: "ApplicationPasswordsGenerationFailed")

/// Posted when site is flagged as unsupported for app password
///
static let JetpackSiteFlaggedUnsupportedForApplicationPassword = NSNotification.Name(rawValue: "JetpackSiteFlaggedUnsupportedForApplicationPassword")

/// Posted when site is detected as eligible for app password authentication
///
static let JetpackSiteEligibleForAppPasswordSupport = NSNotification.Name(rawValue: "JetpackSiteEligibleForAppPasswordSupport")
}
15 changes: 10 additions & 5 deletions Modules/Sources/NetworkingCore/Network/AlamofireNetwork.swift
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ private extension AlamofireNetwork {
network: self
))
requestAuthenticator.delegate = self
errorHandler.resetFailureCount(for: site.siteID) // reset failure count
errorHandler.prepareAppPasswordSupport(for: site.siteID) // reset failure count
updateAuthenticationMode(.appPasswordsWithJetpack)
}
}
Expand All @@ -336,8 +336,13 @@ private extension AlamofireNetwork {
// MARK: `RequestProcessorDelegate` conformance
//
extension AlamofireNetwork: RequestProcessorDelegate {
func didFailToAuthenticateRequestWithAppPassword(siteID: Int64) {
errorHandler.flagSiteAsUnsupported(for: siteID)
func didFailToAuthenticateRequestWithAppPassword(siteID: Int64, error: Error) {
errorHandler.flagSiteAsUnsupported(
for: siteID,
flow: .appPasswordGeneration,
cause: .majorError,
error: error
)
}
}

Expand Down Expand Up @@ -377,8 +382,8 @@ extension Alamofire.DataResponse {
return error
}

if case .some(AFError.requestAdaptationFailed) = error?.asAFError {
return error
if case .some(AFError.requestRetryFailed) = error?.asAFError {
return error?.asAFError
}

return response.flatMap { response in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@ final class AlamofireNetworkErrorHandler {
private let queue = DispatchQueue(label: "com.networkingcore.errorhandler", attributes: .concurrent)
private let userDefaults: UserDefaults
private let credentials: Credentials?
private let notificationCenter: NotificationCenter

private var _appPasswordFailures: [Int64: Int] = [:]
private var _retriedJetpackRequests: [RetriedJetpackRequest] = []

init(credentials: Credentials?, userDefaults: UserDefaults = .standard) {
init(credentials: Credentials?,
userDefaults: UserDefaults = .standard,
notificationCenter: NotificationCenter = .default) {
self.credentials = credentials
self.userDefaults = userDefaults
self.notificationCenter = notificationCenter
}

// MARK: - Thread-safe property access
Expand Down Expand Up @@ -41,8 +45,9 @@ final class AlamofireNetworkErrorHandler {

// MARK: - Public interface

func resetFailureCount(for siteID: Int64) {
func prepareAppPasswordSupport(for siteID: Int64) {
appPasswordFailures.removeValue(forKey: siteID)
notificationCenter.post(name: .JetpackSiteEligibleForAppPasswordSupport, object: siteID)
}

func shouldRetryJetpackRequest(originalRequest: URLRequestConvertible,
Expand All @@ -51,13 +56,14 @@ final class AlamofireNetworkErrorHandler {
guard let error = failure,
let request = originalRequest as? JetpackRequest,
convertedRequest is RESTRequest,
let convertedURLRequest = try? convertedRequest.asURLRequest(),
case .some(.wpcom) = self.credentials else {
return false
}

let isExpectedError: Bool = {
switch error {
case AFError.requestAdaptationFailed:
case AFError.requestRetryFailed:
return true
case _ as NetworkError:
return true
Expand All @@ -69,6 +75,7 @@ final class AlamofireNetworkErrorHandler {
if isExpectedError {
let retriedRequest = RetriedJetpackRequest(request: request, error: error)
retriedJetpackRequests.append(retriedRequest)
logRequestFailure(request: convertedURLRequest, error: error)
return true
}
return false
Expand All @@ -86,7 +93,7 @@ final class AlamofireNetworkErrorHandler {

guard let index = retriedRequestIndex else { return }

let retriedRequest = retriedJetpackRequests[index]
let retriedRequest = retriedJetpackRequests.remove(at: index)

if failure == nil {
let siteID = retriedRequest.request.siteID
Expand All @@ -95,19 +102,27 @@ final class AlamofireNetworkErrorHandler {
case NetworkError.unacceptableStatusCode(statusCode: 401, _),
NetworkError.unacceptableStatusCode(statusCode: 403, _),
NetworkError.unacceptableStatusCode(statusCode: 429, _):
flagSiteAsUnsupported(for: siteID)
flagSiteAsUnsupported(
for: siteID,
flow: .apiRequest,
cause: .majorError,
error: originalFailure
)
default:
if let networkError = originalFailure as? NetworkError,
let code = networkError.errorCode,
AppPasswordConstants.disabledCodes.contains(code) {
flagSiteAsUnsupported(for: siteID)
flagSiteAsUnsupported(
for: siteID,
flow: .apiRequest,
cause: .majorError,
error: originalFailure
)
} else {
incrementFailureCount(for: siteID)
incrementFailureCount(for: siteID, originalFailure: originalFailure)
}
}
}

retriedJetpackRequests.remove(at: index)
}

func handleFailureForDirectRequestIfNeeded(originalRequest: URLRequestConvertible,
Expand All @@ -133,12 +148,24 @@ final class AlamofireNetworkErrorHandler {
}
}

func flagSiteAsUnsupported(for siteID: Int64) {
func flagSiteAsUnsupported(for siteID: Int64, flow: RequestFlow, cause: AppPasswordFlagCause, error: Error) {
queue.sync(flags: .barrier) {
var currentList = userDefaults.applicationPasswordUnsupportedList
currentList[String(siteID)] = Date()
userDefaults.applicationPasswordUnsupportedList = currentList
}

/// Tracks error
let apiErrorCode = (error as? NetworkError)?.errorCode ?? error.localizedDescription
let httpStatusCode = (error as? NetworkError)?.responseCode ?? (error as NSError).code

let tracksProperties: [String: Any] = [
TracksProperty.flow.rawValue: flow.rawValue,
TracksProperty.cause.rawValue: cause.rawValue,
TracksProperty.apiErrorCode.rawValue: apiErrorCode,
TracksProperty.httpStatusCode.rawValue: httpStatusCode
]
notificationCenter.post(name: .JetpackSiteFlaggedUnsupportedForApplicationPassword, object: tracksProperties)
}

func siteFlaggedAsUnsupported(siteID: Int64, unsupportedList: [String: Date]) -> Bool {
Expand All @@ -156,13 +183,38 @@ final class AlamofireNetworkErrorHandler {
}
}

enum RequestFlow: String {
case appPasswordGeneration = "app_password_generation"
case apiRequest = "api_request"
}

enum AppPasswordFlagCause: String {
case majorError = "major_error"
case generalFailuresThresholdReached = "general_failures_threshold_reached"
}

// MARK: Private helpers
private extension AlamofireNetworkErrorHandler {
func incrementFailureCount(for siteID: Int64) {
func incrementFailureCount(for siteID: Int64, originalFailure: Error) {
let currentFailureCount = appPasswordFailures[siteID] ?? 0
let updatedCount = currentFailureCount + 1
if updatedCount == AppPasswordConstants.requestFailureThreshold {
flagSiteAsUnsupported(for: siteID)
let flow: RequestFlow
let failure: Error
switch originalFailure {
case AFError.requestRetryFailed(let error, _):
flow = .appPasswordGeneration
failure = error
default:
flow = .apiRequest
failure = originalFailure
}
flagSiteAsUnsupported(
for: siteID,
flow: flow,
cause: .generalFailuresThresholdReached,
error: failure
)
}
appPasswordFailures[siteID] = updatedCount
}
Expand All @@ -176,9 +228,46 @@ private extension AlamofireNetworkErrorHandler {
}
}

func logRequestFailure(request: URLRequest, error: Error) {
let networkError: NetworkError? = {
switch error {
case AFError.requestRetryFailed(let retryError, _):
return (retryError as? NetworkError)
case let networkError as NetworkError:
return networkError
default:
return nil
}
}()

let siteURL = request.url?.host() ?? ""
let path = request.url?.path(percentEncoded: false) ?? ""
let method = request.httpMethod ?? ""
let apiErrorCode = networkError?.errorCode ?? error.localizedDescription
let httpCode = networkError?.responseCode ?? (error as NSError).code

DDLogError(
"""
⛔️ Request failed using Application Passwords for Jetpack Site:
- Site URL: \(siteURL)
- Path: \(path)
- Method: \(method)
- Error: HTTP status code \(httpCode)
- Error message: \(apiErrorCode)
"""
)
}

enum Constants {
static let flagRefreshDuration: Double = 60 * 60 * 24 * 14 // flag can be reset after 14 days.
}

enum TracksProperty: String {
case flow
case cause
case apiErrorCode = "api_error_code"
case httpStatusCode = "http_status_code"
}
}
/// Helper type to keep track of retried requests with accompanied error
struct RetriedJetpackRequest {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,7 @@ private class MockRequest: Alamofire.DataRequest, @unchecked Sendable {
private class MockRequestProcessorDelegate: RequestProcessorDelegate {
private(set) var didFailToAuthenticateRequestForSiteID: Int64?

func didFailToAuthenticateRequestWithAppPassword(siteID: Int64) {
func didFailToAuthenticateRequestWithAppPassword(siteID: Int64, error: Error) {
didFailToAuthenticateRequestForSiteID = siteID
}
}
Loading