Refactor token refresh to better handle types of failures and retries during download

This commit is contained in:
advplyr
2026-09-06 17:53:46 -05:00
parent e06ce4a868
commit 71bd354bc7
2 changed files with 63 additions and 79 deletions
@@ -405,17 +405,26 @@ class DownloadItemManager(
private fun refreshTokenThenResume(serverConnectionConfigId: String) { private fun refreshTokenThenResume(serverConnectionConfigId: String) {
if (!refreshingServerIds.add(serverConnectionConfigId)) return if (!refreshingServerIds.add(serverConnectionConfigId)) return
apiHandler.refreshAuthTokens(serverConnectionConfigId) { newAccessToken -> apiHandler.refreshAuthTokens(serverConnectionConfigId) { result ->
synchronized(this@DownloadItemManager) { synchronized(this@DownloadItemManager) {
refreshingServerIds.remove(serverConnectionConfigId) refreshingServerIds.remove(serverConnectionConfigId)
if (newAccessToken.isNullOrEmpty()) { when (result) {
failParkedAuthParts(serverConnectionConfigId) is ApiHandler.RefreshResult.Success -> {
} else { AbsLogger.info(
AbsLogger.info( tag,
tag, "Token refresh succeeded; resuming downloads for $serverConnectionConfigId"
"Token refresh succeeded; resuming downloads for $serverConnectionConfigId" )
) checkUpdateDownloadQueue()
checkUpdateDownloadQueue() }
ApiHandler.RefreshResult.Rejected -> failParkedAuthParts(serverConnectionConfigId)
// Parked parts are still queued, so MAX_AUTH_RETRIES bounds the reattempts.
ApiHandler.RefreshResult.Transient -> {
AbsLogger.info(
tag,
"Token refresh could not be completed; retrying downloads for $serverConnectionConfigId"
)
checkUpdateDownloadQueue()
}
} }
} }
} }
@@ -167,7 +167,7 @@ class ApiHandler(var ctx:Context) {
* 2. Make a request to /auth/refresh endpoint with the refresh token * 2. Make a request to /auth/refresh endpoint with the refresh token
* 3. Update the stored tokens with the new access token * 3. Update the stored tokens with the new access token
* 4. Retry the original request with the new access token * 4. Retry the original request with the new access token
* 5. If refresh fails, handle logout * 5. If refresh fails, fail the request ([refreshAuthTokens] owns clearing the session)
* *
* @param originalRequest The original request that failed with 401 * @param originalRequest The original request that failed with 401
* @param httpClient The HTTP client to use for the request * @param httpClient The HTTP client to use for the request
@@ -175,27 +175,37 @@ class ApiHandler(var ctx:Context) {
*/ */
private fun handleTokenRefresh(originalRequest: Request, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) { private fun handleTokenRefresh(originalRequest: Request, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) {
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
refreshAuthTokens(serverConnectionConfigId, httpClient) { newAccessToken -> refreshAuthTokens(serverConnectionConfigId, httpClient) { result ->
if (newAccessToken.isNullOrEmpty()) { if (result is RefreshResult.Success) {
handleRefreshFailure(callback) retryOriginalRequest(originalRequest, result.accessToken, httpClient, callback)
} else { } else {
retryOriginalRequest(originalRequest, newAccessToken, httpClient, callback) callback(JSObject().put("error", "Authentication failed - login again"))
} }
} }
} }
sealed interface RefreshResult {
data class Success(val accessToken: String) : RefreshResult
/** The server rejected the refresh token, so the session has already been cleared. */
data object Rejected : RefreshResult
/** The refresh could not be completed. Credentials are untouched and the caller may retry. */
data object Transient : RefreshResult
}
/** Refreshes tokens for a specific saved server, including downloads queued while another server is active. */ /** Refreshes tokens for a specific saved server, including downloads queued while another server is active. */
fun refreshAuthTokens( fun refreshAuthTokens(
serverConnectionConfigId: String, serverConnectionConfigId: String,
httpClient: OkHttpClient? = null, httpClient: OkHttpClient? = null,
onResult: (String?) -> Unit onResult: (RefreshResult) -> Unit
) { ) {
val config = DeviceManager.getServerConnectionConfig(serverConnectionConfigId) val config = DeviceManager.getServerConnectionConfig(serverConnectionConfigId)
val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId) val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId)
if (config == null || refreshToken.isNullOrEmpty()) { if (config == null || refreshToken.isNullOrEmpty()) {
AbsLogger.error(tag, "No refresh token or server configuration for $serverConnectionConfigId") AbsLogger.error(tag, "No refresh token or server configuration for $serverConnectionConfigId")
handleDownloadRefreshFailure(serverConnectionConfigId) handleRefreshRejected(serverConnectionConfigId)
onResult(null) onResult(RefreshResult.Rejected)
return return
} }
val request = try { val request = try {
@@ -207,21 +217,25 @@ class ApiHandler(var ctx:Context) {
.build() .build()
} catch (e: Exception) { } catch (e: Exception) {
AbsLogger.error(tag, "Could not create refresh request for ${config.name}: ${e.message}") AbsLogger.error(tag, "Could not create refresh request for ${config.name}: ${e.message}")
onResult(null) onResult(RefreshResult.Transient)
return return
} }
(httpClient ?: defaultClient).newCall(request).enqueue(object : Callback { (httpClient ?: defaultClient).newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) { override fun onFailure(call: Call, e: IOException) {
AbsLogger.error(tag, "Token refresh failed for ${config.name}: ${e.message}") AbsLogger.error(tag, "Token refresh failed for ${config.name}: ${e.message}")
onResult(null) onResult(RefreshResult.Transient)
} }
override fun onResponse(call: Call, response: Response) { override fun onResponse(call: Call, response: Response) {
response.use { response.use {
if (!it.isSuccessful) { if (!it.isSuccessful) {
AbsLogger.error(tag, "Token refresh returned ${it.code} for ${config.name}") AbsLogger.error(tag, "Token refresh returned ${it.code} for ${config.name}")
if (it.code == 401 || it.code == 403) handleDownloadRefreshFailure(serverConnectionConfigId) if (it.code != 401 && it.code != 403) {
onResult(null) onResult(RefreshResult.Transient)
return
}
handleRefreshRejected(serverConnectionConfigId)
onResult(RefreshResult.Rejected)
return return
} }
try { try {
@@ -229,14 +243,14 @@ class ApiHandler(var ctx:Context) {
val accessToken = user?.optString("accessToken").orEmpty() val accessToken = user?.optString("accessToken").orEmpty()
if (accessToken.isEmpty()) { if (accessToken.isEmpty()) {
AbsLogger.error(tag, "Refresh response had no access token for ${config.name}") AbsLogger.error(tag, "Refresh response had no access token for ${config.name}")
onResult(null) onResult(RefreshResult.Transient)
return return
} }
updateTokens(accessToken, user?.optString("refreshToken").orEmpty().ifEmpty { refreshToken }, serverConnectionConfigId) updateTokens(accessToken, user?.optString("refreshToken").orEmpty().ifEmpty { refreshToken }, serverConnectionConfigId)
onResult(accessToken) onResult(RefreshResult.Success(accessToken))
} catch (e: Exception) { } catch (e: Exception) {
AbsLogger.error(tag, "Could not parse refresh response for ${config.name}: ${e.message}") AbsLogger.error(tag, "Could not parse refresh response for ${config.name}: ${e.message}")
onResult(null) onResult(RefreshResult.Transient)
} }
} }
} }
@@ -244,20 +258,25 @@ class ApiHandler(var ctx:Context) {
} }
/** /**
* Clears only the server whose refresh token failed; queued downloads can target a non-active server. * Clears only the server that rejected the refresh token; queued downloads can target a non-active server.
* *
* Only call this when the server explicitly rejected the refresh token. Transient failures should not log the user out * Only call this when the server explicitly rejected the refresh token. Transient failures should not log the user out
*/ */
private fun handleDownloadRefreshFailure(serverConnectionConfigId: String) { private fun handleRefreshRejected(serverConnectionConfigId: String) {
secureStorage.removeRefreshToken(serverConnectionConfigId) // Must not throw: callers still have to report the refresh result to an in-flight request or download.
if (DeviceManager.serverConnectionConfigId != serverConnectionConfigId) return try {
DeviceManager.serverConnectionConfig = null secureStorage.removeRefreshToken(serverConnectionConfigId)
DeviceManager.deviceData.lastServerConnectionConfigId = null if (DeviceManager.serverConnectionConfigId != serverConnectionConfigId) return
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) DeviceManager.serverConnectionConfig = null
if (checkAbsDatabaseNotifyListenersInitted()) { DeviceManager.deviceData.lastServerConnectionConfigId = null
absDatabaseNotifyListeners( DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
"onTokenRefreshFailure", if (checkAbsDatabaseNotifyListenersInitted()) {
JSObject().put("error", "Token refresh failed").put("serverConnectionConfigId", serverConnectionConfigId)) absDatabaseNotifyListeners(
"onTokenRefreshFailure",
JSObject().put("error", "Token refresh failed").put("serverConnectionConfigId", serverConnectionConfigId))
}
} catch (e: Exception) {
AbsLogger.error(tag, "Could not clear session for $serverConnectionConfigId: ${e.message}")
} }
} }
@@ -371,50 +390,6 @@ class ApiHandler(var ctx:Context) {
} }
} }
/**
* Handles the case when token refresh fails
* This will clear the current session and notify the callback
*
* @param callback The callback to return the error
*/
private fun handleRefreshFailure(callback: (JSObject) -> Unit) {
try {
Log.d(tag, "handleRefreshFailure: Token refresh failed, clearing session")
// Clear the current server connection
DeviceManager.serverConnectionConfig = null
DeviceManager.deviceData.lastServerConnectionConfigId = null
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
// Remove refresh token from secure storage
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
if (serverConnectionConfigId.isNotEmpty()) {
secureStorage.removeRefreshToken(serverConnectionConfigId)
}
val errorObj = JSObject()
errorObj.put("error", "Authentication failed - please login again")
callback(errorObj)
if (checkAbsDatabaseNotifyListenersInitted()) {
val tokenJsObject = JSObject()
tokenJsObject.put("error", "Token refresh failed")
if (serverConnectionConfigId.isNotEmpty()) {
tokenJsObject.put("serverConnectionConfigId", serverConnectionConfigId)
}
absDatabaseNotifyListeners("onTokenRefreshFailure", tokenJsObject)
} else {
// Can happen if Webview is never run
Log.i(tag, "AbsDatabaseNotifyListeners is not initialized so cannot send token refresh failure notification")
}
} catch (e: Exception) {
Log.e(tag, "handleRefreshFailure: Error during failure handling", e)
val errorObj = JSObject()
errorObj.put("error", "Authentication failed")
callback(errorObj)
}
}
fun getCurrentUser(cb: (User?) -> Unit) { fun getCurrentUser(cb: (User?) -> Unit) {
getRequest("/api/me", null, null) { getRequest("/api/me", null, null) {
if (it.has("error")) { if (it.has("error")) {