mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
Tyler Longwell
parent
97b8b22ee9
commit
591bbbfa7e
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user