fix(mobile): stop media fetch stampede — stable cache keys, bounded decode, 429 cooldown (#2219)

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
Tyler
2026-07-21 07:28:46 -07:00
committed by GitHub
co-authored by npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d Tyler Longwell
parent 97b8b22ee9
commit 591bbbfa7e
9 changed files with 540 additions and 49 deletions
@@ -96,17 +96,14 @@ class _AttachmentStrip extends StatelessWidget {
),
),
)
: Consumer(
builder: (context, ref, _) => Image.network(
previewUrl,
headers: mediaGetHeadersFor(ref, previewUrl),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => ColoredBox(
color: context.colors.surface,
child: Icon(
LucideIcons.image,
color: context.colors.onSurfaceVariant,
),
: MediaImage(
url: previewUrl,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => ColoredBox(
color: context.colors.surface,
child: Icon(
LucideIcons.image,
color: context.colors.onSurfaceVariant,
),
),
),
@@ -274,12 +274,9 @@ class _MediaImageViewerPageState extends State<MediaImageViewerPage>
enabled: !_disableHeroOnDismiss,
child: Hero(
tag: widget.heroTag,
child: Image.network(
widget.imageUrl,
headers: mediaGetHeadersForContext(
context,
widget.imageUrl,
),
child: MediaImage(
url: widget.imageUrl,
boundDecodeToLayout: false,
fit: BoxFit.contain,
semanticLabel: widget.semanticLabel,
errorBuilder: (_, _, _) => const _MediaLoadFailure(
@@ -457,9 +454,8 @@ class _VideoLoadingPoster extends StatelessWidget {
fit: StackFit.expand,
children: [
if (posterUrl != null)
Image.network(
posterUrl!,
headers: mediaGetHeadersForContext(context, posterUrl!),
MediaImage(
url: posterUrl!,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => _videoPlaceholder(context),
)
@@ -254,9 +254,8 @@ class _MessageImagePreview extends HookConsumerWidget {
constraints: layout.constraints,
child: Hero(
tag: heroTag,
child: Image.network(
url,
headers: mediaGetHeadersFor(ref, url),
child: MediaImage(
url: url,
fit: layout.fit,
semanticLabel: semanticLabel,
errorBuilder: (_, _, _) => _MediaPreviewFallback(
@@ -297,9 +296,8 @@ class _MessageVideoPreview extends StatelessWidget {
fit: StackFit.expand,
children: [
if (posterUrl != null)
Image.network(
posterUrl,
headers: mediaGetHeadersForContext(context, posterUrl),
MediaImage(
url: posterUrl,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => const _MediaPreviewFallback(
icon: LucideIcons.video,
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:gpt_markdown/gpt_markdown.dart';
import 'package:gpt_markdown/custom_widgets/markdown_config.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
@@ -32,17 +31,15 @@ class CustomEmojiImage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final fallbackStyle = DefaultTextStyle.of(context).style;
return Consumer(
builder: (context, ref, _) => Image.network(
url,
headers: mediaGetHeadersFor(ref, url),
width: size,
height: size,
fit: BoxFit.contain,
filterQuality: FilterQuality.medium,
semanticLabel: ':$shortcode:',
errorBuilder: (_, _, _) => Text(':$shortcode:', style: fallbackStyle),
),
return MediaImage(
url: url,
width: size,
height: size,
decodeWidth: size,
fit: BoxFit.contain,
filterQuality: FilterQuality.medium,
semanticLabel: ':$shortcode:',
errorBuilder: (_, _, _) => Text(':$shortcode:', style: fallbackStyle),
);
}
}
+32 -3
View File
@@ -9,18 +9,30 @@ import 'relay_provider.dart';
const _mediaGetAuthKind = 24242;
const _mediaGetAuthLifetimeSeconds = 600;
/// Re-sign this long before the cached auth event expires, so an in-flight
/// request signed just before the boundary still lands well within validity.
const _mediaGetAuthRefreshMarginSeconds = 60;
/// Builds BUD-01 Blossom `t=get` auth headers for relay-host media URLs.
///
/// Returns an empty map for non-relay URLs or when no signing key is available,
/// so callers can safely use this on arbitrary profile/custom-emoji URLs without
/// leaking Buzz credentials to third-party hosts.
@immutable
///
/// The signed header is memoized until [_mediaGetAuthRefreshMarginSeconds]
/// before expiry: repeated calls return the byte-identical map instead of
/// producing a fresh Schnorr signature per widget build. The service itself is
/// rebuilt (dropping the memo) whenever the relay config — base URL or signing
/// identity — changes, via [mediaGetAuthServiceProvider].
class MediaGetAuthService {
final String _baseUrl;
final String? _nsec;
final DateTime Function() _now;
const MediaGetAuthService({
Map<String, String>? _cachedHeaders;
DateTime? _refreshAt;
MediaGetAuthService({
required String baseUrl,
required String? nsec,
DateTime Function()? now,
@@ -36,12 +48,29 @@ class MediaGetAuthService {
if (uri == null || relayUri == null) return const {};
if (!_isRelayMediaUrl(uri, relayUri)) return const {};
final cached = _cachedHeaders;
final refreshAt = _refreshAt;
if (cached != null && refreshAt != null && _now().isBefore(refreshAt)) {
return cached;
}
try {
final signedAt = _now();
final authEvent = _buildGetAuthEvent(nsec);
final encoded = base64Url
.encode(utf8.encode(authEvent.toJson()))
.replaceAll('=', '');
return {'Authorization': 'Nostr $encoded'};
final headers = Map<String, String>.unmodifiable({
'Authorization': 'Nostr $encoded',
});
_cachedHeaders = headers;
_refreshAt = signedAt.add(
const Duration(
seconds:
_mediaGetAuthLifetimeSeconds - _mediaGetAuthRefreshMarginSeconds,
),
);
return headers;
} catch (_) {
// Read auth is best-effort: while the relay rollout flag is off, an
// unsigned fetch still works. Once the flag is on, this request will 403
+238
View File
@@ -0,0 +1,238 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'media_auth.dart';
/// Shared, keep-alive HTTP client for media fetches. One client per container
/// so TLS connections are reused across images instead of re-handshaking per
/// fetch. Override in tests to stub the network.
final mediaHttpClientProvider = Provider<http.Client>((ref) {
final client = http.Client();
ref.onDispose(client.close);
return client;
});
/// An [ImageProvider] for relay-hosted (and arbitrary remote) media that fixes
/// the fetch-stampede failure modes of `Image.network` + per-build auth
/// headers:
///
/// 1. **Stable cache identity.** Flutter's [NetworkImage] includes the full
/// headers map in its `==`/`hashCode`, so a freshly signed Authorization
/// header on every widget build made the *same URL* a new cache key and
/// bypassed the in-memory [ImageCache] entirely. This provider keys on
/// (url, scale, auth scope) and injects auth headers at *fetch* time, so
/// header refreshes never invalidate cached images.
/// 2. **Failure cooldown.** A URL that fails to load is not retried until a
/// cooldown elapses (honoring `Retry-After` on 429), so error retries on
/// rebuild cannot amplify into a request storm against the rate limiter.
///
/// The auth scope (relay base URL + signing identity) is part of key equality:
/// switching relay or account can never reuse another identity's cached bytes.
class MediaImageProvider extends ImageProvider<MediaImageProvider> {
final String url;
final double scale;
final MediaGetAuthService auth;
/// Excluded from equality: transport, not identity.
final http.Client client;
const MediaImageProvider({
required this.url,
required this.auth,
required this.client,
this.scale = 1.0,
});
static const _defaultCooldown = Duration(seconds: 30);
static const _maxRetryAfter = Duration(minutes: 5);
static final Map<String, DateTime> _cooldownUntil = {};
/// Injectable clock for cooldown tests.
@visibleForTesting
static DateTime Function() debugNow = DateTime.now;
@visibleForTesting
static void debugResetCooldowns() => _cooldownUntil.clear();
@override
Future<MediaImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture<MediaImageProvider>(this);
}
@override
ImageStreamCompleter loadImage(
MediaImageProvider key,
ImageDecoderCallback decode,
) {
return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode),
scale: key.scale,
debugLabel: url,
);
}
Future<ui.Codec> _loadAsync(
MediaImageProvider key,
ImageDecoderCallback decode,
) async {
try {
final until = _cooldownUntil[url];
if (until != null) {
if (debugNow().isBefore(until)) {
throw MediaImageCooldownException(url: url, until: until);
}
_cooldownUntil.remove(url);
}
final uri = Uri.parse(url);
final http.Response response;
try {
response = await client.get(uri, headers: auth.headersFor(url));
} catch (_) {
_cooldownUntil[url] = debugNow().add(_defaultCooldown);
rethrow;
}
if (response.statusCode != 200) {
_cooldownUntil[url] = debugNow().add(_cooldownFor(response));
throw NetworkImageLoadException(
statusCode: response.statusCode,
uri: uri,
);
}
final bytes = response.bodyBytes;
if (bytes.isEmpty) {
_cooldownUntil[url] = debugNow().add(_defaultCooldown);
throw NetworkImageLoadException(statusCode: 200, uri: uri);
}
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
return decode(buffer);
} catch (_) {
// Match NetworkImage: make sure an errored key is not retained in the
// cache. The cooldown map (not the cache) throttles retries.
scheduleMicrotask(() {
PaintingBinding.instance.imageCache.evict(key);
});
rethrow;
}
}
Duration _cooldownFor(http.Response response) {
final retryAfter = int.tryParse(response.headers['retry-after'] ?? '');
if (retryAfter != null && retryAfter > 0) {
final requested = Duration(seconds: retryAfter);
return requested > _maxRetryAfter ? _maxRetryAfter : requested;
}
return _defaultCooldown;
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is MediaImageProvider &&
other.url == url &&
other.scale == scale &&
other.auth == auth;
}
@override
int get hashCode => Object.hash(url, scale, auth);
@override
String toString() =>
'MediaImageProvider("$url", scale: ${scale.toStringAsFixed(1)})';
}
/// Thrown when a fetch is suppressed because the URL recently failed.
class MediaImageCooldownException implements Exception {
final String url;
final DateTime until;
const MediaImageCooldownException({required this.url, required this.until});
@override
String toString() =>
'MediaImageCooldownException: $url is cooling down until $until';
}
/// Drop-in replacement for the media `Image.network` call sites.
///
/// Renders [url] through [MediaImageProvider] (stable cache key, fetch-time
/// auth, failure cooldown) and bounds the decode size so a handful of large
/// originals cannot thrash the global [ImageCache]:
///
/// - [decodeWidth] set: decode at that logical width (x device pixel ratio).
/// - otherwise, when [boundDecodeToLayout] is true (default): decode at the
/// layout width reported by [LayoutBuilder], when finite.
/// - [boundDecodeToLayout] false and no [decodeWidth]: full-resolution decode
/// (the zoomable full-screen viewer).
class MediaImage extends ConsumerWidget {
final String url;
final BoxFit? fit;
final double? width;
final double? height;
final String? semanticLabel;
final ImageErrorWidgetBuilder? errorBuilder;
final FilterQuality filterQuality;
final double? decodeWidth;
final bool boundDecodeToLayout;
const MediaImage({
super.key,
required this.url,
this.fit,
this.width,
this.height,
this.semanticLabel,
this.errorBuilder,
this.filterQuality = FilterQuality.medium,
this.decodeWidth,
this.boundDecodeToLayout = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final provider = MediaImageProvider(
url: url,
auth: ref.watch(mediaGetAuthServiceProvider),
client: ref.watch(mediaHttpClientProvider),
);
if (decodeWidth != null) {
return _image(provider, _physicalWidth(context, decodeWidth!));
}
if (!boundDecodeToLayout) {
return _image(provider, null);
}
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
final cacheWidth = maxWidth.isFinite && maxWidth > 0
? _physicalWidth(context, maxWidth)
: null;
return _image(provider, cacheWidth);
},
);
}
int _physicalWidth(BuildContext context, double logicalWidth) {
return (logicalWidth * MediaQuery.devicePixelRatioOf(context)).ceil();
}
Widget _image(MediaImageProvider provider, int? cacheWidth) {
return Image(
image: ResizeImage.resizeIfNeeded(cacheWidth, null, provider),
fit: fit,
width: width,
height: height,
semanticLabel: semanticLabel,
errorBuilder: errorBuilder,
filterQuality: filterQuality,
gaplessPlayback: true,
);
}
}
+1
View File
@@ -1,5 +1,6 @@
export 'app_lifecycle_provider.dart';
export 'media_auth.dart';
export 'media_image.dart';
export 'media_upload.dart';
export 'nostr_filters.dart';
export 'nostr_models.dart';
+4 -8
View File
@@ -3,7 +3,6 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../relay/relay.dart';
@@ -100,13 +99,10 @@ class _AvatarImageContentState extends State<AvatarImageContent> {
fit: widget.fit,
errorBuilder: (_, _, _) => centeredFallback,
),
_NetworkAvatarSource(:final url) => Consumer(
builder: (context, ref, _) => Image.network(
url,
headers: mediaGetHeadersFor(ref, url),
fit: widget.fit,
errorBuilder: (_, _, _) => centeredFallback,
),
_NetworkAvatarSource(:final url) => MediaImage(
url: url,
fit: widget.fit,
errorBuilder: (_, _, _) => centeredFallback,
),
null => centeredFallback,
};
@@ -0,0 +1,239 @@
import 'dart:convert';
import 'dart:async';
import 'package:buzz/shared/relay/media_auth.dart';
import 'package:buzz/shared/relay/media_image.dart';
import 'package:flutter/painting.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:nostr/nostr.dart' as nostr;
// Minimal valid 1x1 transparent PNG.
final _pngBytes = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAA'
'AAYAAjCB0C8AAAAASUVORK5CYII=',
);
const _relayBase = 'https://relay.example.com';
const _mediaUrl = '$_relayBase/media/abc123.png';
MediaGetAuthService _auth({String? nsec, DateTime Function()? now}) =>
MediaGetAuthService(baseUrl: _relayBase, nsec: nsec, now: now);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
MediaImageProvider.debugResetCooldowns();
MediaImageProvider.debugNow = DateTime.now;
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
});
group('MediaGetAuthService memoization', () {
test('repeated calls return byte-identical headers', () {
final nsec = nostr.Keys.generate().nsec;
final auth = _auth(nsec: nsec);
final first = auth.headersFor(_mediaUrl);
final second = auth.headersFor(_mediaUrl);
expect(first, isNotEmpty);
expect(identical(first, second), isTrue);
});
test('re-signs only at the refresh margin before expiry', () {
final nsec = nostr.Keys.generate().nsec;
var current = DateTime.utc(2026, 7, 21, 12);
final auth = _auth(nsec: nsec, now: () => current);
final first = auth.headersFor(_mediaUrl);
// 600s lifetime - 60s margin = re-sign boundary at +540s.
current = current.add(const Duration(seconds: 539));
expect(identical(auth.headersFor(_mediaUrl), first), isTrue);
current = current.add(const Duration(seconds: 2));
final refreshed = auth.headersFor(_mediaUrl);
expect(identical(refreshed, first), isFalse);
expect(refreshed['Authorization'], isNot(first['Authorization']));
});
test('non-relay URLs get no headers even with a key', () {
final nsec = nostr.Keys.generate().nsec;
final auth = _auth(nsec: nsec);
expect(auth.headersFor('https://elsewhere.com/media/abc.png'), isEmpty);
expect(auth.headersFor('$_relayBase/not-media/abc.png'), isEmpty);
});
});
group('MediaImageProvider cache identity', () {
test('equal url + same auth service => equal keys', () {
final auth = _auth(nsec: nostr.Keys.generate().nsec);
final client = http.Client();
addTearDown(client.close);
final a = MediaImageProvider(url: _mediaUrl, auth: auth, client: client);
final b = MediaImageProvider(url: _mediaUrl, auth: auth, client: client);
expect(a, equals(b));
expect(a.hashCode, equals(b.hashCode));
});
test('different auth service (relay/account switch) => unequal keys', () {
final client = http.Client();
addTearDown(client.close);
final a = MediaImageProvider(
url: _mediaUrl,
auth: _auth(nsec: nostr.Keys.generate().nsec),
client: client,
);
final b = MediaImageProvider(
url: _mediaUrl,
auth: _auth(nsec: nostr.Keys.generate().nsec),
client: client,
);
expect(a, isNot(equals(b)));
});
test('transport client is not part of identity', () {
final auth = _auth(nsec: nostr.Keys.generate().nsec);
final c1 = http.Client();
final c2 = http.Client();
addTearDown(c1.close);
addTearDown(c2.close);
expect(
MediaImageProvider(url: _mediaUrl, auth: auth, client: c1),
equals(MediaImageProvider(url: _mediaUrl, auth: auth, client: c2)),
);
});
});
group('MediaImageProvider fetching', () {
test('resolves one fetch for repeated resolves of the same key', () async {
var fetches = 0;
final client = http_testing.MockClient((request) async {
fetches += 1;
return http.Response.bytes(_pngBytes, 200);
});
final auth = _auth(nsec: nostr.Keys.generate().nsec);
for (var i = 0; i < 3; i++) {
final provider = MediaImageProvider(
url: _mediaUrl,
auth: auth,
client: client,
);
final completer = provider.resolve(ImageConfiguration.empty);
await _wait(completer);
}
expect(fetches, 1);
});
test('sends auth headers with the fetch', () async {
Map<String, String>? seen;
final client = http_testing.MockClient((request) async {
seen = request.headers;
return http.Response.bytes(_pngBytes, 200);
});
final auth = _auth(nsec: nostr.Keys.generate().nsec);
final provider = MediaImageProvider(
url: _mediaUrl,
auth: auth,
client: client,
);
await _wait(provider.resolve(ImageConfiguration.empty));
expect(seen?['Authorization'], startsWith('Nostr '));
});
test('failed URL is not refetched until cooldown elapses', () async {
var fetches = 0;
final client = http_testing.MockClient((request) async {
fetches += 1;
return http.Response('rate limited', 429);
});
final auth = _auth(nsec: nostr.Keys.generate().nsec);
var current = DateTime.utc(2026, 7, 21, 12);
MediaImageProvider.debugNow = () => current;
Future<Object?> attempt() async {
final provider = MediaImageProvider(
url: _mediaUrl,
auth: auth,
client: client,
);
return _waitError(provider.resolve(ImageConfiguration.empty));
}
expect(await attempt(), isA<NetworkImageLoadException>());
expect(fetches, 1);
// Within cooldown: suppressed, no network call.
expect(await attempt(), isA<MediaImageCooldownException>());
expect(fetches, 1);
// After cooldown: retried.
current = current.add(const Duration(seconds: 31));
expect(await attempt(), isA<NetworkImageLoadException>());
expect(fetches, 2);
});
test('honors Retry-After on 429 (capped)', () async {
var fetches = 0;
final client = http_testing.MockClient((request) async {
fetches += 1;
return http.Response(
'rate limited',
429,
headers: {'retry-after': '120'},
);
});
final auth = _auth(nsec: nostr.Keys.generate().nsec);
var current = DateTime.utc(2026, 7, 21, 12);
MediaImageProvider.debugNow = () => current;
Future<Object?> attempt() async {
final provider = MediaImageProvider(
url: _mediaUrl,
auth: auth,
client: client,
);
return _waitError(provider.resolve(ImageConfiguration.empty));
}
await attempt();
expect(fetches, 1);
current = current.add(const Duration(seconds: 60));
expect(await attempt(), isA<MediaImageCooldownException>());
expect(fetches, 1);
current = current.add(const Duration(seconds: 61));
await attempt();
expect(fetches, 2);
});
});
}
Future<void> _wait(ImageStream stream) {
final done = Completer<void>();
late final ImageStreamListener listener;
listener = ImageStreamListener(
(image, sync) {
image.dispose();
stream.removeListener(listener);
if (!done.isCompleted) done.complete();
},
onError: (error, stack) {
stream.removeListener(listener);
if (!done.isCompleted) done.completeError(error, stack);
},
);
stream.addListener(listener);
return done.future;
}
Future<Object?> _waitError(ImageStream stream) async {
try {
await _wait(stream);
return null;
} catch (e) {
return e;
}
}