From 0520ce3847ec8763db93cabc68b7ce8d25b926b4 Mon Sep 17 00:00:00 2001 From: Claudio Schaad Date: Mon, 13 Apr 2026 20:31:08 +0200 Subject: [PATCH] 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 --- Server/Program.cs | 15 +++++++++++++++ Server/ShiftScheduler.Server.csproj | 1 + Services/NextcloudCalendarService.cs | 12 +++++------- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/Server/Program.cs b/Server/Program.cs index 4a7e574..8ea29ab 100644 --- a/Server/Program.cs +++ b/Server/Program.cs @@ -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(new ConfigurationService(appConfiguration)); builder.Services.AddMemoryCache(); builder.Services.AddHttpClient(); +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(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/Server/ShiftScheduler.Server.csproj b/Server/ShiftScheduler.Server.csproj index 62dca14..0cacc7a 100644 --- a/Server/ShiftScheduler.Server.csproj +++ b/Server/ShiftScheduler.Server.csproj @@ -3,6 +3,7 @@ + diff --git a/Services/NextcloudCalendarService.cs b/Services/NextcloudCalendarService.cs index 06fb715..ff15a15 100644 --- a/Services/NextcloudCalendarService.cs +++ b/Services/NextcloudCalendarService.cs @@ -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 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,10 +189,8 @@ 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); + } } }