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.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 2a6da074..b980ed76 100644 --- a/Core/Resgrid.Services/WeatherAlertService.cs +++ b/Core/Resgrid.Services/WeatherAlertService.cs @@ -238,9 +238,22 @@ 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); } + 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.IsPermanentFailure = 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 +281,11 @@ 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 && source.IsPermanentFailure) + continue; + // Check if it's time to poll based on interval if (source.LastPollUtc.HasValue) { 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/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..31bb6e0c 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs @@ -206,6 +206,11 @@ 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); var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); @@ -482,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 ?? "" }); }