The Object Store in the Corner: Convey and OpenStack Swift
Convey.Persistence.OpenStack.OCS is the package the whole first series skipped — a hand-rolled Swift object-storage client with Keystone auth, an operation-result pattern, a server-side COPY verb, and a re-authentication habit worth learning from.
In part 13 of the parent series I gave Convey.Persistence.OpenStack.OCS exactly one paragraph. I called it “the odd one out in the persistence family,” guessed it was “clearly built for one deployment environment somebody had,” and moved on to Redis. That was a coverage decision, not a judgment — but it left an entire package unread, and this series exists to fix exactly that. So: the whole package, roughly twenty-five files, read properly.
The short version of what it is: a hand-rolled HTTP client for OpenStack Swift — the object-storage service of the OpenStack cloud platform (OCS here is the object storage flavor some providers sell under that name). Think S3, but for the private-cloud world: containers, objects, eventual consistency, REST all the way down. There is no official .NET SDK anyone loves, so Convey's authors did what teams in OpenStack shops do — wrote the client they needed. What makes it worth an article is that it is a complete miniature of how you build a storage client from nothing: auth, request building, result modelling, all visible in one small package. Including the corners that got cut.
The front door: one section, four registrations
The package follows the house style from part 2 exactly. AddOcsClient() binds an OcsOptions from an "OcsClient" section — note, unusually for Convey, PascalCase — registers a named HttpClient with the storage base address, and adds three transient services: an IRequestHandler, an IAuthManager, and the public face, IOcsClient. The options tell you the whole deployment story in nine strings: StorageUrl, AuthRelativeUrl, UserId, Password, AuthMethod, ProjectId, ProjectRelativeUrl, RootDirectory, InternalHttpClientName.
Two of those deserve translation for anyone who hasn't run OpenStack. ProjectId is Keystone's tenant concept — everything in OpenStack is scoped to a project. And RootDirectory is the Swift container the client is pinned to: every path this client builds starts with it, so one registration equals one bucket. There is no multi-container story; this is a client for your service's blob space, which is honestly the right scope for a chassis package.
Keystone in one builder
Authentication is OpenStack's Identity v3 dance, and the package models it faithfully. An AuthRequestBuilder assembles the famously nested Keystone payload — identity, methods, password, user, scope, project — through a fluent interface, and AuthManager.Authenticate() POSTs it to the AuthRelativeUrl. Keystone's quirk, which the code handles correctly, is that the token comes back not in the response body but in a response header: X-Subject-Token. The manager plucks it out and wraps it in an AuthData.
Then comes the line I want every reader to see, from AuthManager (source: Convey, MIT-licensed):
if (!response.IsSuccessStatusCode)
{
throw new Exception("Something failed");
}
Something failed. No status code, no body, no exception type. If your Keystone password is wrong, this string is the entirety of your diagnostic experience. I am not quoting it to dunk on the authors — every codebase has this line somewhere — I am quoting it because it is the sharpest possible contrast with the rest of the package, which models storage failures with real care. The lesson generalizes: error handling quality tends to follow the author's mental model of what will actually fail, and auth was assumed to work.
Every request pays the auth toll
The RequestHandler is the package's spine, and it contains its most consequential design decision. Every single storage call goes through Send, and Send begins:
var authData = await _authManager.Authenticate();
Not “get a token, cached, refreshed near expiry.” Authenticate. Every. Request. Download three hundred thumbnails and you have made six hundred HTTP calls, half of them to Keystone. Keystone tokens are typically valid for an hour; this client treats them as valid for one request.
It is worth being precise about why this is both bad and defensible. Bad: you double your latency floor, you put your identity service on the hot path of every read, and under load you can rate-limit yourself out of your own storage. Defensible: it is unconditionally correct. No expiry tracking, no clock skew bugs, no stampede of requests holding a token that was revoked mid-flight. Token caching with correct renewal is genuinely fiddly code, and this package chose zero fiddly code. If I were adopting it, a caching decorator around IAuthManager — the interface seam is right there — would be my first pull request, and the fact that the fix is a decorator one interface wide is the package's architecture earning its keep.
One more find in Send, for the production-readiness file: the whole response body is dumped to the console inside an #if DEBUG block. Your debug builds print every object you download. Symbols matter.
Results, not exceptions
The public surface, IOcsClient, is eight methods: get an object as byte[], as Stream, or as a Base64 string; list a directory; upload from a stream or Base64; server-side copy; delete. Every one returns an IOperationResult — a tiny status-plus-value envelope with three states: Success, NotFound, Failed.
This is the package's second design position, and it is the opposite of idiomatic BCL style: storage misses are not exceptional. A 404 from Swift comes back as OperationStatus.NotFound, not a thrown HttpRequestException, because for blob storage “it isn't there” is a routine answer you branch on, not a disaster you unwind for. Exceptions are still possible — the handler catches them and folds them into a Failed result — so the caller sees exactly one shape regardless of what went wrong. Years before Result<T> types became a .NET fashion, this little package was quietly shipping one. I think it aged better than almost anything else in the file list.
The mapping layer shows the same pragmatism. Swift returns directory listings as JSON with snake_case fields, and rather than annotate, the package declares an internal DTO with properties literally named last_modified and content_type, then hand-maps them to a clean OcsObjectMetadata. Ugly inner layer, clean outer layer, zero serializer configuration. There are worse trades.
COPY: the verb HttpMethod forgot
My favorite corner of the package is three lines in a file called HttpMethodExtended. Swift supports server-side copy: send a COPY request with a Destination header and the cluster duplicates the object without the bytes ever leaving the data center. But .NET's HttpMethod class has no Copy member — so the package simply mints one (new HttpMethod("COPY")) and CopyInternally builds the request with the destination header pointed inside the same root container.
If you have only ever used S3 SDKs, this is the moment the package clicks: this is what SDK methods are — a verb, a header convention, and someone willing to read the storage API docs. There is no magic under CopyObjectAsync either.
And right next to that competence sits the counterweight, in the URL builder: paths are assembled by string interpolation and cleaned with url.Replace("//", "/"), above a comment that says //TODO: use regex for multiple '/'. Three or more consecutive slashes survive the replace. An object named with a leading slash will build a path the TODO already predicted would break. Every hand-rolled client has this file; the honesty of shipping the TODO in open source is, genuinely, part of the value of reading it.
What the corner package teaches
Would I use Convey.Persistence.OpenStack.OCS today? Only if I were on OpenStack, and then only after wrapping the auth manager with a cache and replacing "Something failed". But I got more out of reading it than several of the polished packages, because it shows the full anatomy of a storage client at a size you can hold in your head — auth dance, request builder, result envelope, verb extension, path bugs and all. In the parent series I said a chassis “grows by accretion of real needs, not by roadmap symmetry.” This package is what one ring of that accretion looks like under a microscope.
Next chapter stays in the persistence-meets-messaging seam the parent series under-served: the two outbox implementations — Entity Framework and Mongo — and the trade-off part 12 never actually made.