diff --git a/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml.cs b/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml.cs index d007f86..dd41d3c 100644 --- a/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml.cs +++ b/clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml.cs @@ -304,14 +304,14 @@ public partial class MainWindow : Window UpdateLiveStatus(); } - private void OnFailure(string path) + private void OnFailure(string path, string message) { _failedPaths.Add(path); Interlocked.Decrement(ref _uploadingCount); Interlocked.Increment(ref _failedCount); UpdateUpload(path, UploadStatus.Failed); UpdateStatusIfAllowed($"Upload fehlgeschlagen: {Path.GetFileName(path)}", true); - SetLastError($"Upload fehlgeschlagen: {Path.GetFileName(path)}"); + SetLastError($"{Path.GetFileName(path)} – {message}"); UpdateRetryButton(); UpdateCountersText(); } diff --git a/clients/photobooth-uploader/PhotoboothUploader/Services/UploadService.cs b/clients/photobooth-uploader/PhotoboothUploader/Services/UploadService.cs index 107cb24..3076c02 100644 --- a/clients/photobooth-uploader/PhotoboothUploader/Services/UploadService.cs +++ b/clients/photobooth-uploader/PhotoboothUploader/Services/UploadService.cs @@ -14,6 +14,8 @@ namespace PhotoboothUploader.Services; public sealed class UploadService { private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(20); + private static readonly TimeSpan RetryBaseDelay = TimeSpan.FromSeconds(2); + private const int MaxRetries = 2; private readonly Channel _queue = Channel.CreateUnbounded(); private readonly ConcurrentDictionary _pending = new(StringComparer.OrdinalIgnoreCase); private string _userAgent = "FotospielPhotoboothUploader"; @@ -33,7 +35,7 @@ public sealed class UploadService Action onQueued, Action onUploading, Action onSuccess, - Action onFailure) + Action onFailure) { Stop(); @@ -69,7 +71,7 @@ public sealed class UploadService Action onQueued, Action onUploading, Action onSuccess, - Action onFailure, + Action onFailure, CancellationToken token) { if (string.IsNullOrWhiteSpace(settings.UploadUrl)) @@ -89,18 +91,20 @@ public sealed class UploadService try { onUploading(path); - await WaitForFileReadyAsync(path, token); - await UploadAsync(client, settings, path, token); - onSuccess(path); + var error = await UploadWithRetryAsync(client, settings, path, token); + if (error is null) + { + onSuccess(path); + } + else + { + onFailure(path, error); + } } catch (OperationCanceledException) { return; } - catch - { - onFailure(path); - } finally { _pending.TryRemove(path, out _); @@ -109,38 +113,46 @@ public sealed class UploadService } } - private static async Task WaitForFileReadyAsync(string path, CancellationToken token) + private static async Task UploadWithRetryAsync( + HttpClient client, + PhotoboothSettings settings, + string path, + CancellationToken token) { - var lastSize = -1L; - - for (var attempts = 0; attempts < 10; attempts++) + for (var attempt = 0; attempt <= MaxRetries; attempt++) { - token.ThrowIfCancellationRequested(); - - if (!File.Exists(path)) + var attemptError = await UploadOnceAsync(client, settings, path, token); + if (attemptError.Success) { - await Task.Delay(500, token); - continue; + return null; } - var info = new FileInfo(path); - var size = info.Length; - - if (size > 0 && size == lastSize) + if (!attemptError.Retryable || attempt >= MaxRetries) { - return; + return attemptError.Error ?? "Upload fehlgeschlagen."; } - lastSize = size; - await Task.Delay(700, token); + await Task.Delay(GetRetryDelay(attempt), token); } + + return "Upload fehlgeschlagen."; } - private static async Task UploadAsync(HttpClient client, PhotoboothSettings settings, string path, CancellationToken token) + private static async Task UploadOnceAsync( + HttpClient client, + PhotoboothSettings settings, + string path, + CancellationToken token) { + var readyError = await WaitForFileReadyAsync(path, token); + if (readyError is not null) + { + return UploadAttempt.Fail(readyError, retryable: false); + } + if (!File.Exists(path)) { - return; + return UploadAttempt.Fail("Datei nicht gefunden.", retryable: false); } using var content = new MultipartFormDataContent(); @@ -165,8 +177,61 @@ public sealed class UploadService fileContent.Headers.ContentType = new MediaTypeHeaderValue(ResolveContentType(path)); content.Add(fileContent, "media", Path.GetFileName(path)); - var response = await client.PostAsync(settings.UploadUrl, content, token); - response.EnsureSuccessStatusCode(); + try + { + var response = await client.PostAsync(settings.UploadUrl, content, token); + + if (response.IsSuccessStatusCode) + { + return UploadAttempt.Ok(); + } + + var body = await ReadResponseBodyAsync(response, token); + var status = $"{(int)response.StatusCode} {response.ReasonPhrase}".Trim(); + var message = string.IsNullOrWhiteSpace(body) ? status : $"{status} – {body}"; + return UploadAttempt.Fail(message, IsRetryableStatus(response.StatusCode)); + } + catch (TaskCanceledException) when (!token.IsCancellationRequested) + { + return UploadAttempt.Fail("Zeitüberschreitung beim Upload.", retryable: true); + } + catch (HttpRequestException) + { + return UploadAttempt.Fail("Netzwerkfehler beim Upload.", retryable: true); + } + catch (IOException) + { + return UploadAttempt.Fail("Datei konnte nicht gelesen werden.", retryable: false); + } + } + + private static async Task WaitForFileReadyAsync(string path, CancellationToken token) + { + var lastSize = -1L; + + for (var attempts = 0; attempts < 10; attempts++) + { + token.ThrowIfCancellationRequested(); + + if (!File.Exists(path)) + { + await Task.Delay(500, token); + continue; + } + + var info = new FileInfo(path); + var size = info.Length; + + if (size > 0 && size == lastSize) + { + return null; + } + + lastSize = size; + await Task.Delay(700, token); + } + + return "Datei ist noch in Bearbeitung."; } private static string ResolveContentType(string path) @@ -179,6 +244,35 @@ public sealed class UploadService }; } + private static bool IsRetryableStatus(System.Net.HttpStatusCode statusCode) + { + var numeric = (int)statusCode; + return numeric >= 500 || statusCode is System.Net.HttpStatusCode.RequestTimeout or System.Net.HttpStatusCode.TooManyRequests; + } + + private static TimeSpan GetRetryDelay(int attempt) + { + var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(100, 350)); + return TimeSpan.FromMilliseconds(RetryBaseDelay.TotalMilliseconds * Math.Pow(2, attempt)) + jitter; + } + + private static async Task ReadResponseBodyAsync(HttpResponseMessage response, CancellationToken token) + { + if (response.Content is null) + { + return null; + } + + var body = await response.Content.ReadAsStringAsync(token); + if (string.IsNullOrWhiteSpace(body)) + { + return null; + } + + body = body.Trim(); + return body.Length > 200 ? body[..200] + "…" : body; + } + private static int GetWorkerCount(PhotoboothSettings settings) { var count = settings.MaxConcurrentUploads; @@ -189,4 +283,11 @@ public sealed class UploadService return count > 5 ? 5 : count; } + + private readonly record struct UploadAttempt(bool Success, bool Retryable, string? Error) + { + public static UploadAttempt Ok() => new(true, false, null); + + public static UploadAttempt Fail(string error, bool retryable) => new(false, retryable, error); + } }