posts: working on draft

This commit is contained in:
Stevan Freeborn
2025-06-20 19:53:58 -07:00
parent f2a69d0735
commit 6bdb1e1983
@@ -4,7 +4,7 @@
"lead": "Some practical lessons I picked up while wrestling with WebSockets, concurrency, and Discord's Gateway API",
"isPublished": true,
"publishedAt": "2025-06-17",
"openGraphImage": "posts/how-building-a-discord-bot-made-me-a-better-developer/og-image.png",
"openGraphImage": "posts/how-building-a-discord-bot-made-me-a-better-developer/og-image.png"
}
```
@@ -14,6 +14,87 @@ Spoiler alert: it wasn't.
What I thought would be straightforward turned into a crash course in building resilient network clients. By the time I had a working `DiscordGatewayClient` that could stay connected reliably, I'd learned a bunch of things that made me a better developer.
Here's what building this bot taught memaybe some of it will be useful for your projects too.
Here's what building this bot taught me. I am hoping maybe some of it will be useful for your projects too.
## Abstractions Are Worth The Extra Work
My first instinct was to just use .NET's ClientWebSocket directly. Why add extra layers when I could just call the API?
Then I tried to write tests and immediately regretted that decision.
How do you test reconnection logic without actually connecting to Discord? How do you simulate network failures? How do you test your error handling without hitting rate limits?
So I bit the bullet and created some interfaces:
```csharp
internal interface IWebSocket : IDisposable
{
WebSocketState State { get; }
Task ConnectAsync(Uri uri, CancellationToken cancellationToken);
Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken);
Task<WebSocketReceiveResult> ReceiveAsync(ArraySegment<byte> buffer, CancellationToken cancellationToken);
Task SendAsync(ArraySegment<byte> buffer, WebSocketMessageType messageType, bool endOfMessage, CancellationToken cancellationToken);
}
internal interface IWebSocketFactory
{
IWebSocket Create();
}
```
Yeah, it felt like overkill at first. But once I had these abstractions, testing became so much easier. I could inject mock WebSocket implementations, simulate connection failures, and test edge cases without depending on Discord's servers.
The lesson here isn't groundbreaking: abstractions make testing easier. But it reinforced the fact that while they feel like extra work upfront future me always appreciates the effort.
## Async Doesn't Mean Thread-Safe
I've done a lot async programming. I've used Task.Run and handled CancellationToken parameters before. But building a WebSocket client that needs to handle multiple concurrent operations simultaneously taught me a few things.
The client needs to:
- Send heartbeats on a timer
- Continuously receive messages
- Handle reconnections when things go wrong
- Keep track of connection state
- All at the same time, without stepping on its own feet.
My first attempts had race conditions everywhere. Heartbeat timers would conflict with reconnection logic. State would get corrupted when multiple threads tried to update it simultaneously.
So I had to get serious about thread safety which meant using locks, but in the asynchronous context that meant using a `SemaphoreSlim` to control access to shared state. I ended up wrapping the `SemaphoreSlim` in a an `AsyncLock` class to make it easier to use. You can watch me discuss the specifics of that in [this video](https://youtu.be/E4OLKVlRxyI), but the value in it was that I could then mutate shared state safetly like this:
```csharp
private readonly AsyncLock _lock = new();
private async Task SetSequenceAsync(int? sequence, CancellationToken cancellationToken)
{
using var _ = await _lock.LockAsync(cancellationToken);
_lastSequence = sequence;
}
```
It does lead to a bit of boilerplate, but it was worth it to ensure that my WebSocket client could handle multiple operations concurrently without crashing or losing messages. Plus it means I don't have to spend time debugging and troubleshooting race conditions later.
I also learned that you can get pretty fancy with `CancellationToken`s like so:
```csharp
private async Task StartReceiveMessagesAsync(CancellationToken cancellationToken)
{
var newReceiveCts = new CancellationTokenSource();
var newReceiveLinkedCts = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, newReceiveCts.Token
);
await SetReceiveCtsAsync(newReceiveCts, cancellationToken);
await SetLinkedReceiveCtsAsync(newReceiveLinkedCts, cancellationToken);
_ = Task.Run(async () => {
// Long-running message loop
}, newReceiveLinkedCts.Token);
}
```
This dual-token approach lets me handle two types of cancellation: external (when the app shuts down) and internal (when you need to restart a specific task during reconnection).
## Error Handling Is Where The Magic Happens
## Abstractions Are Worth The Extra Work (Even When They Feel Unnecessary)