Typed Clients Over One HTTP Door
Convey's HTTP story in two layers - a hardened IHttpClient with retries, masking and correlation headers, and RestEase interfaces on top that turn a service name in config into a typed client.
Service-to-service HTTP is where microservice codebases quietly rot. Every service grows its own HttpClient wrapper — one team retries, one doesn't; one forwards the correlation header, one drops it; one logs full URLs including the API key in the query string, and that one you find out about during an audit. The fix is boring and effective: one client, hardened once, and typed facades on top so call sites never touch HTTP at all.
That is exactly the two-layer design in Convey.HTTP and Convey.HTTP.RestEase, from DevMentors' open-source Convey toolkit — code I have run and read at length, and code whose sharp edges I want to show you alongside its good ideas.
Layer one: the hardened door
AddHttpClient() registers IHttpClient — a wrapper over the real HttpClient — configured from an httpClient section:
"httpClient": {
"type": "",
"retries": 2,
"services": { "pricing": "localhost:5003" },
"correlationIdHeader": "x-correlation-id",
"requestMasking": {
"enabled": true,
"urlParts": [ "api-key" ],
"maskTemplate": "*****"
}
}
Three things in HttpClientOptions deserve attention because most homegrown wrappers forget at least two of them.
Correlation headers. CorrelationContextHeader and CorrelationIdHeader names are configured once, and the client forwards them on every outbound call. Cross-service log correlation (part 19) works only if every hop plays; putting the header logic in the one shared client is how you make that structural instead of aspirational.
Request masking. URL parts listed in requestMasking.urlParts are replaced with the mask template before the URL is logged. It is a small feature with a compliance-sized payoff — the difference between a log line you can ship to a third-party aggregator and one you cannot.
The services map. services associates logical names with addresses. Combined with type (empty, "consul", or "fabio"), it decides how a name like pricing becomes a URL — a plain lookup, or dynamic discovery (part 15). Call sites use the logical name either way, so how services find each other is an ops decision, not a code change.
The retry policy, read carefully
Every send is wrapped in Polly:
// Convey.HTTP/ConveyHttpClient.cs
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request)
=> Policy.Handle<Exception>()
.WaitAndRetryAsync(_options.Retries, r => TimeSpan.FromSeconds(Math.Pow(2, r)))
.ExecuteAsync(() => _client.SendAsync(request));
Credit first: this is exponential backoff (2s, 4s, 8s…), which the RabbitMQ subscriber from part 10 never got. Now the two problems, both of which have bitten me in systems using this exact shape.
Policy.Handle<Exception>() retries every exception on every verb. Retrying a timed-out GET is free; retrying a timed-out POST is how you charge a customer twice. The timeout case is precisely the ambiguous one — the request may have succeeded and the response died on the way back. A safe general-purpose policy retries connection-level failures on idempotent verbs and makes non-idempotent retries an explicit opt-in with an idempotency key. This one retries everything, and because it lives in the shared client, every call site inherits the risk silently.
Second, the typed convenience path:
var response = await _client.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
return default; // 404? 500? 401? — all become null
}
GetAsync<PricingDto>("pricing/123") returns null for not-found — reasonable — but also null for a 500, a 401, and a misconfigured route. The caller cannot distinguish “does not exist” from “everything is on fire.” The package does offer SendResultAsync<T>/HttpResult<T>, which carries the raw response alongside the payload; in any code I influence, the result-returning variants are mandatory and the null-swallowing ones are banned by convention. The API's mistake is making the lossy path the short, attractive one.
Layer two: interfaces as clients
On top of the door, Convey.HTTP.RestEase uses the excellent RestEase library to turn an interface into a client:
public interface IPricingService
{
[AllowAnyStatusCode]
[Get("pricing/{orderId}")]
Task<PricingDto> GetAsync([Path] Guid orderId);
}
registered with one call and a logical name:
.AddServiceClient<IPricingService>("pricing")
Now handlers depend on IPricingService — mockable, typed, zero HTTP plumbing — and the sample Orders service calls _pricingServiceClient.GetAsync(orderId) from its command handler as if pricing were a local class.
The interesting part is what AddServiceClient<T> does with the name. Reading Extensions.cs, the restEase section's loadBalancer value switches the wiring:
switch (options.LoadBalancer?.ToLowerInvariant())
{
case "consul": builder.AddConsulHttpClient(clientName, serviceName); break;
case "fabio": builder.AddFabioHttpClient(clientName, serviceName); break;
default: ConfigureDefaultClient(builder.Services, clientName, serviceName, options); break;
}
With no load balancer, the base URL comes from the services map in configuration. With consul, a message handler resolves the name against the registry per request; with fabio, requests route through the load balancer's stable URL. The typed interface is identical in all three worlds. This is the payoff of the two-layer design: discovery strategy became a config value, and part 15 walks through those two strategies properly.
Two implementation notes for source readers. The registration path calls AddFabio(...) regardless of which branch the switch took — pulling Consul/Fabio wiring into services that opted for static config; it is guarded by enablement flags, but the coupling is untidy. And the package's RemoveHttpClient() helper reaches into ASP.NET Core internals by reflection to unregister the default client registration — the kind of trick that works until a minor framework update renames a private type. I catalogued the same pattern in the WebApi package (part 5); it is this toolkit's recurring vice.
The scorecard
Steal these: one shared client as the single door out; correlation headers and URL masking as defaults, not per-team heroics; logical service names in config; typed RestEase interfaces so business code never sees a URL; discovery as a config switch.
Fix these before production: replace the catch-all retry with verb-aware handling (HttpRequestMessage.Method is right there), prefer SendResultAsync so failures stay visible, and if you are on .NET 8+, weigh the whole first layer against AddHttpClient().AddStandardResilienceHandler() — the platform now ships retry classification, circuit breaking and timeout budgets that this 2019-era code had to hand-roll, which is a theme I return to in the retrospective (part 22).
Next: what actually happens when type says consul or fabio — registration, health checks, and client-side discovery before the mesh made it invisible.