Implement error handling and notification

This commit is contained in:
Alexander Horner
2022-12-07 23:20:36 +00:00
parent 1e1187e71f
commit ecbde9509f
3 changed files with 188 additions and 68 deletions

View File

@@ -19,10 +19,29 @@ namespace ntfysh_client
{ {
_notificationListener = notificationListener; _notificationListener = notificationListener;
_notificationListener.OnNotificationReceive += OnNotificationReceive; _notificationListener.OnNotificationReceive += OnNotificationReceive;
_notificationListener.OnConnectionMultiAttemptFailure += OnConnectionMultiAttemptFailure;
_notificationListener.OnConnectionCredentialsFailure += OnConnectionCredentialsFailure;
InitializeComponent(); InitializeComponent();
} }
private void OnNotificationReceive(object sender, NotificationReceiveEventArgs e)
{
notifyIcon.ShowBalloonTip(3000, e.Title, e.Message, ToolTipIcon.Info);
}
private void OnConnectionMultiAttemptFailure(NotificationListener sender, SubscribedTopic topic)
{
MessageBox.Show($"Connecting to topic ID '{topic.TopicId}' on server '{topic.ServerUrl}' failed after multiple attempts.\n\nThis topic ID will be ignored and you will not receive notifications for it until you restart the application.", "Connection Failure", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
private void OnConnectionCredentialsFailure(NotificationListener sender, SubscribedTopic topic)
{
string reason = string.IsNullOrWhiteSpace(topic.Username) ? "credentials are required but were not provided" : "the entered credentials are incorrect";
MessageBox.Show($"Connecting to topic ID '{topic.TopicId}' on server '{topic.ServerUrl}' failed because {reason}.\n\nThis topic ID will be ignored and you will not receive notifications for it until you correct the credentials.", "Connection Authentication Failure", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
private void MainForm_Load(object sender, EventArgs e) => LoadTopics(); private void MainForm_Load(object sender, EventArgs e) => LoadTopics();
private void subscribeNewTopic_Click(object sender, EventArgs e) private void subscribeNewTopic_Click(object sender, EventArgs e)
@@ -137,7 +156,7 @@ namespace ntfysh_client
} }
//Assemble new format //Assemble new format
List<SubscribedTopic> newTopics = legacyTopics.Select(lt => new SubscribedTopic(lt, "https://ntfy.sh", null, null, null, null)).ToList(); List<SubscribedTopic> newTopics = legacyTopics.Select(lt => new SubscribedTopic(lt, "https://ntfy.sh", null, null)).ToList();
string newFormatSerialised = JsonConvert.SerializeObject(newTopics, Formatting.Indented); string newFormatSerialised = JsonConvert.SerializeObject(newTopics, Formatting.Indented);
@@ -196,11 +215,6 @@ namespace ntfysh_client
} }
} }
private void OnNotificationReceive(object sender, NotificationReceiveEventArgs e)
{
notifyIcon.ShowBalloonTip(3000, e.Title, e.Message, ToolTipIcon.Info);
}
private void MainForm_FormClosed(object sender, FormClosedEventArgs e) private void MainForm_FormClosed(object sender, FormClosedEventArgs e)
{ {
notifyIcon.Dispose(); notifyIcon.Dispose();

View File

@@ -15,33 +15,52 @@ using System.Web;
namespace ntfysh_client namespace ntfysh_client
{ {
public class NotificationListener : IDisposable public class NotificationListener
{ {
private readonly HttpClient _httpClient = new();
private bool _isDisposed;
public readonly Dictionary<string, SubscribedTopic?> SubscribedTopicsByUnique = new(); public readonly Dictionary<string, SubscribedTopic?> SubscribedTopicsByUnique = new();
public delegate void NotificationReceiveHandler(object sender, NotificationReceiveEventArgs e); public delegate void NotificationReceiveHandler(NotificationListener sender, NotificationReceiveEventArgs e);
public event NotificationReceiveHandler? OnNotificationReceive; public event NotificationReceiveHandler? OnNotificationReceive;
public delegate void ConnectionErrorHandler(NotificationListener sender, SubscribedTopic topic);
public event ConnectionErrorHandler? OnConnectionMultiAttemptFailure;
public event ConnectionErrorHandler? OnConnectionCredentialsFailure;
public NotificationListener() public NotificationListener()
{ {
_httpClient.Timeout = TimeSpan.FromMilliseconds(Timeout.Infinite);
ServicePointManager.DefaultConnectionLimit = 100; ServicePointManager.DefaultConnectionLimit = 100;
} }
private async Task ListenToTopicWithHttpLongJsonAsync(HttpRequestMessage message, CancellationToken cancellationToken) private async Task ListenToTopicWithHttpLongJsonAsync(HttpRequestMessage message, CancellationToken cancellationToken, SubscribedTopic topic)
{ {
if (_isDisposed) throw new ObjectDisposedException(nameof(NotificationListener)); int connectionAttempts = 0;
while (!cancellationToken.IsCancellationRequested) while (!cancellationToken.IsCancellationRequested)
{ {
using HttpResponseMessage response = await _httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); //See if we have exceeded maximum attempts
await using Stream body = await response.Content.ReadAsStreamAsync(cancellationToken); if (connectionAttempts >= 10)
{
//10 connection failures (1 initial + 9 reattempts)! Do not retry
OnConnectionMultiAttemptFailure?.Invoke(this, topic);
return;
}
try try
{ {
//Establish connection
using HttpClient client = new();
client.Timeout = TimeSpan.FromMilliseconds(Timeout.Infinite); //This will not prevent us from failing to connect, luckily
using HttpResponseMessage response = await client.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
await using Stream body = await response.Content.ReadAsStreamAsync(cancellationToken);
//Ensure successful connect
response.EnsureSuccessStatusCode();
//Reset connection attempts after a successful connect
connectionAttempts = 0;
//Begin listening
StringBuilder mainBuffer = new(); StringBuilder mainBuffer = new();
while (!cancellationToken.IsCancellationRequested) while (!cancellationToken.IsCancellationRequested)
@@ -73,32 +92,82 @@ namespace ntfysh_client
mainBuffer.Append(partialLine); mainBuffer.Append(partialLine);
} }
} }
catch (Exception ex) catch (HttpRequestException hre)
{ {
if (hre.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
//Our credentials either aren't present when they need to be or are invalid
//Credential Failure! Do not retry
OnConnectionCredentialsFailure?.Invoke(this, topic);
return;
}
#if DEBUG #if DEBUG
Debug.WriteLine(ex); Debug.WriteLine(hre);
#endif #endif
//Fall back to the outer loop to restart the listen, or cancel if requested //We will not hit the finally block which will increment the connection failure counter and attempt a reconnect if applicable
}
catch (Exception e)
{
#if DEBUG
Debug.WriteLine(e);
#endif
//We will not hit the finally block which will increment the connection failure counter and attempt a reconnect if applicable
}
finally
{
//We land here if we fail to connect or our connection gets closed (and if we are canceeling, but that gets ignored)
if (!cancellationToken.IsCancellationRequested)
{
//Not cancelling, legitimate connection failure or termination
if (connectionAttempts != 0)
{
//On our first reconnect attempt, try instantly. On consecutive, wait 3 seconds before each attempt
await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken);
}
//Increment attempts
connectionAttempts++;
//Proceed to reattempt
}
} }
} }
} }
private async Task ListenToTopicWithWebsocketAsync(Uri uri, NetworkCredential credentials, CancellationToken cancellationToken) private async Task ListenToTopicWithWebsocketAsync(Uri uri, NetworkCredential credentials, CancellationToken cancellationToken, SubscribedTopic topic)
{ {
if (_isDisposed) throw new ObjectDisposedException(nameof(NotificationListener)); int connectionAttempts = 0;
while (!cancellationToken.IsCancellationRequested) while (!cancellationToken.IsCancellationRequested)
{ {
using ClientWebSocket socket = new(); //See if we have exceeded maximum attempts
socket.Options.Credentials = credentials; if (connectionAttempts >= 10)
{
//10 connection failures (1 initial + 9 reattempts)! Do not retry
OnConnectionMultiAttemptFailure?.Invoke(this, topic);
return;
}
try try
{ {
StringBuilder mainBuffer = new(); //Establish connection
using ClientWebSocket socket = new();
socket.Options.Credentials = credentials;
await socket.ConnectAsync(uri, cancellationToken); await socket.ConnectAsync(uri, cancellationToken);
//Reset connection attempts after a successful connect
connectionAttempts = 0;
//Begin listening
StringBuilder mainBuffer = new();
while (!cancellationToken.IsCancellationRequested) while (!cancellationToken.IsCancellationRequested)
{ {
//Read as much as possible //Read as much as possible
@@ -126,13 +195,50 @@ namespace ntfysh_client
mainBuffer.Append(partialLine); mainBuffer.Append(partialLine);
} }
} }
catch (Exception ex) catch (WebSocketException wse)
{ {
if (wse.WebSocketErrorCode is WebSocketError.NotAWebSocket)
{
//We haven't achieved a connection with a websocket. TODO Seems ntfy doesn't report unauthorised properly, and responds 200
//Credential Failure! Do not retry
OnConnectionCredentialsFailure?.Invoke(this, topic);
return;
}
#if DEBUG #if DEBUG
Debug.WriteLine(ex); Debug.WriteLine(wse);
#endif #endif
//Fall back to the outer loop to restart the listen, or cancel if requested //We will not hit the finally block which will increment the connection failure counter and attempt a reconnect if applicable
}
catch (Exception e)
{
#if DEBUG
Debug.WriteLine(e);
#endif
//We will not hit the finally block which will increment the connection failure counter and attempt a reconnect if applicable
}
finally
{
//We land here if we fail to connect or our connection gets closed (and if we are canceeling, but that gets ignored)
if (!cancellationToken.IsCancellationRequested)
{
//Not cancelling, legitimate connection failure or termination
if (connectionAttempts != 0)
{
//On our first reconnect attempt, try instantly. On consecutive, wait 3 seconds before each attempt
await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken);
}
//Increment attempts
connectionAttempts++;
//Proceed to reattempt
}
} }
} }
} }
@@ -156,8 +262,6 @@ namespace ntfysh_client
public void SubscribeToTopicUsingLongHttpJson(string unique, string topicId, string serverUrl, string? username, string? password) public void SubscribeToTopicUsingLongHttpJson(string unique, string topicId, string serverUrl, string? username, string? password)
{ {
if (_isDisposed) throw new ObjectDisposedException(nameof(NotificationListener));
if (SubscribedTopicsByUnique.ContainsKey(unique)) throw new InvalidOperationException("A topic with this unique already exists"); if (SubscribedTopicsByUnique.ContainsKey(unique)) throw new InvalidOperationException("A topic with this unique already exists");
if (string.IsNullOrWhiteSpace(username)) username = null; if (string.IsNullOrWhiteSpace(username)) username = null;
@@ -172,31 +276,35 @@ namespace ntfysh_client
message.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(boundCredentialsBytes)); message.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(boundCredentialsBytes));
} }
CancellationTokenSource listenCanceller = new(); SubscribedTopic newTopic = new(topicId, serverUrl, username, password);
Task listenTask = ListenToTopicWithHttpLongJsonAsync(message, listenCanceller.Token);
SubscribedTopicsByUnique.Add(unique, new SubscribedTopic(topicId, serverUrl, username, password, listenTask, listenCanceller)); CancellationTokenSource listenCanceller = new();
Task listenTask = ListenToTopicWithHttpLongJsonAsync(message, listenCanceller.Token, newTopic);
newTopic.SetAssociatedRunner(listenTask, listenCanceller);
SubscribedTopicsByUnique.Add(unique, newTopic);
} }
public void SubscribeToTopicUsingWebsocket(string unique, string topicId, string serverUrl, string? username, string? password) public void SubscribeToTopicUsingWebsocket(string unique, string topicId, string serverUrl, string? username, string? password)
{ {
if (_isDisposed) throw new ObjectDisposedException(nameof(NotificationListener));
if (SubscribedTopicsByUnique.ContainsKey(unique)) throw new InvalidOperationException("A topic with this unique already exists"); if (SubscribedTopicsByUnique.ContainsKey(unique)) throw new InvalidOperationException("A topic with this unique already exists");
if (string.IsNullOrWhiteSpace(username)) username = null; if (string.IsNullOrWhiteSpace(username)) username = null;
if (string.IsNullOrWhiteSpace(password)) password = null; if (string.IsNullOrWhiteSpace(password)) password = null;
Uri targetUri = new($"{serverUrl}/{HttpUtility.UrlEncode(topicId)}/ws"); SubscribedTopic newTopic = new(topicId, serverUrl, username, password);
CancellationTokenSource listenCanceller = new(); CancellationTokenSource listenCanceller = new();
Task listenTask = ListenToTopicWithWebsocketAsync(targetUri, new NetworkCredential(username, password), listenCanceller.Token); Task listenTask = ListenToTopicWithWebsocketAsync(new Uri($"{serverUrl}/{HttpUtility.UrlEncode(topicId)}/ws"), new NetworkCredential(username, password), listenCanceller.Token, newTopic);
SubscribedTopicsByUnique.Add(unique, new SubscribedTopic(topicId, serverUrl, username, password, listenTask, listenCanceller));
newTopic.SetAssociatedRunner(listenTask, listenCanceller);
SubscribedTopicsByUnique.Add(unique, newTopic);
} }
public async Task UnsubscribeFromTopicAsync(string topicUniqueString) public async Task UnsubscribeFromTopicAsync(string topicUniqueString)
{ {
if (_isDisposed) throw new ObjectDisposedException(nameof(NotificationListener));
#if DEBUG #if DEBUG
Debug.WriteLine($"Removing topic {topicUniqueString}"); Debug.WriteLine($"Removing topic {topicUniqueString}");
#endif #endif
@@ -208,12 +316,12 @@ namespace ntfysh_client
if (!SubscribedTopicsByUnique.TryGetValue(topicUniqueString, out topic!)) return; if (!SubscribedTopicsByUnique.TryGetValue(topicUniqueString, out topic!)) return;
//Cancel and dispose the task runner //Cancel and dispose the task runner
topic.RunnerCanceller.Cancel(); topic.RunnerCanceller?.Cancel();
//Wait for the task runner to shut down //Wait for the task runner to shut down
try try
{ {
await topic.Runner; if (topic.Runner is not null) await topic.Runner;
} }
catch (Exception) catch (Exception)
{ {
@@ -221,19 +329,10 @@ namespace ntfysh_client
} }
//Dispose task //Dispose task
topic.Runner.Dispose(); topic.Runner?.Dispose();
//Remove the old topic //Remove the old topic
SubscribedTopicsByUnique.Remove(topicUniqueString); SubscribedTopicsByUnique.Remove(topicUniqueString);
} }
public void Dispose()
{
if (_isDisposed) return;
_httpClient.Dispose();
_isDisposed = true;
}
} }
} }

View File

@@ -1,4 +1,5 @@
using System.Threading; using System;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Newtonsoft.Json; using Newtonsoft.Json;
@@ -6,12 +7,18 @@ namespace ntfysh_client
{ {
public class SubscribedTopic public class SubscribedTopic
{ {
public SubscribedTopic(string topicId, string serverUrl, string? username, string? password, Task runner, CancellationTokenSource runnerCanceller) public SubscribedTopic(string topicId, string serverUrl, string? username, string? password)
{ {
TopicId = topicId; TopicId = topicId;
ServerUrl = serverUrl; ServerUrl = serverUrl;
Username = username; Username = username;
Password = password; Password = password;
}
public void SetAssociatedRunner(Task runner, CancellationTokenSource runnerCanceller)
{
if (Runner is not null || RunnerCanceller is not null) throw new InvalidOperationException("Runner already associated");
Runner = runner; Runner = runner;
RunnerCanceller = runnerCanceller; RunnerCanceller = runnerCanceller;
} }
@@ -22,9 +29,9 @@ namespace ntfysh_client
public string? Password { get; } public string? Password { get; }
[JsonIgnore] [JsonIgnore]
public Task Runner { get; } public Task? Runner { get; private set; }
[JsonIgnore] [JsonIgnore]
public CancellationTokenSource RunnerCanceller { get; } public CancellationTokenSource? RunnerCanceller { get; private set; }
} }
} }