fix(avatar): retry the redirect fetch instead of giving up on one failure

You confirmed the whole upload chain works — the new avatar shows
instantly on Outline's web app — but this app kept showing the old/
placeholder picture in both Settings and the sidebar, which read the
same underlying session.userAvatarURL. Since that state is provably
correct (the same URL that works on web), the bug has to be in how
AvatarBadge loads it, not in the update itself.

Leading theory: a freshly-uploaded attachment's redirect URL can fail
on the very first request right after upload — self-hosted storage
behind a reverse proxy isn't necessarily instantly consistent —  and
AvatarBadge's .task(id:) only ever fires once per URL with no retry,
so a single transient failure right after uploading would leave the
placeholder showing forever even though the exact same URL works fine
moments later (which lines up with it looking fine on a fresh web
load). Now retries twice with a short delay and explicitly checks the
HTTP status before treating the body as image data, instead of
silently accepting whatever came back. Also drops any URLCache
involvement (.reloadIgnoringLocalCacheData) as a second, independent
possible cause, cheap to rule out at the same time.

Couldn't confirm this is the actual root cause without being able to
run the app — worth retesting.
This commit is contained in:
2026-08-15 16:37:44 +01:00
parent cb8bbd53c4
commit bde6be91ca
+26 -2
View File
@@ -41,11 +41,35 @@ struct AvatarBadge: View {
.task(id: avatarURL) { .task(id: avatarURL) {
loadedImage = nil loadedImage = nil
guard let avatarURL else { return } guard let avatarURL else { return }
guard let (data, _) = try? await URLSession.shared.data(from: avatarURL) else { return } loadedImage = await Self.loadImage(from: avatarURL)
loadedImage = PlatformImage(data: data)
} }
} }
/// A freshly-uploaded attachment's redirect URL can 404/fail on the very
/// first request right after upload (self-hosted reverse-proxied
/// storage, not necessarily instantly consistent) since `.task(id:)`
/// only ever runs once per URL and never retries on its own, a single
/// transient failure right after uploading a new avatar would leave the
/// placeholder showing forever, even though the exact same URL works
/// fine moments later. Two short-delayed retries before giving up.
private static func loadImage(from url: URL) async -> PlatformImage? {
var request = URLRequest(url: url)
request.cachePolicy = .reloadIgnoringLocalCacheData
for attempt in 0..<3 {
if attempt > 0 {
try? await Task.sleep(for: .milliseconds(400))
}
if let (data, response) = try? await URLSession.shared.data(for: request),
let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode),
let image = PlatformImage(data: data) {
return image
}
}
return nil
}
private func platformImage(_ image: PlatformImage) -> Image { private func platformImage(_ image: PlatformImage) -> Image {
#if os(macOS) #if os(macOS)
Image(nsImage: image) Image(nsImage: image)