diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 8c3855c1..628145da 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -3169,6 +3169,14 @@ final class ASRService: ObservableObject { if Task.isCancelled || Self.isModelPreparationCancellation(error) { throw CancellationError() } + if Self.isRateLimitOrNetworkError(error) { + DebugLogger.shared.warning( + "ASRService: Prepare failed for \(provider.name) due to rate limiting or a network error; " + + "skipping cache-clear recovery so cached files stay resumable: \(error)", + source: "ASRService" + ) + throw error + } firstError = error DebugLogger.shared.error("ASRService: First prepare attempt for \(provider.name) failed after \(String(format: "%.2f", Date().timeIntervalSince(start)))s", source: "ASRService") DebugLogger.shared.warning( @@ -3228,6 +3236,40 @@ final class ASRService: ObservableObject { return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled } + /// Rate-limit and transport failures must not trigger the cache-clear recovery: clearing the + /// cache forces a full re-download, which burns more of HuggingFace's fixed per-IP API window + /// and turns a transient 429 into a self-reinforcing failure loop (issue #683). + private nonisolated static func isRateLimitOrNetworkError(_ error: Error) -> Bool { + // Providers may rebox the download error, so walk the underlying-error chain. + var current: Error? = error + var depth = 0 + while let candidate = current, depth < 5 { + #if arch(arm64) + if let hfError = candidate as? DownloadUtils.HuggingFaceDownloadError { + switch hfError { + case .rateLimited, .htmlErrorResponse: + return true + case .invalidResponse, .downloadFailed, .modelNotFound: + return false + } + } + #endif + let nsError = candidate as NSError + if nsError.domain == NSURLErrorDomain { + return nsError.code != NSURLErrorCancelled + } + // The app's own HuggingFaceModelDownloader signals HTTP failures as + // NSError(domain: "HF", code: ) — treat its rate-limit statuses + // the same way so Nemotron/Cohere downloads never wipe cache on a 429. + if nsError.domain == "HF", nsError.code == 429 || nsError.code == 503 { + return true + } + current = nsError.userInfo[NSUnderlyingErrorKey] as? Error + depth += 1 + } + return false + } + // MARK: - Model lifecycle helpers (parity with original API) func predownloadSelectedModel() {