data: handle network exceptions elegantly

This commit is contained in:
Han Yin 2025-07-08 11:51:25 -07:00
parent 85434e6580
commit 7c2e24b4fe
3 changed files with 75 additions and 35 deletions

View File

@ -7,6 +7,8 @@ import android.util.Log
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.FileNotFoundException
import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -26,14 +28,14 @@ interface HuggingFaceRemoteDataSource {
direction: String? = "-1", direction: String? = "-1",
limit: Int? = SEARCH_RESULT_LIMIT, limit: Int? = SEARCH_RESULT_LIMIT,
full: Boolean = true, full: Boolean = true,
): List<HuggingFaceModel> ): Result<List<HuggingFaceModel>>
suspend fun getModelDetails(modelId: String): HuggingFaceModelDetails suspend fun getModelDetails(modelId: String): HuggingFaceModelDetails
/** /**
* Obtain selected HuggingFace model's GGUF file size from HTTP header * Obtain selected HuggingFace model's GGUF file size from HTTP header
*/ */
suspend fun getFileSize(modelId: String, filePath: String): Long? suspend fun getFileSize(modelId: String, filePath: String): Result<Long>
/** /**
* Download selected HuggingFace model's GGUF file via DownloadManager * Download selected HuggingFace model's GGUF file via DownloadManager
@ -57,14 +59,23 @@ class HuggingFaceRemoteDataSourceImpl @Inject constructor(
limit: Int?, limit: Int?,
full: Boolean, full: Boolean,
) = withContext(Dispatchers.IO) { ) = withContext(Dispatchers.IO) {
apiService.getModels( try {
search = query, apiService.getModels(
filter = filter, search = query,
sort = sort, filter = filter,
direction = direction, sort = sort,
limit = limit, direction = direction,
full = full, limit = limit,
).filter { it.gated != true && it.private != true && it.getGgufFilename() != null } full = full,
).filter {
it.gated != true && it.private != true && it.getGgufFilename() != null
}.let {
if (it.isEmpty()) Result.failure(FileNotFoundException()) else Result.success(it)
}
} catch (e: Exception) {
Log.e(TAG, "Error searching for models on HuggingFace: ${e.message}")
Result.failure(e)
}
} }
override suspend fun getModelDetails( override suspend fun getModelDetails(
@ -76,18 +87,26 @@ class HuggingFaceRemoteDataSourceImpl @Inject constructor(
override suspend fun getFileSize( override suspend fun getFileSize(
modelId: String, modelId: String,
filePath: String filePath: String
): Long? = withContext(Dispatchers.IO) { ): Result<Long> = withContext(Dispatchers.IO) {
try { try {
apiService.getModelFileHeader(modelId, filePath).let { apiService.getModelFileHeader(modelId, filePath).let { resp ->
if (it.isSuccessful) { if (resp.isSuccessful) {
it.headers()[HTTP_HEADER_CONTENT_LENGTH]?.toLongOrNull() resp.headers()[HTTP_HEADER_CONTENT_LENGTH]?.toLongOrNull()?.let {
Result.success(it)
} ?: Result.failure(IOException("Content-Length header missing"))
} else { } else {
null Result.failure(
when (resp.code()) {
401 -> SecurityException("Model requires authentication")
404 -> FileNotFoundException("Model file not found")
else -> IOException("Failed to get file info: HTTP ${resp.code()}")
}
)
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error getting file size for $modelId: ${e.message}") Log.e(TAG, "Error getting file size for $modelId: ${e.message}")
null Result.failure(e)
} }
} }

View File

@ -86,7 +86,7 @@ interface ModelRepository {
/** /**
* Search models on HuggingFace * Search models on HuggingFace
*/ */
suspend fun searchHuggingFaceModels(limit: Int = 20): List<HuggingFaceModel> suspend fun searchHuggingFaceModels(limit: Int = 20): Result<List<HuggingFaceModel>>
/** /**
* Obtain the model details from HuggingFace * Obtain the model details from HuggingFace
@ -96,7 +96,7 @@ interface ModelRepository {
/** /**
* Obtain the model's size from HTTP response header * Obtain the model's size from HTTP response header
*/ */
suspend fun getHuggingFaceModelFileSize(downloadInfo: HuggingFaceDownloadInfo): Long? suspend fun getHuggingFaceModelFileSize(downloadInfo: HuggingFaceDownloadInfo): Result<Long>
/** /**
* Download a HuggingFace model via system download manager * Download a HuggingFace model via system download manager
@ -351,7 +351,7 @@ class ModelRepositoryImpl @Inject constructor(
override suspend fun searchHuggingFaceModels( override suspend fun searchHuggingFaceModels(
limit: Int limit: Int
): List<HuggingFaceModel> = withContext(Dispatchers.IO) { ) = withContext(Dispatchers.IO) {
huggingFaceRemoteDataSource.searchModels(limit = limit) huggingFaceRemoteDataSource.searchModels(limit = limit)
} }
@ -363,7 +363,7 @@ class ModelRepositoryImpl @Inject constructor(
override suspend fun getHuggingFaceModelFileSize( override suspend fun getHuggingFaceModelFileSize(
downloadInfo: HuggingFaceDownloadInfo, downloadInfo: HuggingFaceDownloadInfo,
): Long? = withContext(Dispatchers.IO) { ): Result<Long> = withContext(Dispatchers.IO) {
huggingFaceRemoteDataSource.getFileSize(downloadInfo.modelId, downloadInfo.filename) huggingFaceRemoteDataSource.getFileSize(downloadInfo.modelId, downloadInfo.filename)
} }

View File

@ -38,6 +38,9 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.IOException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -246,11 +249,23 @@ class ModelsManagementViewModel @Inject constructor(
huggingFaceQueryJob = viewModelScope.launch { huggingFaceQueryJob = viewModelScope.launch {
_managementState.emit(Download.Querying) _managementState.emit(Download.Querying)
try { try {
val models = modelRepository.searchHuggingFaceModels() modelRepository.searchHuggingFaceModels().fold(
Log.d(TAG, "Fetched ${models.size} models from HuggingFace:") onSuccess = { models ->
_managementState.emit(Download.Ready(models)) Log.d(TAG, "Fetched ${models.size} models from HuggingFace:")
_managementState.emit(Download.Ready(models))
},
onFailure = { throw it }
)
} catch (_: CancellationException) { } catch (_: CancellationException) {
// no-op // no-op
} catch (_: UnknownHostException) {
_managementState.value = Download.Error(message = "No internet connection")
} catch (_: SocketTimeoutException) {
_managementState.value = Download.Error(message = "Connection timed out")
} catch (e: IOException) {
_managementState.value = Download.Error(message = "Network error: ${e.message}")
} catch (_: FileNotFoundException) {
_managementState.emit(Download.Error(message = "No eligible models"))
} catch (e: Exception) { } catch (e: Exception) {
_managementState.emit(Download.Error(message = e.message ?: "Unknown error")) _managementState.emit(Download.Error(message = e.message ?: "Unknown error"))
} }
@ -267,17 +282,24 @@ class ModelsManagementViewModel @Inject constructor(
val downloadInfo = model.toDownloadInfo() val downloadInfo = model.toDownloadInfo()
requireNotNull(downloadInfo) { "Download URL is missing!" } requireNotNull(downloadInfo) { "Download URL is missing!" }
val actualSize = modelRepository.getHuggingFaceModelFileSize(downloadInfo) modelRepository.getHuggingFaceModelFileSize(downloadInfo).fold(
requireNotNull(actualSize) { "Unknown model file size!" } onSuccess = { actualSize ->
Log.d(TAG, "Model file size: ${formatFileByteSize(actualSize)}") Log.d(TAG, "Model file size: ${formatFileByteSize(actualSize)}")
modelRepository.downloadHuggingFaceModel(downloadInfo, actualSize)
modelRepository.downloadHuggingFaceModel(downloadInfo, actualSize) .onSuccess { downloadId ->
.onSuccess { downloadId -> activeDownloads[downloadId] = model
activeDownloads[downloadId] = model _managementState.value = Download.Dispatched(downloadInfo)
_managementState.value = Download.Dispatched(downloadInfo) }
} .onFailure { throw it }
.onFailure { throw it } },
onFailure = { throw it }
)
} catch (_: UnknownHostException) {
_managementState.value = Download.Error(message = "No internet connection")
} catch (_: SocketTimeoutException) {
_managementState.value = Download.Error(message = "Connection timed out")
} catch (e: IOException) {
_managementState.value = Download.Error(message = "Network error: ${e.message}")
} catch (e: InsufficientStorageException) { } catch (e: InsufficientStorageException) {
_managementState.value = Download.Error( _managementState.value = Download.Error(
message = e.message ?: "Insufficient storage space to download ${model.modelId}", message = e.message ?: "Insufficient storage space to download ${model.modelId}",
@ -343,7 +365,6 @@ class ModelsManagementViewModel @Inject constructor(
companion object { companion object {
private val TAG = ModelsManagementViewModel::class.java.simpleName private val TAG = ModelsManagementViewModel::class.java.simpleName
private const val SUBSCRIPTION_TIMEOUT_MS = 5000L
private const val SUCCESS_RESET_TIMEOUT_MS = 1000L private const val SUCCESS_RESET_TIMEOUT_MS = 1000L
} }
} }