From c2fda6aae27248e5fade04d8777668593c261bc9 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 21:20:57 -0400 Subject: [PATCH] feat(uploads): fall back through the Blossom server list when an upload fails An upload went to the one server picked in the dialog and failed there. Which servers accept what isn't visible in advance: primal, azzamo and blossom.band answer an encrypted (application/octet-stream) blob with 415, the paid ones with 402, and any server can be down. So an encrypted chat attachment failed depending on which default happened to be selected. The Blossom settings screen already told users uploads 'try each server from the top down'; nothing did. Blossom uploads, plain and encrypted, now try the picked server first and then the rest of the list the picker offered (the user's kind-10063 servers, or the defaults when they have none), top down, skipping duplicates. An encrypted upload re-sends the same ciphertext, so its key, nonce and hash stay valid wherever it lands. A read-only login stops at once; if every server fails, the selected server's error is shown. NIP-96 and NIP-95 keep a single target. White Noise (MDK 0.10.4) fetches any safe https Blossom URL, not only the group policy's endpoints, so a fallback server is readable by it. The shared JVM UploadOrchestrator (desktop app and amy) gets the same behaviour: upload/uploadEncrypted take the account's other servers and try them in order; the desktop DM, note composer and profile-picture uploads pass the list. Its auth header isn't server-scoped, so one serves every attempt. Co-Authored-By: Claude Opus 5.5 --- .../service/uploads/UploadOrchestrator.kt | 39 +++++++++- .../model/mediaServers/UploadFallback.kt | 47 +++++++++++ .../model/mediaServers/UploadFallbackTest.kt | 55 +++++++++++++ .../service/upload/UploadOrchestrator.kt | 64 ++++++++++++--- .../amethyst/desktop/ui/ComposeNoteDialog.kt | 4 + .../amethyst/desktop/ui/chats/ChatPane.kt | 2 +- .../desktop/ui/profile/EditProfileScreen.kt | 5 +- .../service/upload/UploadOrchestratorTest.kt | 77 +++++++++++++++++++ 8 files changed, 276 insertions(+), 17 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallback.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallbackTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index 5728bab2e0..193a275694 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -25,6 +25,7 @@ import android.net.Uri import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType +import com.vitorpamplona.amethyst.commons.model.mediaServers.blossomUploadOrder import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.avif_metadata_strip_failed import com.vitorpamplona.amethyst.commons.resources.blossom_payment_required @@ -202,6 +203,32 @@ class UploadOrchestrator { } } + /** + * Tries [selected], then the rest of the servers the picker offered (see + * [blossomUploadOrder]) until one stores the blob. A failure that no other server would + * fix (a read-only login) stops at once. When every server fails, the error shown is the + * selected server's: that is the one the user chose and will recognize. + */ + private suspend fun uploadBlossomWithFallback( + selected: ServerName, + account: Account, + uploadTo: suspend (serverBaseUrl: String) -> UploadingFinalState, + ): UploadingFinalState { + val order = blossomUploadOrder(selected, account.blossomServers.hostNameFlow.value) + var firstError: UploadingState.Error? = null + for (server in order) { + when (val result = uploadTo(server.baseUrl)) { + is UploadingState.Finished -> return result + is UploadingState.Error -> { + if (firstError == null) firstError = result + if (result.errorResource == Res.string.login_with_a_private_key_to_be_able_to_upload) return result + Log.w("UploadOrchestrator", "Upload to ${server.baseUrl} failed, trying the next server") + } + } + } + return firstError!!.also { updateState(0.0, it) } + } + private suspend fun uploadBlossom( fileUri: Uri, contentType: String?, @@ -482,7 +509,10 @@ class UploadOrchestrator { return when (server.type) { ServerType.NIP95 -> uploadNIP95(finalUri, compressed.contentType, null, null, context) ServerType.NIP96 -> uploadNIP96(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, forcedSigner, context) - ServerType.Blossom -> uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, forcedSigner, context) + ServerType.Blossom -> + uploadBlossomWithFallback(server, account) { baseUrl -> + uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, baseUrl, null, null, account, forcedSigner, context) + } } } finally { deleteTempUri(finalUri, uri) @@ -527,7 +557,12 @@ class UploadOrchestrator { return when (server.type) { ServerType.NIP95 -> uploadNIP95(encrypted.uri, encrypted.contentType, compressed.contentType, encrypted.originalHash, context) ServerType.NIP96 -> uploadNIP96(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context) - ServerType.Blossom -> uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context) + // The same encrypted file goes to every server tried, so its key, nonce and + // hash stay valid whichever one ends up holding it. + ServerType.Blossom -> + uploadBlossomWithFallback(server, account) { baseUrl -> + uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context) + } } } finally { deleteTempUri(encrypted.uri, uri) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallback.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallback.kt new file mode 100644 index 0000000000..fd16921495 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallback.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.mediaServers + +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl + +/** + * The Blossom servers an upload tries, in order: the one the user picked, then the rest + * of the list the picker offered them (their kind-10063 servers, or the defaults when + * they have none), top down. + * + * Servers differ in what they accept, and nothing tells the user in advance: primal, + * azzamo and blossom.band answer an encrypted (application/octet-stream) blob with + * 415, some demand payment, some are simply down. With a single target, picking the + * wrong one meant a failed upload and a retry by hand; the settings screen already told + * users uploads "try each server from the top down". + * + * Blossom only: a NIP-96 or NIP-95 upload keeps its single target, because falling back + * from one would change the kind of event the upload produces. + */ +fun blossomUploadOrder( + selected: ServerName, + offered: List, +): List { + if (selected.type != ServerType.Blossom) return listOf(selected) + val seen = mutableSetOf(BlossomServerUrl.domain(selected.baseUrl)) + val rest = offered.filter { it.type == ServerType.Blossom && seen.add(BlossomServerUrl.domain(it.baseUrl)) } + return listOf(selected) + rest +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallbackTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallbackTest.kt new file mode 100644 index 0000000000..9aeaee304e --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/mediaServers/UploadFallbackTest.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.mediaServers + +import kotlin.test.Test +import kotlin.test.assertEquals + +class UploadFallbackTest { + private val band = ServerName("Nostr.Build", "https://blossom.band/") + private val primal = ServerName("Primal", "https://blossom.primal.net/") + private val yaki = ServerName("YakiHonne", "https://blossom.yakihonne.com/") + + @Test + fun `the picked server goes first and the rest follow in list order`() { + assertEquals(listOf(primal, band, yaki), blossomUploadOrder(primal, listOf(band, primal, yaki))) + } + + @Test + fun `a server is never tried twice, even under another spelling`() { + val primalAgain = ServerName("primal", "https://BLOSSOM.PRIMAL.NET") + assertEquals(listOf(primal, band), blossomUploadOrder(primal, listOf(primalAgain, band, band))) + } + + @Test + fun `a server picked from outside the list is still first`() { + val custom = ServerName("mine", "https://blobs.example.com") + assertEquals(listOf(custom, band), blossomUploadOrder(custom, listOf(band))) + } + + @Test + fun `non-Blossom uploads do not fall back`() { + val nip96 = ServerName("nostr.build", "https://nostr.build", ServerType.NIP96) + assertEquals(listOf(nip96), blossomUploadOrder(nip96, listOf(band, nip96))) + // Nor does a Blossom upload fall back onto a NIP-96 server. + assertEquals(listOf(band), blossomUploadOrder(band, listOf(nip96))) + } +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt index 9f28a12765..c8b47520d9 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt @@ -24,12 +24,15 @@ import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder.Reencode import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.ciphers.AESGCM import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext import java.io.File +import kotlin.coroutines.cancellation.CancellationException data class UploadResult( val blossom: BlossomUploadResult, @@ -76,6 +79,8 @@ class UploadOrchestrator( quality: CompressionQuality? = null, bypassReencode: Boolean = false, preCompressed: File? = null, + // Tried in order after [serverBaseUrl] fails; see [uploadToFirstAccepting]. + fallbackServerBaseUrls: List = emptyList(), ): UploadResult { var reencodedTemp: File? = null var strippedTemp: File? = null @@ -133,12 +138,14 @@ class UploadOrchestrator( // 5. Upload. val result = - client.upload( - file = finalFile, - contentType = metadata.mimeType, - serverBaseUrl = serverBaseUrl, - authHeader = authHeader, - ) + uploadToFirstAccepting(serverBaseUrl, fallbackServerBaseUrls) { server -> + client.upload( + file = finalFile, + contentType = metadata.mimeType, + serverBaseUrl = server, + authHeader = authHeader, + ) + } return UploadResult(blossom = result, metadata = metadata) } finally { @@ -171,6 +178,8 @@ class UploadOrchestrator( // When true it's uploaded with the real media type — less private, but // required by strict Blossom servers that reject octet-stream (HTTP 415). declareRealMimeType: Boolean = false, + // Tried in order after [serverBaseUrl] fails; see [uploadToFirstAccepting]. + fallbackServerBaseUrls: List = emptyList(), ): EncryptedUploadResult { var reencodedTemp: File? = null var strippedTemp: File? = null @@ -230,13 +239,17 @@ class UploadOrchestrator( // 415) also work, at the cost of leaking the media category. val uploadContentType = if (declareRealMimeType) metadata.mimeType else "application/octet-stream" + // The same ciphertext goes to every server tried, so the cipher and the + // encrypted hash stay valid wherever it lands. val result = - client.upload( - bytes = encrypted, - contentType = uploadContentType, - serverBaseUrl = serverBaseUrl, - authHeader = authHeader, - ) + uploadToFirstAccepting(serverBaseUrl, fallbackServerBaseUrls) { server -> + client.upload( + bytes = encrypted, + contentType = uploadContentType, + serverBaseUrl = server, + authHeader = authHeader, + ) + } return EncryptedUploadResult( blossom = result, @@ -251,4 +264,31 @@ class UploadOrchestrator( } } } + + /** + * [serverBaseUrl] first, then each of [fallbacks] (skipping duplicates by domain) until + * one accepts. Servers differ in what they take -- several answer an opaque encrypted + * blob with 415, paid ones with 402 -- and the user can't see that in advance. The auth + * header is not server-scoped, so the same one serves every attempt. When all fail, the + * first server's error is thrown: that is the server the user chose. + */ + private suspend fun uploadToFirstAccepting( + serverBaseUrl: String, + fallbacks: List, + upload: suspend (String) -> BlossomUploadResult, + ): BlossomUploadResult { + val seen = mutableSetOf() + val order = (listOf(serverBaseUrl) + fallbacks).filter { seen.add(BlossomServerUrl.domain(it)) } + var firstError: Exception? = null + for (server in order) { + try { + return upload(server) + } catch (e: Exception) { + if (e is CancellationException) throw e + if (firstError == null) firstError = e + Log.w("UploadOrchestrator") { "Upload to $server failed (${e.message}), trying the next server" } + } + } + throw firstError!! + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index fe7a29c773..c44212493f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -336,6 +336,7 @@ fun ComposeNoteDialog( file = file, alt = null, serverBaseUrl = selectedServer, + fallbackServerBaseUrls = effectiveServers, signer = account.signer, stripExif = stripExifSetting, quality = activeQuality, @@ -350,6 +351,7 @@ fun ComposeNoteDialog( file = file, alt = null, serverBaseUrl = selectedServer, + fallbackServerBaseUrls = effectiveServers, signer = account.signer, stripExif = stripExifSetting, quality = activeQuality, @@ -361,6 +363,7 @@ fun ComposeNoteDialog( file = file, alt = null, serverBaseUrl = selectedServer, + fallbackServerBaseUrls = effectiveServers, signer = account.signer, stripExif = stripExifSetting, quality = activeQuality, @@ -371,6 +374,7 @@ fun ComposeNoteDialog( file = file, alt = null, serverBaseUrl = selectedServer, + fallbackServerBaseUrls = effectiveServers, signer = account.signer, stripExif = stripExifSetting, quality = activeQuality, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index c34aea979b..cd139fe605 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -1410,7 +1410,7 @@ private suspend fun sendEncryptedFiles( // unsupported file can't abort the whole send. val fileQuality = if (file.extension.lowercase() in IMAGE_EXTENSIONS) quality else null val result = - orchestrator.uploadEncrypted(file, cipher, server, account.signer, stripExif, fileQuality, declareRealMimeType) + orchestrator.uploadEncrypted(file, cipher, server, account.signer, stripExif, fileQuality, declareRealMimeType, fallbackServerBaseUrls = blossomServers?.value.orEmpty()) val url = result.blossom.url ?: continue val template = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt index 317b36f1c4..44cb45d946 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt @@ -172,7 +172,8 @@ fun EditProfileDialog( } val orchestrator = remember { UploadOrchestrator() } - val serverBaseUrl = LocalBlossomServers.current?.value?.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER + val blossomServers = LocalBlossomServers.current + val serverBaseUrl = blossomServers?.value?.firstOrNull() ?: DEFAULT_BLOSSOM_SERVER fun uploadFile( file: File, @@ -182,7 +183,7 @@ fun EditProfileDialog( scope.launch(Dispatchers.IO) { setUploading(true) try { - val result = orchestrator.upload(file, null, serverBaseUrl, account.signer) + val result = orchestrator.upload(file, null, serverBaseUrl, account.signer, fallbackServerBaseUrls = blossomServers?.value.orEmpty()) result.blossom.url?.let { onUrl(it) } } catch (e: CancellationException) { throw e diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/UploadOrchestratorTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/UploadOrchestratorTest.kt index 04ba37a589..326b036f98 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/UploadOrchestratorTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/upload/UploadOrchestratorTest.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.service.upload.BlossomAuth import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult +import com.vitorpamplona.quartz.utils.ciphers.AESGCM import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -222,4 +223,80 @@ class UploadOrchestratorTest { file.delete() } } + + @Test + fun encryptedUploadFallsBackToTheNextServerWithTheSameCiphertext() = + runTest { + // A server that refuses opaque blobs (415) must not fail the upload while + // another listed server would take it -- and the retry has to send the very + // bytes the cipher and hash describe. + val mockClient = mockk() + val servers = mutableListOf() + val bodies = mutableListOf() + coEvery { + mockClient.upload(bytes = any(), contentType = any(), serverBaseUrl = any(), authHeader = any()) + } answers { + val server = arg(2) + servers += server + bodies += arg(0) + if (server.contains("primal")) { + throw IllegalStateException("Unsupported Media Type") + } + BlossomUploadResult(url = "$server/blob") + } + + val file = File.createTempFile("test_", ".bin") + file.deleteOnExit() + file.writeBytes(ByteArray(64) { it.toByte() }) + val mockSigner = mockk(relaxed = true) + + try { + val result = + UploadOrchestrator(mockClient).uploadEncrypted( + file = file, + cipher = AESGCM(), + serverBaseUrl = "https://blossom.primal.net", + signer = mockSigner, + fallbackServerBaseUrls = listOf("https://BLOSSOM.PRIMAL.NET/", "https://nostr.download"), + ) + + assertEquals(listOf("https://blossom.primal.net", "https://nostr.download"), servers) + assertTrue(bodies[0].contentEquals(bodies[1])) + assertEquals("https://nostr.download/blob", result.blossom.url) + } finally { + file.delete() + } + } + + @Test + fun whenEveryServerFailsTheSelectedServersErrorIsThrown() = + runTest { + val mockClient = mockk() + coEvery { + mockClient.upload(file = any(), contentType = any(), serverBaseUrl = any(), authHeader = any()) + } answers { throw IllegalStateException("refused by ${arg(2)}") } + + val file = File.createTempFile("test_", ".txt") + file.deleteOnExit() + file.writeText("content") + val mockSigner = mockk(relaxed = true) + + try { + val error = + kotlin + .runCatching { + UploadOrchestrator(mockClient).upload( + file = file, + alt = null, + serverBaseUrl = "https://a.example", + signer = mockSigner, + stripExif = false, + fallbackServerBaseUrls = listOf("https://b.example"), + ) + }.exceptionOrNull() + assertEquals("refused by https://a.example", error?.message) + } finally { + file.delete() + } + } }