Skip to content

Commit d5786ad

Browse files
authored
Application password experiment: Handle errors when generating password fails (#16057)
2 parents 4470c01 + 7e8680e commit d5786ad

File tree

11 files changed

+488
-9
lines changed

11 files changed

+488
-9
lines changed

Modules/Sources/NetworkingCore/ApplicationPassword/RequestAuthenticator.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ protocol RequestAuthenticator {
1010
///
1111
var credentials: Credentials? { get }
1212

13+
var jetpackSiteID: Int64? { get }
14+
1315
/// Authenticates the provided urlRequest using the `credentials`
1416
///
1517
/// - Parameter urlRequest: `URLRequest` to authenticate
@@ -21,6 +23,10 @@ protocol RequestAuthenticator {
2123
///
2224
func generateApplicationPassword() async throws
2325

26+
/// Delete existing application password remotely
27+
///
28+
func deleteApplicationPassword() async throws
29+
2430
/// Checks whether the given URLRequest is eligible for retyring
2531
///
2632
func shouldRetry(_ urlRequest: URLRequest) -> Bool
@@ -33,6 +39,10 @@ public struct DefaultRequestAuthenticator: RequestAuthenticator {
3339
///
3440
let credentials: Credentials?
3541

42+
/// ID of current site if Jetpack site
43+
///
44+
let jetpackSiteID: Int64?
45+
3646
/// The use case to handle authentication with application passwords.
3747
///
3848
private let applicationPasswordUseCase: ApplicationPasswordUseCase?
@@ -82,6 +92,8 @@ public struct DefaultRequestAuthenticator: RequestAuthenticator {
8292
return selectedSite?.siteAddress
8393
}
8494
}()
95+
96+
jetpackSiteID = selectedSite?.siteID
8597
}
8698

8799
/// Authenticates the provided urlRequest using the `credentials`
@@ -107,6 +119,13 @@ public struct DefaultRequestAuthenticator: RequestAuthenticator {
107119
return
108120
}
109121

122+
func deleteApplicationPassword() async throws {
123+
guard let applicationPasswordUseCase else {
124+
throw RequestAuthenticatorError.applicationPasswordUseCaseNotAvailable
125+
}
126+
try await applicationPasswordUseCase.deletePassword(locally: false)
127+
}
128+
110129
/// Checks whether the given URLRequest is eligible for retyring
111130
///
112131
func shouldRetry(_ urlRequest: URLRequest) -> Bool {

Modules/Sources/NetworkingCore/ApplicationPassword/RequestProcessor.swift

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import Alamofire
22
import Foundation
33

4+
enum AppPasswordFailureReason {
5+
case notSupported
6+
case unknown
7+
}
8+
9+
protocol RequestProcessorDelegate: AnyObject {
10+
func didFailToAuthenticateRequestWithAppPassword(siteID: Int64, reason: AppPasswordFailureReason)
11+
}
12+
413
/// Authenticates and retries requests
514
///
615
final class RequestProcessor: RequestInterceptor {
@@ -12,6 +21,10 @@ final class RequestProcessor: RequestInterceptor {
1221

1322
private let notificationCenter: NotificationCenter
1423

24+
private var currentSiteID: Int64?
25+
26+
weak var delegate: RequestProcessorDelegate?
27+
1528
init(requestAuthenticator: RequestAuthenticator,
1629
notificationCenter: NotificationCenter = .default) {
1730
self.requestAuthenticator = requestAuthenticator
@@ -20,6 +33,7 @@ final class RequestProcessor: RequestInterceptor {
2033

2134
func updateAuthenticator(_ authenticator: RequestAuthenticator) {
2235
requestAuthenticator = authenticator
36+
currentSiteID = authenticator.jetpackSiteID
2337
}
2438
}
2539

@@ -57,7 +71,7 @@ extension RequestProcessor: RequestRetrier {
5771
//
5872
private extension RequestProcessor {
5973
func generateApplicationPassword() {
60-
Task(priority: .medium) {
74+
Task(priority: .medium) { @MainActor in
6175
do {
6276
let _ = try await requestAuthenticator.generateApplicationPassword()
6377
isAuthenticating = false
@@ -67,16 +81,63 @@ private extension RequestProcessor {
6781

6882
completeRequests(true)
6983
} catch {
70-
isAuthenticating = false
7184

7285
// Post a notification for tracking
7386
notificationCenter.post(name: .ApplicationPasswordsGenerationFailed, object: error, userInfo: nil)
7487

75-
completeRequests(false)
88+
let shouldRetry = await checkIfRetryingGenerationIsNeeded(for: error)
89+
if shouldRetry {
90+
generateApplicationPassword()
91+
} else {
92+
isAuthenticating = false
93+
completeRequests(false)
94+
if let currentSiteID {
95+
notifyFailure(error, for: currentSiteID)
96+
}
97+
}
7698
}
7799
}
78100
}
79101

102+
/// Checks error code to retry or mark site as unsupported for app password.
103+
/// Returns whether retry is needed.
104+
@MainActor
105+
func checkIfRetryingGenerationIsNeeded(for error: Error) async -> Bool {
106+
guard currentSiteID != nil else {
107+
return false
108+
}
109+
switch error {
110+
case NetworkError.unacceptableStatusCode(let statusCode, _) where statusCode == 409:
111+
/// Password with the same name already exists. Request deletion remotely and retry.
112+
do {
113+
try await requestAuthenticator.deleteApplicationPassword()
114+
return true
115+
} catch {
116+
return false
117+
}
118+
default:
119+
return false
120+
}
121+
}
122+
123+
func notifyFailure(_ error: Error, for siteID: Int64) {
124+
let reason: AppPasswordFailureReason = {
125+
switch error {
126+
case NetworkError.notFound:
127+
return .notSupported
128+
case let networkError as NetworkError:
129+
if let code = networkError.errorCode,
130+
AppPasswordConstants.disabledCodes.contains(code) {
131+
return .notSupported
132+
}
133+
return .unknown
134+
default:
135+
return .unknown
136+
}
137+
}()
138+
delegate?.didFailToAuthenticateRequestWithAppPassword(siteID: siteID, reason: reason)
139+
}
140+
80141
func shouldRetry(_ error: Error) -> Bool {
81142
switch error {
82143
case RequestAuthenticatorError.applicationPasswordNotAvailable,

Modules/Sources/NetworkingCore/Network/AlamofireNetwork.swift

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ public class AlamofireNetwork: Network {
3535

3636
private let selectedSite: AnyPublisher<JetpackSite?, Never>?
3737

38+
private let userDefaults: UserDefaults
39+
3840
/// Converter to convert Jetpack tunnel requests into REST API requests if applicable
3941
///
4042
private var requestConverter: RequestConverter
@@ -47,6 +49,9 @@ public class AlamofireNetwork: Network {
4749

4850
private var subscription: AnyCancellable?
4951

52+
/// Keeps track of failure counts for each site when switching to direct requests
53+
private var appPasswordFailures: [Int64: Int] = [:]
54+
5055
/// Public Initializer
5156
///
5257
/// - Parameters:
@@ -57,10 +62,12 @@ public class AlamofireNetwork: Network {
5762
/// Defaults to false for backward compatibility. Set to true when making concurrent requests immediately after initialization.
5863
public required init(credentials: Credentials?,
5964
selectedSite: AnyPublisher<JetpackSite?, Never>? = nil,
65+
userDefaults: UserDefaults = .standard,
6066
sessionManager: Alamofire.Session? = nil,
6167
ensuresSessionManagerIsInitialized: Bool = false) {
6268
self.credentials = credentials
6369
self.selectedSite = selectedSite
70+
self.userDefaults = userDefaults
6471
self.requestConverter = {
6572
let siteAddress: String? = {
6673
switch credentials {
@@ -89,6 +96,7 @@ public class AlamofireNetwork: Network {
8996
} else {
9097
requestConverter = RequestConverter(siteAddress: nil)
9198
requestAuthenticator.updateAuthenticator(DefaultRequestAuthenticator(credentials: credentials))
99+
requestAuthenticator.delegate = nil
92100
}
93101
}
94102

@@ -202,11 +210,14 @@ private extension AlamofireNetwork {
202210
func observeSelectedSite(_ selectedSite: AnyPublisher<JetpackSite?, Never>) {
203211
subscription = selectedSite
204212
.removeDuplicates()
205-
.sink { [weak self] site in
213+
.combineLatest(userDefaults.publisher(for: \.applicationPasswordUnsupportedList))
214+
.sink { [weak self] site, unsupportedList in
206215
guard let self else { return }
207-
guard let site, site.applicationPasswordAvailable else {
216+
guard let site, site.applicationPasswordAvailable,
217+
unsupportedList.contains(site.siteID) == false else {
208218
requestConverter = RequestConverter(siteAddress: nil)
209219
requestAuthenticator.updateAuthenticator(DefaultRequestAuthenticator(credentials: credentials))
220+
requestAuthenticator.delegate = nil
210221
return
211222
}
212223
requestConverter = RequestConverter(siteAddress: site.siteAddress)
@@ -215,10 +226,42 @@ private extension AlamofireNetwork {
215226
selectedSite: site,
216227
network: self
217228
))
229+
requestAuthenticator.delegate = self
230+
appPasswordFailures.removeValue(forKey: site.siteID) // reset failure count
231+
}
232+
}
233+
}
234+
235+
// MARK: `RequestProcessorDelegate` conformance
236+
//
237+
extension AlamofireNetwork: RequestProcessorDelegate {
238+
func didFailToAuthenticateRequestWithAppPassword(siteID: Int64, reason: AppPasswordFailureReason) {
239+
switch reason {
240+
case .notSupported:
241+
let currentList = userDefaults.applicationPasswordUnsupportedList
242+
userDefaults.applicationPasswordUnsupportedList = currentList + [siteID]
243+
case .unknown:
244+
let currentFailureCount = appPasswordFailures[siteID] ?? 0
245+
let updatedCount = currentFailureCount + 1
246+
if updatedCount == AppPasswordConstants.requestFailureThreshold {
247+
let currentList = userDefaults.applicationPasswordUnsupportedList
248+
userDefaults.applicationPasswordUnsupportedList = currentList + [siteID]
218249
}
250+
appPasswordFailures[siteID] = updatedCount
251+
}
219252
}
220253
}
221254

255+
// MARK: - Constants for direct request error handling
256+
enum AppPasswordConstants {
257+
// flag site as disabled after threshold is reached
258+
static let requestFailureThreshold = 10
259+
static let disabledCodes = [
260+
"application_passwords_disabled",
261+
"application_passwords_disabled_for_user"
262+
]
263+
}
264+
222265
private extension DataRequest {
223266
/// Validates only for `RESTRequest`
224267
///
@@ -260,3 +303,16 @@ extension Alamofire.DataResponse {
260303
}
261304
}
262305
}
306+
307+
// MARK: - Helper extension to save internal flag for app password availability
308+
//
309+
extension UserDefaults {
310+
@objc dynamic var applicationPasswordUnsupportedList: [Int64] {
311+
get { value(forKey: Key.applicationPasswordUnsupportedList.rawValue) as? [Int64] ?? [] }
312+
set { setValue(newValue, forKey: Key.applicationPasswordUnsupportedList.rawValue) }
313+
}
314+
315+
enum Key: String {
316+
case applicationPasswordUnsupportedList
317+
}
318+
}

Modules/Sources/NetworkingCore/Network/NetworkError.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ public enum NetworkError: Error, Equatable {
4949
return nil
5050
}
5151
}
52+
53+
/// Content of the `code` field in the response if available
54+
var errorCode: String? {
55+
guard let response else { return nil }
56+
let decoder = JSONDecoder()
57+
guard let decodedResponse = try? decoder.decode(NetworkErrorResponse.self, from: response) else {
58+
return nil
59+
}
60+
return decodedResponse.code
61+
}
5262
}
5363

5464

@@ -121,3 +131,25 @@ extension NetworkError: CustomStringConvertible {
121131
}
122132
}
123133
}
134+
135+
struct NetworkErrorResponse: Decodable {
136+
let code: String?
137+
138+
init(from decoder: Decoder) throws {
139+
let container = try decoder.container(keyedBy: CodingKeys.self)
140+
self.code = try {
141+
let errorValue = try container.decodeIfPresent(String.self, forKey: .error)
142+
if let errorValue {
143+
return errorValue
144+
}
145+
return try container.decodeIfPresent(String.self, forKey: .code)
146+
}()
147+
}
148+
149+
/// Coding Keys
150+
///
151+
private enum CodingKeys: String, CodingKey {
152+
case error
153+
case code
154+
}
155+
}

0 commit comments

Comments
 (0)