From 8dfa342023413ce0f1fe6c59f37038dd564f7d99 Mon Sep 17 00:00:00 2001 From: Colton Date: Sun, 8 Nov 2026 12:42:34 -0500 Subject: [PATCH 1/2] Wait before retrying a download whose cache write failed, 5 s doubling to a minute When a write to the partial cache file fails (a full disk), BufferedIOSink.flush keeps its failed future and CacheDownloader cancels. The Downloader loop then exits without its 5 s delay, and download()'s while (true) loop started a new HTTP attempt at once: 1389 requests in 3 s to a loopback origin in the new test. download() now counts attempts in a row that ended on an error (one onError reported after the last onPosition). A retained stream waits 5 s after such an attempt, doubling to 60 s; an attempt that ends cleanly resets the count. test/e2e/write_failure_test.dart blocks the partial file with a directory: one request in 3 s, and once the blocker is removed the attempt after the wait completes the download. --- lib/src/cache_stream/http_cache_stream.dart | 22 ++++++++ test/e2e/write_failure_test.dart | 58 +++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 test/e2e/write_failure_test.dart diff --git a/lib/src/cache_stream/http_cache_stream.dart b/lib/src/cache_stream/http_cache_stream.dart index 66fb263..02ce5fc 100644 --- a/lib/src/cache_stream/http_cache_stream.dart +++ b/lib/src/cache_stream/http_cache_stream.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:math' show min; import 'package:http_cache_stream/src/cache_stream/cache_downloader/cache_downloader.dart'; import 'package:http_cache_stream/src/etc/extensions/future_extensions.dart'; @@ -206,6 +207,8 @@ class HttpCacheStream { await _ensureInit(); _checkDisposed(); + ///Attempts in a row that ended on an error, see [_retryDelay]. + int failedAttempts = 0; while (true) { final state = await refreshCacheState(); if (state.isComplete) { @@ -223,11 +226,14 @@ class HttpCacheStream { continue; } + ///The error this attempt ended on: one reported after the download last moved on. + Object? endError; try { final downloader = _cacheDownloader = CacheDownloader.construct(metadata, config); await downloader.download( onPosition: (position) { + endError = null; //It moved on, past any error before _updateCacheState( CacheState.incomplete(position, downloader.sourceLength)); while (_queuedRequests.isNotEmpty && @@ -251,6 +257,7 @@ class HttpCacheStream { }, onError: (e) { assert(e is! InvalidCacheException); + endError = e; _addError(e, closeRequests: true); }, ); @@ -263,13 +270,28 @@ class HttpCacheStream { } if (!isRetained) rethrow; await Future.delayed(const Duration(seconds: 5)); + continue; } finally { _cacheDownloader = null; } + + if (endError == null) { + failedAttempts = 0; + } else if (isRetained) { + await Future.delayed(_retryDelay(++failedAttempts)); + } } }); } + ///How long [download] waits after its [attempts]th attempt in a row that ended on an error: 5 s, + ///doubling to a minute. The downloader retries what the network does itself, after 5 s; an + ///attempt ends on an error when it gives up, as a write to the cache file that fails (a full + ///disk) gives it up, and without the wait the next attempt would request the source again at + ///once, and fail again. + static Duration _retryDelay(final int attempts) => + Duration(seconds: min(5 << min(attempts - 1, 4), 60)); + Future downloadHeaders({bool save = true}) { return _downloadHeadersFuture.run(() async { final latestHeaders = await CachedResponseHeaders.fromUrl( diff --git a/test/e2e/write_failure_test.dart b/test/e2e/write_failure_test.dart new file mode 100644 index 0000000..c6a44a6 --- /dev/null +++ b/test/e2e/write_failure_test.dart @@ -0,0 +1,58 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import '../support/harness.dart'; + +/// A download whose writes to the cache file fail (a full disk, here a +/// directory where the file goes) gives up its attempt. The next one waits, +/// rather than requesting the source again at once, again and again. +void main() { + late CacheTestHarness h; + + setUp(() async { + h = CacheTestHarness(); + await h.setUp(); + }); + + tearDown(() => h.tearDown()); + + test( + 'an attempt whose cache writes fail is retried after a wait, not at once', + () async { + final stream = h.manager.createStream(h.origin.url('/a.mp3')); + await Directory(stream.files.partial.path).create(recursive: true); + final errors = []; + final subscription = + stream.cacheStateStream.listen(null, onError: errors.add); + + stream.download().ignore(); + await Future.delayed(const Duration(seconds: 3)); + + expect(errors, isNotEmpty, reason: 'the failed write is reported'); + expect(h.origin.requestCount, 1, reason: 'the next attempt waits 5 s'); + await subscription.cancel(); + await stream.dispose(force: true); + }); + + test('the attempt after the wait downloads once the cache can be written', + () async { + final stream = h.manager.createStream(h.origin.url('/a.mp3')); + final blocker = Directory(stream.files.partial.path); + await blocker.create(recursive: true); + final failed = Completer(); + final subscription = stream.cacheStateStream.listen(null, onError: (_) { + if (!failed.isCompleted) failed.complete(); + }); + final download = stream.download(); + await failed.future.timeout(const Duration(seconds: 5)); + await blocker.delete(); + + final file = await download.timeout(const Duration(seconds: 15)); + expect(await file.length(), h.origin.payload.length); + expect(h.origin.requestCount, 2); + await subscription.cancel(); + await stream.dispose(force: true); + }); +} From 5987685690d3c9604577e1d8074c3fbd0f7526ca Mon Sep 17 00:00:00 2001 From: Colton Date: Sun, 8 Nov 2026 12:42:34 -0500 Subject: [PATCH 2/2] CHANGELOG and version bump to 0.3.1 0.3.0 is on pub.dev, so its section loses the (unreleased) label. --- CHANGELOG.md | 8 +++++++- pubspec.yaml | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f43a8e5..914b6a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,10 @@ -## 0.3.0 (unreleased) +## 0.3.1 + +### Fixes + +* Fixed a download whose write to the cache file failed (a full disk, say) requesting the source again at once, again and again, reporting an error each time. The next attempt now waits 5 s, doubling to a minute while attempts keep failing. + +## 0.3.0 ### New Features diff --git a/pubspec.yaml b/pubspec.yaml index 44a713c..f881abe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: http_cache_stream description: "Simultaneously download, cache, and stream remote content. Perfect for media players and any plugin that streams web content." -version: 0.3.0 +version: 0.3.1 homepage: https://github.com/Colton127/http_cache_stream repository: https://github.com/Colton127/http_cache_stream topics: