diff --git a/WeatherExtension.Tests/ConnectivityProbeTests.cs b/WeatherExtension.Tests/ConnectivityProbeTests.cs
new file mode 100644
index 0000000..495ba0d
--- /dev/null
+++ b/WeatherExtension.Tests/ConnectivityProbeTests.cs
@@ -0,0 +1,141 @@
+// Copyright (c) Bald Bearded Builder LLC
+// Bald Bearded Builder LLC licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CmdPal.Ext.Weather.Services;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Microsoft.CmdPal.Ext.Weather.UnitTests;
+
+[TestClass]
+public class ConnectivityProbeTests
+{
+ [TestMethod]
+ public async Task GetCurrentWeatherAsync_ApiReturns500_TriggersProbe()
+ {
+ var callCount = 0;
+ var handler = new CountingHttpHandler(request =>
+ {
+ callCount++;
+ return new HttpResponseMessage(HttpStatusCode.InternalServerError);
+ });
+
+ using var service = new OpenMeteoService(handler);
+ var result = await service.GetCurrentWeatherAsync(52.52, 13.41, ct: CancellationToken.None);
+
+ Assert.IsNull(result);
+ // 1 API call + 1 connectivity probe = 2
+ Assert.AreEqual(2, callCount, "Expected API call + connectivity probe");
+ }
+
+ [TestMethod]
+ public async Task GetCurrentWeatherAsync_ApiThrows_TriggersProbe()
+ {
+ var callCount = 0;
+ var handler = new CountingHttpHandler(request =>
+ {
+ callCount++;
+ throw new HttpRequestException("Connection refused");
+ });
+
+ using var service = new OpenMeteoService(handler);
+ var result = await service.GetCurrentWeatherAsync(52.52, 13.41, ct: CancellationToken.None);
+
+ Assert.IsNull(result);
+ // 1 failed API call + 1 probe attempt (also fails since same handler throws) = 2
+ Assert.AreEqual(2, callCount, "Expected API call + connectivity probe attempt");
+ }
+
+ [TestMethod]
+ public async Task GetForecastAsync_ApiReturns500_TriggersProbe()
+ {
+ var callCount = 0;
+ var handler = new CountingHttpHandler(request =>
+ {
+ callCount++;
+ return new HttpResponseMessage(HttpStatusCode.InternalServerError);
+ });
+
+ using var service = new OpenMeteoService(handler);
+ var result = await service.GetForecastAsync(52.52, 13.41, ct: CancellationToken.None);
+
+ Assert.IsNull(result);
+ Assert.AreEqual(2, callCount, "Expected API call + connectivity probe");
+ }
+
+ [TestMethod]
+ public async Task GetHourlyForecastAsync_ApiReturns500_TriggersProbe()
+ {
+ var callCount = 0;
+ var handler = new CountingHttpHandler(request =>
+ {
+ callCount++;
+ return new HttpResponseMessage(HttpStatusCode.InternalServerError);
+ });
+
+ using var service = new OpenMeteoService(handler);
+ var result = await service.GetHourlyForecastAsync(52.52, 13.41, ct: CancellationToken.None);
+
+ Assert.IsNull(result);
+ Assert.AreEqual(2, callCount, "Expected API call + connectivity probe");
+ }
+
+ [TestMethod]
+ public async Task GetCurrentWeatherAsync_ApiSucceeds_NoProbe()
+ {
+ var callCount = 0;
+ var handler = new CountingHttpHandler(request =>
+ {
+ callCount++;
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("{}"),
+ };
+ });
+
+ using var service = new OpenMeteoService(handler);
+ _ = await service.GetCurrentWeatherAsync(52.52, 13.41, ct: CancellationToken.None);
+
+ // Only the API call, no probe
+ Assert.AreEqual(1, callCount, "Successful API call should not trigger probe");
+ }
+
+ [TestMethod]
+ public async Task GetCurrentWeatherAsync_Cancelled_NoProbe()
+ {
+ var callCount = 0;
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ var handler = new CountingHttpHandler(request =>
+ {
+ callCount++;
+ throw new OperationCanceledException();
+ });
+
+ using var service = new OpenMeteoService(handler);
+
+ // HttpClient wraps OperationCanceledException as TaskCanceledException (a subtype).
+ // Use try/catch to accept any OperationCanceledException subtype.
+ Exception? thrown = null;
+ try
+ {
+ await service.GetCurrentWeatherAsync(52.52, 13.41, ct: cts.Token);
+ Assert.Fail("Expected OperationCanceledException to be thrown");
+ }
+ catch (OperationCanceledException ex)
+ {
+ thrown = ex;
+ }
+
+ Assert.IsNotNull(thrown, "Expected OperationCanceledException (or subtype)");
+ // Cancelled — probe should not fire
+ Assert.AreEqual(0, callCount, "Cancelled request should not trigger probe");
+ }
+}
+
diff --git a/WeatherExtension/Properties/Resources.Designer.cs b/WeatherExtension/Properties/Resources.Designer.cs
index dad66af..bdfb617 100644
--- a/WeatherExtension/Properties/Resources.Designer.cs
+++ b/WeatherExtension/Properties/Resources.Designer.cs
@@ -60,6 +60,51 @@ internal Resources() {
}
}
+ ///
+ /// Looks up a localized string similar to Weather API unreachable — may be blocked in your region.
+ ///
+ public static string connectivity_api_blocked {
+ get {
+ return ResourceManager.GetString("connectivity_api_blocked", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to current weather.
+ ///
+ public static string connectivity_endpoint_current_weather {
+ get {
+ return ResourceManager.GetString("connectivity_endpoint_current_weather", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to forecast.
+ ///
+ public static string connectivity_endpoint_forecast {
+ get {
+ return ResourceManager.GetString("connectivity_endpoint_forecast", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to hourly forecast.
+ ///
+ public static string connectivity_endpoint_hourly_forecast {
+ get {
+ return ResourceManager.GetString("connectivity_endpoint_hourly_forecast", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to No internet connection.
+ ///
+ public static string connectivity_no_internet {
+ get {
+ return ResourceManager.GetString("connectivity_no_internet", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Celsius.
///
diff --git a/WeatherExtension/Properties/Resources.resx b/WeatherExtension/Properties/Resources.resx
index 1cf54c5..87ada64 100644
--- a/WeatherExtension/Properties/Resources.resx
+++ b/WeatherExtension/Properties/Resources.resx
@@ -252,6 +252,21 @@ If you continue to have issues, please [file a bug](https://github.com/michaeljo
12 hours
+
+ No internet connection
+
+
+ Weather API unreachable — may be blocked in your region
+
+
+ current weather
+
+
+ forecast
+
+
+ hourly forecast
+
Favorites
diff --git a/WeatherExtension/Services/OpenMeteoService.cs b/WeatherExtension/Services/OpenMeteoService.cs
index f86aacf..bb57877 100644
--- a/WeatherExtension/Services/OpenMeteoService.cs
+++ b/WeatherExtension/Services/OpenMeteoService.cs
@@ -13,6 +13,7 @@ public sealed partial class OpenMeteoService : IWeatherService, IDisposable
{
private readonly HttpClient _httpClient;
private const string BaseUrl = "https://api.open-meteo.com/v1/forecast";
+ private const string ConnectivityProbeUrl = "https://connectivitycheck.gstatic.com/generate_204";
private const int CacheExpirationMinutes = 15;
private WeatherData? _cachedWeather;
@@ -36,6 +37,15 @@ public OpenMeteoService()
_httpClient.DefaultRequestHeaders.Add("User-Agent", "PowerToys-CmdPal-Weather/1.0");
}
+ internal OpenMeteoService(HttpMessageHandler handler)
+ {
+ _httpClient = new HttpClient(handler)
+ {
+ Timeout = TimeSpan.FromSeconds(10),
+ };
+ _httpClient.DefaultRequestHeaders.Add("User-Agent", "PowerToys-CmdPal-Weather/1.0");
+ }
+
public async Task GetCurrentWeatherAsync(
double latitude,
double longitude,
@@ -64,6 +74,7 @@ public OpenMeteoService()
WeatherLogger.LogToHost(
MessageState.Error,
$"Weather API returned status {response.StatusCode}");
+ await ProbeConnectivityAsync(Resources.connectivity_endpoint_current_weather, ct).ConfigureAwait(false);
return null;
}
@@ -86,11 +97,16 @@ public OpenMeteoService()
return weatherData;
}
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
catch (Exception ex)
{
WeatherLogger.LogToHost(
MessageState.Error,
$"Weather fetch error: {ex.Message}");
+ await ProbeConnectivityAsync(Resources.connectivity_endpoint_current_weather, ct).ConfigureAwait(false);
return null;
}
}
@@ -122,6 +138,7 @@ public OpenMeteoService()
WeatherLogger.LogToHost(
MessageState.Error,
$"Forecast API returned status {response.StatusCode}");
+ await ProbeConnectivityAsync(Resources.connectivity_endpoint_forecast, ct).ConfigureAwait(false);
return null;
}
@@ -144,11 +161,16 @@ public OpenMeteoService()
return forecastData;
}
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
catch (Exception ex)
{
WeatherLogger.LogToHost(
MessageState.Error,
$"Forecast fetch error: {ex.Message}");
+ await ProbeConnectivityAsync(Resources.connectivity_endpoint_forecast, ct).ConfigureAwait(false);
return null;
}
}
@@ -181,6 +203,7 @@ public OpenMeteoService()
WeatherLogger.LogToHost(
MessageState.Error,
$"Hourly forecast API returned status {response.StatusCode}");
+ await ProbeConnectivityAsync(Resources.connectivity_endpoint_hourly_forecast, ct).ConfigureAwait(false);
return null;
}
@@ -203,15 +226,61 @@ public OpenMeteoService()
return hourlyData;
}
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
catch (Exception ex)
{
WeatherLogger.LogToHost(
MessageState.Error,
$"Hourly forecast fetch error: {ex.Message}");
+ await ProbeConnectivityAsync(Resources.connectivity_endpoint_hourly_forecast, ct).ConfigureAwait(false);
return null;
}
}
+ private async Task ProbeConnectivityAsync(string failedEndpoint, CancellationToken ct)
+ {
+ try
+ {
+ using var probeRequest = new HttpRequestMessage(HttpMethod.Head, ConnectivityProbeUrl);
+ probeRequest.Headers.Add("User-Agent", "PowerToys-CmdPal-Weather/1.0");
+
+ using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ probeCts.CancelAfter(TimeSpan.FromSeconds(2));
+
+ var probeResponse = await _httpClient.SendAsync(probeRequest, probeCts.Token).ConfigureAwait(false);
+
+ if (probeResponse.StatusCode == System.Net.HttpStatusCode.NoContent)
+ {
+ // Got expected 204 — we have internet, weather API is specifically unreachable
+ WeatherLogger.LogToHost(
+ MessageState.Error,
+ $"{Resources.connectivity_api_blocked} ({failedEndpoint})");
+ }
+ else
+ {
+ // Non-204 response (e.g., captive portal redirect) — treat as no internet
+ WeatherLogger.LogToHost(
+ MessageState.Warning,
+ Resources.connectivity_no_internet);
+ }
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ // Caller cancelled — don't log, just return
+ return;
+ }
+ catch
+ {
+ // Probe failed (timeout or network error) — no internet connection
+ WeatherLogger.LogToHost(
+ MessageState.Warning,
+ Resources.connectivity_no_internet);
+ }
+ }
+
public void Dispose()
{
_httpClient?.Dispose();