Add upload retries and richer errors
This commit is contained in:
@@ -304,14 +304,14 @@ public partial class MainWindow : Window
|
|||||||
UpdateLiveStatus();
|
UpdateLiveStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnFailure(string path)
|
private void OnFailure(string path, string message)
|
||||||
{
|
{
|
||||||
_failedPaths.Add(path);
|
_failedPaths.Add(path);
|
||||||
Interlocked.Decrement(ref _uploadingCount);
|
Interlocked.Decrement(ref _uploadingCount);
|
||||||
Interlocked.Increment(ref _failedCount);
|
Interlocked.Increment(ref _failedCount);
|
||||||
UpdateUpload(path, UploadStatus.Failed);
|
UpdateUpload(path, UploadStatus.Failed);
|
||||||
UpdateStatusIfAllowed($"Upload fehlgeschlagen: {Path.GetFileName(path)}", true);
|
UpdateStatusIfAllowed($"Upload fehlgeschlagen: {Path.GetFileName(path)}", true);
|
||||||
SetLastError($"Upload fehlgeschlagen: {Path.GetFileName(path)}");
|
SetLastError($"{Path.GetFileName(path)} – {message}");
|
||||||
UpdateRetryButton();
|
UpdateRetryButton();
|
||||||
UpdateCountersText();
|
UpdateCountersText();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ namespace PhotoboothUploader.Services;
|
|||||||
public sealed class UploadService
|
public sealed class UploadService
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(20);
|
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(20);
|
||||||
|
private static readonly TimeSpan RetryBaseDelay = TimeSpan.FromSeconds(2);
|
||||||
|
private const int MaxRetries = 2;
|
||||||
private readonly Channel<string> _queue = Channel.CreateUnbounded<string>();
|
private readonly Channel<string> _queue = Channel.CreateUnbounded<string>();
|
||||||
private readonly ConcurrentDictionary<string, byte> _pending = new(StringComparer.OrdinalIgnoreCase);
|
private readonly ConcurrentDictionary<string, byte> _pending = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private string _userAgent = "FotospielPhotoboothUploader";
|
private string _userAgent = "FotospielPhotoboothUploader";
|
||||||
@@ -33,7 +35,7 @@ public sealed class UploadService
|
|||||||
Action<string> onQueued,
|
Action<string> onQueued,
|
||||||
Action<string> onUploading,
|
Action<string> onUploading,
|
||||||
Action<string> onSuccess,
|
Action<string> onSuccess,
|
||||||
Action<string> onFailure)
|
Action<string, string> onFailure)
|
||||||
{
|
{
|
||||||
Stop();
|
Stop();
|
||||||
|
|
||||||
@@ -69,7 +71,7 @@ public sealed class UploadService
|
|||||||
Action<string> onQueued,
|
Action<string> onQueued,
|
||||||
Action<string> onUploading,
|
Action<string> onUploading,
|
||||||
Action<string> onSuccess,
|
Action<string> onSuccess,
|
||||||
Action<string> onFailure,
|
Action<string, string> onFailure,
|
||||||
CancellationToken token)
|
CancellationToken token)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(settings.UploadUrl))
|
if (string.IsNullOrWhiteSpace(settings.UploadUrl))
|
||||||
@@ -89,18 +91,20 @@ public sealed class UploadService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
onUploading(path);
|
onUploading(path);
|
||||||
await WaitForFileReadyAsync(path, token);
|
var error = await UploadWithRetryAsync(client, settings, path, token);
|
||||||
await UploadAsync(client, settings, path, token);
|
if (error is null)
|
||||||
onSuccess(path);
|
{
|
||||||
|
onSuccess(path);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
onFailure(path, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
catch
|
|
||||||
{
|
|
||||||
onFailure(path);
|
|
||||||
}
|
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_pending.TryRemove(path, out _);
|
_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<string?> UploadWithRetryAsync(
|
||||||
|
HttpClient client,
|
||||||
|
PhotoboothSettings settings,
|
||||||
|
string path,
|
||||||
|
CancellationToken token)
|
||||||
{
|
{
|
||||||
var lastSize = -1L;
|
for (var attempt = 0; attempt <= MaxRetries; attempt++)
|
||||||
|
|
||||||
for (var attempts = 0; attempts < 10; attempts++)
|
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
var attemptError = await UploadOnceAsync(client, settings, path, token);
|
||||||
|
if (attemptError.Success)
|
||||||
if (!File.Exists(path))
|
|
||||||
{
|
{
|
||||||
await Task.Delay(500, token);
|
return null;
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var info = new FileInfo(path);
|
if (!attemptError.Retryable || attempt >= MaxRetries)
|
||||||
var size = info.Length;
|
|
||||||
|
|
||||||
if (size > 0 && size == lastSize)
|
|
||||||
{
|
{
|
||||||
return;
|
return attemptError.Error ?? "Upload fehlgeschlagen.";
|
||||||
}
|
}
|
||||||
|
|
||||||
lastSize = size;
|
await Task.Delay(GetRetryDelay(attempt), token);
|
||||||
await Task.Delay(700, token);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return "Upload fehlgeschlagen.";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task UploadAsync(HttpClient client, PhotoboothSettings settings, string path, CancellationToken token)
|
private static async Task<UploadAttempt> 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))
|
if (!File.Exists(path))
|
||||||
{
|
{
|
||||||
return;
|
return UploadAttempt.Fail("Datei nicht gefunden.", retryable: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
using var content = new MultipartFormDataContent();
|
using var content = new MultipartFormDataContent();
|
||||||
@@ -165,8 +177,61 @@ public sealed class UploadService
|
|||||||
fileContent.Headers.ContentType = new MediaTypeHeaderValue(ResolveContentType(path));
|
fileContent.Headers.ContentType = new MediaTypeHeaderValue(ResolveContentType(path));
|
||||||
content.Add(fileContent, "media", Path.GetFileName(path));
|
content.Add(fileContent, "media", Path.GetFileName(path));
|
||||||
|
|
||||||
var response = await client.PostAsync(settings.UploadUrl, content, token);
|
try
|
||||||
response.EnsureSuccessStatusCode();
|
{
|
||||||
|
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<string?> 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)
|
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<string?> 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)
|
private static int GetWorkerCount(PhotoboothSettings settings)
|
||||||
{
|
{
|
||||||
var count = settings.MaxConcurrentUploads;
|
var count = settings.MaxConcurrentUploads;
|
||||||
@@ -189,4 +283,11 @@ public sealed class UploadService
|
|||||||
|
|
||||||
return count > 5 ? 5 : count;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user