From 13a44f5775c7bb0314cd7195ebc239162633027b Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 12 May 2026 08:46:14 -0700 Subject: [PATCH 1/2] RE1-T118 NWS API fix --- .../PermanentWeatherAlertException.cs | 17 +++++++++++++++++ Core/Resgrid.Services/WeatherAlertService.cs | 17 +++++++++++++++++ .../NwsWeatherAlertProvider.cs | 12 ++++++------ .../Controllers/v4/WeatherAlertsController.cs | 4 ++++ 4 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 Core/Resgrid.Model/PermanentWeatherAlertException.cs diff --git a/Core/Resgrid.Model/PermanentWeatherAlertException.cs b/Core/Resgrid.Model/PermanentWeatherAlertException.cs new file mode 100644 index 00000000..03a22d4d --- /dev/null +++ b/Core/Resgrid.Model/PermanentWeatherAlertException.cs @@ -0,0 +1,17 @@ +using System; + +namespace Resgrid.Model +{ + /// + /// Thrown when a weather alert API request fails due to a permanent, non-retriable condition + /// (e.g. invalid NWS zone code). Unlike , callers + /// should mark the source as permanently failed and stop retrying until the user corrects the configuration. + /// + public class PermanentWeatherAlertException : Exception + { + public PermanentWeatherAlertException(string message) : base(message) { } + + public PermanentWeatherAlertException(string message, Exception innerException) + : base(message, innerException) { } + } +} diff --git a/Core/Resgrid.Services/WeatherAlertService.cs b/Core/Resgrid.Services/WeatherAlertService.cs index 2a6da074..b8ad1219 100644 --- a/Core/Resgrid.Services/WeatherAlertService.cs +++ b/Core/Resgrid.Services/WeatherAlertService.cs @@ -241,6 +241,17 @@ public async Task ProcessWeatherAlertSourceAsync(Guid sourceId, CancellationToke source.ErrorMessage = null; await _weatherAlertSourceRepository.UpdateAsync(source, ct, true); } + catch (PermanentWeatherAlertException ex) + { + // Permanent failures (invalid zone codes, etc.) — record the error + // and mark the source as failed. Don't rethrow so the poller continues + // without noisy Fatal logs. The source will be retried only after the + // user edits and saves it. + source.LastPollUtc = DateTime.UtcNow; + source.IsFailure = true; + source.ErrorMessage = ex.Message; + await _weatherAlertSourceRepository.UpdateAsync(source, ct, true); + } catch (TransientWeatherAlertException ex) { // Transient failures (rate-limit, server errors) — record the error @@ -268,6 +279,12 @@ public async Task ProcessAllActiveSourcesAsync(CancellationToken ct = default) foreach (var source in sources) { + // Skip sources with permanent failures (e.g. invalid zone codes). + // They will be retried only after the user edits and saves the source. + if (source.IsFailure && !string.IsNullOrEmpty(source.ErrorMessage) && + !source.ErrorMessage.StartsWith("Transient:")) + continue; + // Check if it's time to poll based on interval if (source.LastPollUtc.HasValue) { diff --git a/Providers/Resgrid.Providers.Weather/NwsWeatherAlertProvider.cs b/Providers/Resgrid.Providers.Weather/NwsWeatherAlertProvider.cs index 458c293d..b94e7c43 100644 --- a/Providers/Resgrid.Providers.Weather/NwsWeatherAlertProvider.cs +++ b/Providers/Resgrid.Providers.Weather/NwsWeatherAlertProvider.cs @@ -55,12 +55,12 @@ public async Task> FetchAlertsAsync(WeatherAlertSource source var stateCodes = zones.Where(z => z.Length == 2).ToArray(); var zoneCodes = zones.Where(z => z.Length > 2).ToArray(); - // Validate zone codes before calling the NWS API to produce a clear error - // instead of a cryptic 400 Bad Request from the upstream API. - var validationError = GetZoneValidationError(source.AreaFilter); - if (validationError != null) - throw new HttpRequestException( - $"Invalid NWS zone code in area filter for department {source.DepartmentId}: {validationError}"); + // Validate zone codes before calling the NWS API to produce a clear error + // instead of a cryptic 400 Bad Request from the upstream API. + var validationError = GetZoneValidationError(source.AreaFilter); + if (validationError != null) + throw new PermanentWeatherAlertException( + $"Invalid NWS zone code in area filter for department {source.DepartmentId}: {validationError}"); var queryParams = new List(); if (stateCodes.Length > 0) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs index f25a100e..96d003c8 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs @@ -206,6 +206,10 @@ public async Task> SaveSource([FromBo } } + // Clear any previous permanent failure so the source will be retried on next poll. + source.IsFailure = false; + source.ErrorMessage = null; + await _weatherAlertService.SaveSourceAsync(source); var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); From 4546687cbadcbe4dba8dcfd83932ce2d4f77dc13 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 12 May 2026 09:09:55 -0700 Subject: [PATCH 2/2] RE1-T118 PR#379 fixes --- Core/Resgrid.Model/WeatherAlertSource.cs | 2 ++ Core/Resgrid.Services/WeatherAlertService.cs | 5 +++-- ...IsPermanentFailureToWeatherAlertSources.cs | 19 +++++++++++++++++++ ...PermanentFailureToWeatherAlertSourcesPg.cs | 19 +++++++++++++++++++ .../Controllers/v4/WeatherAlertsController.cs | 2 ++ .../WeatherAlertSourceResultData.cs | 1 + .../User/Views/WeatherAlerts/Settings.cshtml | 1 + 7 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0068_AddIsPermanentFailureToWeatherAlertSources.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0068_AddIsPermanentFailureToWeatherAlertSourcesPg.cs diff --git a/Core/Resgrid.Model/WeatherAlertSource.cs b/Core/Resgrid.Model/WeatherAlertSource.cs index deda5c86..70d2b1a3 100644 --- a/Core/Resgrid.Model/WeatherAlertSource.cs +++ b/Core/Resgrid.Model/WeatherAlertSource.cs @@ -44,6 +44,8 @@ public class WeatherAlertSource : IEntity public bool IsFailure { get; set; } + public bool IsPermanentFailure { get; set; } + [MaxLength(2000)] public string ErrorMessage { get; set; } diff --git a/Core/Resgrid.Services/WeatherAlertService.cs b/Core/Resgrid.Services/WeatherAlertService.cs index b8ad1219..b980ed76 100644 --- a/Core/Resgrid.Services/WeatherAlertService.cs +++ b/Core/Resgrid.Services/WeatherAlertService.cs @@ -238,6 +238,7 @@ public async Task ProcessWeatherAlertSourceAsync(Guid sourceId, CancellationToke source.LastPollUtc = DateTime.UtcNow; source.LastSuccessUtc = DateTime.UtcNow; source.IsFailure = false; + source.IsPermanentFailure = false; source.ErrorMessage = null; await _weatherAlertSourceRepository.UpdateAsync(source, ct, true); } @@ -249,6 +250,7 @@ public async Task ProcessWeatherAlertSourceAsync(Guid sourceId, CancellationToke // user edits and saves it. source.LastPollUtc = DateTime.UtcNow; source.IsFailure = true; + source.IsPermanentFailure = true; source.ErrorMessage = ex.Message; await _weatherAlertSourceRepository.UpdateAsync(source, ct, true); } @@ -281,8 +283,7 @@ public async Task ProcessAllActiveSourcesAsync(CancellationToken ct = default) { // Skip sources with permanent failures (e.g. invalid zone codes). // They will be retried only after the user edits and saves the source. - if (source.IsFailure && !string.IsNullOrEmpty(source.ErrorMessage) && - !source.ErrorMessage.StartsWith("Transient:")) + if (source.IsFailure && source.IsPermanentFailure) continue; // Check if it's time to poll based on interval diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0068_AddIsPermanentFailureToWeatherAlertSources.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0068_AddIsPermanentFailureToWeatherAlertSources.cs new file mode 100644 index 00000000..8a71fa3c --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0068_AddIsPermanentFailureToWeatherAlertSources.cs @@ -0,0 +1,19 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + [Migration(68)] + public class M0068_AddIsPermanentFailureToWeatherAlertSources : Migration + { + public override void Up() + { + Alter.Table("WeatherAlertSources") + .AddColumn("IsPermanentFailure").AsBoolean().NotNullable().WithDefaultValue(false); + } + + public override void Down() + { + Delete.Column("IsPermanentFailure").FromTable("WeatherAlertSources"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0068_AddIsPermanentFailureToWeatherAlertSourcesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0068_AddIsPermanentFailureToWeatherAlertSourcesPg.cs new file mode 100644 index 00000000..679948ec --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0068_AddIsPermanentFailureToWeatherAlertSourcesPg.cs @@ -0,0 +1,19 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + [Migration(68)] + public class M0068_AddIsPermanentFailureToWeatherAlertSourcesPg : Migration + { + public override void Up() + { + Alter.Table("weatheralertsources") + .AddColumn("ispermanentfailure").AsBoolean().NotNullable().WithDefaultValue(false); + } + + public override void Down() + { + Delete.Column("ispermanentfailure").FromTable("weatheralertsources"); + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs index 96d003c8..31bb6e0c 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs @@ -208,6 +208,7 @@ public async Task> SaveSource([FromBo // Clear any previous permanent failure so the source will be retried on next poll. source.IsFailure = false; + source.IsPermanentFailure = false; source.ErrorMessage = null; await _weatherAlertService.SaveSourceAsync(source); @@ -486,6 +487,7 @@ private static WeatherAlertSourceResultData MapSourceToResultData(WeatherAlertSo LastPollUtc = source.LastPollUtc?.TimeConverterToString(department), LastSuccessUtc = source.LastSuccessUtc?.TimeConverterToString(department), IsFailure = source.IsFailure, + IsPermanentFailure = source.IsPermanentFailure, ErrorMessage = source.ErrorMessage }; } diff --git a/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertSourceResultData.cs b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertSourceResultData.cs index 6c632b4f..0ef232af 100644 --- a/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertSourceResultData.cs +++ b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertSourceResultData.cs @@ -14,6 +14,7 @@ public class WeatherAlertSourceResultData public string LastPollUtc { get; set; } public string LastSuccessUtc { get; set; } public bool IsFailure { get; set; } + public bool IsPermanentFailure { get; set; } public string ErrorMessage { get; set; } } } diff --git a/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Settings.cshtml b/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Settings.cshtml index efc76f5c..b53aa66f 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Settings.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Settings.cshtml @@ -165,6 +165,7 @@ areaFilter = source.AreaFilter ?? "", pollInterval = source.PollIntervalMinutes, active = source.Active, + isPermanentFailure = source.IsPermanentFailure, errorMessage = source.ErrorMessage ?? "" }); }