Skip to content

Commit f001b60

Browse files
authored
fix(auth): do not remove session in case of network failure (#712)
* fix(auth): do not remove session in case of cancellation error * add tests * do not remove session * fix integration tests
1 parent 5f3d75c commit f001b60

File tree

11 files changed

+434
-139
lines changed

11 files changed

+434
-139
lines changed

Sources/Auth/AuthClient.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ public actor AuthClient {
109109
}
110110
)
111111

112-
Task { @MainActor in await observeAppLifecycleChanges() }
112+
Task { @MainActor in observeAppLifecycleChanges() }
113113
}
114114

115115
#if canImport(ObjectiveC) && canImport(Combine)

Sources/Auth/AuthError.swift

-10
Original file line numberDiff line numberDiff line change
@@ -298,13 +298,3 @@ public enum AuthError: LocalizedError, Equatable {
298298
return lhs == rhs
299299
}
300300
}
301-
302-
extension AuthError: RetryableError {
303-
package var shouldRetry: Bool {
304-
switch self {
305-
case .api(_, _, _, let response):
306-
defaultRetryableHTTPStatusCodes.contains(response.statusCode)
307-
default: false
308-
}
309-
}
310-
}

Sources/Auth/Internal/SessionManager.swift

+16-35
Original file line numberDiff line numberDiff line change
@@ -78,43 +78,24 @@ private actor LiveSessionManager {
7878
logger?.debug("Refresh task ended")
7979
}
8080

81-
do {
82-
let session = try await api.execute(
83-
HTTPRequest(
84-
url: configuration.url.appendingPathComponent("token"),
85-
method: .post,
86-
query: [
87-
URLQueryItem(name: "grant_type", value: "refresh_token")
88-
],
89-
body: configuration.encoder.encode(
90-
UserCredentials(refreshToken: refreshToken)
91-
)
81+
let session = try await api.execute(
82+
HTTPRequest(
83+
url: configuration.url.appendingPathComponent("token"),
84+
method: .post,
85+
query: [
86+
URLQueryItem(name: "grant_type", value: "refresh_token")
87+
],
88+
body: configuration.encoder.encode(
89+
UserCredentials(refreshToken: refreshToken)
9290
)
9391
)
94-
.decoded(as: Session.self, decoder: configuration.decoder)
95-
96-
update(session)
97-
eventEmitter.emit(.tokenRefreshed, session: session)
98-
99-
return session
100-
} catch {
101-
logger?.debug("Refresh token failed with error: \(error)")
102-
103-
// DO NOT remove session in case it is an error that should be retried.
104-
// i.e. server instability, connection issues, ...
105-
//
106-
// Need to do this check here, because not all RetryableError's should be retried.
107-
// URLError conforms to RetryableError, but only a subset of URLError should be retried,
108-
// the same is true for AuthError.
109-
if let error = error as? URLError, error.shouldRetry {
110-
throw error
111-
} else if let error = error as? any RetryableError, error.shouldRetry {
112-
throw error
113-
} else {
114-
remove()
115-
throw error
116-
}
117-
}
92+
)
93+
.decoded(as: Session.self, decoder: configuration.decoder)
94+
95+
update(session)
96+
eventEmitter.emit(.tokenRefreshed, session: session)
97+
98+
return session
11899
}
119100

120101
return try await inFlightRefreshTask!.value

Sources/Helpers/HTTP/RetryRequestInterceptor.swift

+27-8
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,22 @@ package actor RetryRequestInterceptor: HTTPClientInterceptor {
3030
.delete, .get, .head, .options, .put, .trace,
3131
]
3232

33+
/// The default set of retryable URL error codes.
34+
package static let defaultRetryableURLErrorCodes: Set<URLError.Code> = [
35+
.backgroundSessionInUseByAnotherProcess, .backgroundSessionWasDisconnected,
36+
.badServerResponse, .callIsActive, .cannotConnectToHost, .cannotFindHost,
37+
.cannotLoadFromNetwork, .dataNotAllowed, .dnsLookupFailed,
38+
.downloadDecodingFailedMidStream, .downloadDecodingFailedToComplete,
39+
.internationalRoamingOff, .networkConnectionLost, .notConnectedToInternet,
40+
.secureConnectionFailed, .serverCertificateHasBadDate,
41+
.serverCertificateNotYetValid, .timedOut,
42+
]
43+
44+
/// The default set of retryable HTTP status codes.
45+
package static let defaultRetryableHTTPStatusCodes: Set<Int> = [
46+
408, 500, 502, 503, 504,
47+
]
48+
3349
/// The maximum number of retries.
3450
package let retryLimit: Int
3551
/// The base value for exponential backoff.
@@ -56,12 +72,14 @@ package actor RetryRequestInterceptor: HTTPClientInterceptor {
5672
retryLimit: Int = RetryRequestInterceptor.defaultRetryLimit,
5773
exponentialBackoffBase: UInt = RetryRequestInterceptor.defaultExponentialBackoffBase,
5874
exponentialBackoffScale: Double = RetryRequestInterceptor.defaultExponentialBackoffScale,
59-
retryableHTTPMethods: Set<HTTPTypes.HTTPRequest.Method> = RetryRequestInterceptor.defaultRetryableHTTPMethods,
60-
retryableHTTPStatusCodes: Set<Int> = defaultRetryableHTTPStatusCodes,
61-
retryableErrorCodes: Set<URLError.Code> = defaultRetryableURLErrorCodes
75+
retryableHTTPMethods: Set<HTTPTypes.HTTPRequest.Method> = RetryRequestInterceptor
76+
.defaultRetryableHTTPMethods,
77+
retryableHTTPStatusCodes: Set<Int> = RetryRequestInterceptor.defaultRetryableHTTPStatusCodes,
78+
retryableErrorCodes: Set<URLError.Code> = RetryRequestInterceptor.defaultRetryableURLErrorCodes
6279
) {
6380
precondition(
64-
exponentialBackoffBase >= 2, "The `exponentialBackoffBase` must be a minimum of 2."
81+
exponentialBackoffBase >= 2,
82+
"The `exponentialBackoffBase` must be a minimum of 2."
6583
)
6684

6785
self.retryLimit = retryLimit
@@ -114,10 +132,11 @@ package actor RetryRequestInterceptor: HTTPClientInterceptor {
114132
}
115133

116134
if retryCount < retryLimit, shouldRetry(request: request, result: result) {
117-
let retryDelay = pow(
118-
Double(exponentialBackoffBase),
119-
Double(retryCount)
120-
) * exponentialBackoffScale
135+
let retryDelay =
136+
pow(
137+
Double(exponentialBackoffBase),
138+
Double(retryCount)
139+
) * exponentialBackoffScale
121140

122141
let nanoseconds = UInt64(retryDelay)
123142
try? await Task.sleep(nanoseconds: NSEC_PER_SEC * nanoseconds)

Sources/Helpers/RetryableError.swift

-35
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"recommendations": ["denoland.vscode-deno"]
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"deno.enablePaths": [
3+
"supabase/functions"
4+
],
5+
"deno.lint": true,
6+
"deno.unstable": [
7+
"bare-node-builtins",
8+
"byonm",
9+
"sloppy-imports",
10+
"unsafe-proto",
11+
"webgpu",
12+
"broadcast-channel",
13+
"worker-options",
14+
"cron",
15+
"kv",
16+
"ffi",
17+
"fs",
18+
"http",
19+
"net"
20+
],
21+
"[typescript]": {
22+
"editor.defaultFormatter": "denoland.vscode-deno"
23+
}
24+
}

Tests/IntegrationTests/AuthClientIntegrationTests.swift

+46-49
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
// Created by Guilherme Souza on 27/03/24.
66
//
77

8-
@testable import Auth
98
import ConcurrencyExtras
109
import CustomDump
10+
import InlineSnapshotTesting
1111
import TestHelpers
1212
import XCTest
1313

14+
@testable import Auth
15+
1416
#if canImport(FoundationNetworking)
1517
import FoundationNetworking
1618
#endif
@@ -55,7 +57,7 @@ final class AuthClientIntegrationTests: XCTestCase {
5557
let password = mockPassword()
5658

5759
let metadata: [String: AnyJSON] = [
58-
"test": .integer(42),
60+
"test": .integer(42)
5961
]
6062

6163
let response = try await authClient.signUp(
@@ -74,23 +76,23 @@ final class AuthClientIntegrationTests: XCTestCase {
7476
}
7577
}
7678

77-
// func testSignUpAndSignInWithPhone() async throws {
78-
// try await XCTAssertAuthChangeEvents([.initialSession, .signedIn, .signedOut, .signedIn]) {
79-
// let phone = mockPhoneNumber()
80-
// let password = mockPassword()
81-
// let metadata: [String: AnyJSON] = [
82-
// "test": .integer(42),
83-
// ]
84-
// let response = try await authClient.signUp(phone: phone, password: password, data: metadata)
85-
// XCTAssertNotNil(response.session)
86-
// XCTAssertEqual(response.user.phone, phone)
87-
// XCTAssertEqual(response.user.userMetadata["test"], 42)
88-
//
89-
// try await authClient.signOut()
90-
//
91-
// try await authClient.signIn(phone: phone, password: password)
92-
// }
93-
// }
79+
// func testSignUpAndSignInWithPhone() async throws {
80+
// try await XCTAssertAuthChangeEvents([.initialSession, .signedIn, .signedOut, .signedIn]) {
81+
// let phone = mockPhoneNumber()
82+
// let password = mockPassword()
83+
// let metadata: [String: AnyJSON] = [
84+
// "test": .integer(42),
85+
// ]
86+
// let response = try await authClient.signUp(phone: phone, password: password, data: metadata)
87+
// XCTAssertNotNil(response.session)
88+
// XCTAssertEqual(response.user.phone, phone)
89+
// XCTAssertEqual(response.user.userMetadata["test"], 42)
90+
//
91+
// try await authClient.signOut()
92+
//
93+
// try await authClient.signIn(phone: phone, password: password)
94+
// }
95+
// }
9496

9597
func testSignInWithEmail_invalidEmail() async throws {
9698
let email = mockEmail()
@@ -108,12 +110,12 @@ final class AuthClientIntegrationTests: XCTestCase {
108110
}
109111
}
110112

111-
// func testSignInWithOTP_usingEmail() async throws {
112-
// let email = mockEmail()
113-
//
114-
// try await authClient.signInWithOTP(email: email)
115-
// try await authClient.verifyOTP(email: email, token: "123456", type: .magiclink)
116-
// }
113+
// func testSignInWithOTP_usingEmail() async throws {
114+
// let email = mockEmail()
115+
//
116+
// try await authClient.signInWithOTP(email: email)
117+
// try await authClient.verifyOTP(email: email, token: "123456", type: .magiclink)
118+
// }
117119

118120
func testSignOut_otherScope_shouldSignOutLocally() async throws {
119121
try await XCTAssertAuthChangeEvents([.initialSession, .signedIn]) {
@@ -170,7 +172,7 @@ final class AuthClientIntegrationTests: XCTestCase {
170172
func testUserIdentities() async throws {
171173
let session = try await signUpIfNeededOrSignIn(email: mockEmail(), password: mockPassword())
172174
let identities = try await authClient.userIdentities()
173-
expectNoDifference(session.user.identities, identities)
175+
expectNoDifference(session.user.identities?.map(\.identityId) ?? [], identities.map(\.identityId))
174176
}
175177

176178
func testUnlinkIdentity_withOnlyOneIdentity() async throws {
@@ -181,15 +183,8 @@ final class AuthClientIntegrationTests: XCTestCase {
181183
do {
182184
try await authClient.unlinkIdentity(identity)
183185
XCTFail("Expect failure")
184-
} catch {
185-
if let error = error as? AuthError {
186-
XCTAssertEqual(
187-
error.localizedDescription,
188-
"User must have at least 1 identity after unlinking"
189-
)
190-
} else {
191-
XCTFail("Unexpected error: \(error)")
192-
}
186+
} catch let error as AuthError {
187+
XCTAssertEqual(error.errorCode, .singleIdentityNotDeletable)
193188
}
194189
}
195190

@@ -246,8 +241,8 @@ final class AuthClientIntegrationTests: XCTestCase {
246241
func testLinkIdentity() async throws {
247242
try await signUpIfNeededOrSignIn(email: mockEmail(), password: mockPassword())
248243

249-
try await authClient.linkIdentity(provider: .github) { url in
250-
XCTAssertTrue(url.absoluteString.contains("github.com"))
244+
try await authClient.linkIdentity(provider: .apple) { url in
245+
XCTAssertTrue(url.absoluteString.contains("apple.com"))
251246
}
252247
}
253248

@@ -291,10 +286,10 @@ final class AuthClientIntegrationTests: XCTestCase {
291286
}
292287
}
293288

294-
private func mockEmail(length: Int = Int.random(in: 5 ... 10)) -> String {
289+
private func mockEmail(length: Int = Int.random(in: 5...10)) -> String {
295290
var username = ""
296-
for _ in 0 ..< length {
297-
let randomAscii = Int.random(in: 97 ... 122) // ASCII values for lowercase letters
291+
for _ in 0..<length {
292+
let randomAscii = Int.random(in: 97...122) // ASCII values for lowercase letters
298293
let randomCharacter = Character(UnicodeScalar(randomAscii)!)
299294
username.append(randomCharacter)
300295
}
@@ -303,13 +298,13 @@ final class AuthClientIntegrationTests: XCTestCase {
303298

304299
private func mockPhoneNumber() -> String {
305300
// Generate random country code (1 to 3 digits)
306-
let countryCode = String(format: "%d", Int.random(in: 1 ... 999))
301+
let countryCode = String(format: "%d", Int.random(in: 1...999))
307302

308303
// Generate random area code (3 digits)
309-
let areaCode = String(format: "%03d", Int.random(in: 100 ... 999))
304+
let areaCode = String(format: "%03d", Int.random(in: 100...999))
310305

311306
// Generate random subscriber number (7 digits)
312-
let subscriberNumber = String(format: "%07d", Int.random(in: 1000000 ... 9999999))
307+
let subscriberNumber = String(format: "%07d", Int.random(in: 1_000_000...9_999_999))
313308

314309
// Format the phone number in E.164 format
315310
let phoneNumber = "\(countryCode)\(areaCode)\(subscriberNumber)"
@@ -322,12 +317,14 @@ final class AuthClientIntegrationTests: XCTestCase {
322317
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+"
323318
var password = ""
324319

325-
for _ in 0 ..< length {
326-
let randomIndex = Int.random(in: 0 ..< allowedCharacters.count)
327-
let character = allowedCharacters[allowedCharacters.index(
328-
allowedCharacters.startIndex,
329-
offsetBy: randomIndex
330-
)]
320+
for _ in 0..<length {
321+
let randomIndex = Int.random(in: 0..<allowedCharacters.count)
322+
let character = allowedCharacters[
323+
allowedCharacters.index(
324+
allowedCharacters.startIndex,
325+
offsetBy: randomIndex
326+
)
327+
]
331328
password.append(character)
332329
}
333330

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Supabase
2+
.branches
3+
.temp
4+
5+
# dotenvx
6+
.env.keys
7+
.env.local
8+
.env.*.local
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v2.6.8
1+
v2.22.6

0 commit comments

Comments
 (0)