fix: handle Nextcloud CalDAV 429 rate limiting with Polly retry

Replaces hand-rolled retry logic with Microsoft.Extensions.Http.Resilience
(Polly v8). The named 'nextcloud' HttpClient retries up to 3 times on 429
with exponential backoff (2s, 4s, 8s). Also adds a 300ms delay after every
individual PUT/DELETE request to reduce burst rate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Claudio Schaad 2026-04-13 20:31:08 +02:00
parent c4c67e2b73
commit 0520ce3847
3 changed files with 21 additions and 7 deletions

View file

@ -1,6 +1,9 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.Google;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Http.Resilience;
using Polly;
using System.Net;
using System.Security.Claims;
using ShiftScheduler.Services;
using ShiftScheduler.Shared;
@ -24,6 +27,18 @@ builder.Services.AddSingleton(authorizedEmails);
builder.Services.AddSingleton<IConfigurationService>(new ConfigurationService(appConfiguration));
builder.Services.AddMemoryCache();
builder.Services.AddHttpClient<TransportApiService>();
builder.Services.AddHttpClient("nextcloud")
.AddResilienceHandler("nextcloud-retry", pipeline =>
{
pipeline.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 3,
Delay = TimeSpan.FromSeconds(2),
BackoffType = DelayBackoffType.Exponential,
ShouldHandle = args => ValueTask.FromResult(
args.Outcome.Result?.StatusCode == HttpStatusCode.TooManyRequests)
});
});
builder.Services.AddSingleton<IcsExportService>();
builder.Services.AddSingleton<PdfExportService>();
builder.Services.AddSingleton<ITransportApiService, TransportApiService>();

View file

@ -3,6 +3,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="9.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="9.0.8" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.0.0" />
</ItemGroup>
<ItemGroup>

View file

@ -110,7 +110,7 @@ public class NextcloudCalendarService(
reportRequest.Headers.Add("Depth", "1");
reportRequest.Content = new StringContent(body, Encoding.UTF8, "application/xml");
var client = httpClientFactory.CreateClient();
var client = httpClientFactory.CreateClient("nextcloud");
var reportResponse = await client.SendAsync(reportRequest);
reportResponse.EnsureSuccessStatusCode();
@ -126,8 +126,7 @@ public class NextcloudCalendarService(
deleteResponse.StatusCode != System.Net.HttpStatusCode.NotFound)
deleteResponse.EnsureSuccessStatusCode();
if (eventUrls.Count > 1)
await Task.Delay(200);
await Task.Delay(300);
}
}
@ -155,7 +154,7 @@ public class NextcloudCalendarService(
private async Task CreateShiftEventsAsync(string calendarId, List<ShiftWithTransport> shifts)
{
var client = httpClientFactory.CreateClient();
var client = httpClientFactory.CreateClient("nextcloud");
foreach (var shiftWithTransport in shifts)
{
@ -176,6 +175,7 @@ public class NextcloudCalendarService(
var putRequest = CreateRequest(HttpMethod.Put, $"{calendarId}{Uri.EscapeDataString(uid)}.ics");
putRequest.Content = new StringContent(icsContent, Encoding.UTF8, "text/calendar");
(await client.SendAsync(putRequest)).EnsureSuccessStatusCode();
await Task.Delay(300);
}
if (!string.IsNullOrEmpty(shift.AfternoonTime))
@ -189,12 +189,10 @@ public class NextcloudCalendarService(
var putRequest = CreateRequest(HttpMethod.Put, $"{calendarId}{Uri.EscapeDataString(uid)}.ics");
putRequest.Content = new StringContent(icsContent, Encoding.UTF8, "text/calendar");
(await client.SendAsync(putRequest)).EnsureSuccessStatusCode();
}
if (shifts.Count > 1)
await Task.Delay(300);
}
}
}
private static string BuildIcsContent(string uid, CalendarEventData eventData, DateTimeOffset startTime, DateTimeOffset endTime)
{