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 ?? ""
});
}