From 4002641f1cf59367f6cd3df7518a99f6e98e5098 Mon Sep 17 00:00:00 2001 From: Kendra Agents Bot <298386085+kendra-agents-bot@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:30:38 -0400 Subject: [PATCH] fix(mobile): retry legacy upload after transport reset Signed-off-by: Kendra Agents Bot <298386085+kendra-agents-bot@users.noreply.github.com> --- mobile/lib/shared/relay/media_upload.dart | 37 ++++++++++++------ .../test/shared/relay/media_upload_test.dart | 38 +++++++++++++++++++ 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index 3161a70362..fd78909e8e 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -502,24 +502,37 @@ class MediaUploadService { } final sha256 = _sha256Hex(bytes); - var response = await _sendUploadRequest( + Future uploadTo(String path) => _sendUploadRequest( bytes: bytes, mimeType: mimeType, sha256: sha256, - path: _mediaUploadPath, + path: path, onProgress: onProgress, cancellationToken: cancellationToken, ); - if (response.statusCode == HttpStatus.notFound || - response.statusCode == HttpStatus.methodNotAllowed) { - response = await _sendUploadRequest( - bytes: bytes, - mimeType: mimeType, - sha256: sha256, - path: _legacyMediaUploadPath, - onProgress: onProgress, - cancellationToken: cancellationToken, - ); + + late http.Response response; + var usedLegacyRoute = false; + try { + response = await uploadTo(_mediaUploadPath); + } on http.ClientException { + _throwIfCancelled(cancellationToken); + // Older self-hosted relays can reject /upload before consuming the + // streamed request body. Dart then reports the half-closed stream as a + // connection reset instead of exposing the 404/405 response. Blossom + // uploads are SHA-256 idempotent, so retrying the legacy alias is safe + // even if the first response was lost after the server stored the blob. + usedLegacyRoute = true; + response = await uploadTo(_legacyMediaUploadPath); + } on SocketException { + _throwIfCancelled(cancellationToken); + usedLegacyRoute = true; + response = await uploadTo(_legacyMediaUploadPath); + } + if (!usedLegacyRoute && + (response.statusCode == HttpStatus.notFound || + response.statusCode == HttpStatus.methodNotAllowed)) { + response = await uploadTo(_legacyMediaUploadPath); } if (response.statusCode < 200 || response.statusCode >= 300) { if (_allowedImageMimeTypes.contains(mimeType) && diff --git a/mobile/test/shared/relay/media_upload_test.dart b/mobile/test/shared/relay/media_upload_test.dart index 815f41c53a..046429da6d 100644 --- a/mobile/test/shared/relay/media_upload_test.dart +++ b/mobile/test/shared/relay/media_upload_test.dart @@ -470,6 +470,44 @@ void main() { }, ); + test( + 'retries the legacy upload route when the standard route resets', + () async { + final requests = []; + final client = http_testing.MockClient((request) async { + requests.add(request.url); + if (request.url.path == '/upload') { + throw http.ClientException('Connection reset by peer', request.url); + } + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/test.png', + 'sha256': request.headers['X-SHA-256'], + 'size': _pngBytes.length, + 'type': 'image/png', + 'uploaded': 1, + }), + HttpStatus.ok, + ); + }); + final service = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + httpClient: client, + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + ); + + final descriptor = await service.uploadBytes( + _pngBytes, + mimeType: 'image/png', + ); + + expect(descriptor.type, 'image/png'); + expect(requests.map((url) => url.path), ['/upload', '/media/upload']); + }, + ); + for (final statusCode in [ HttpStatus.unsupportedMediaType, HttpStatus.unprocessableEntity,