One HTTP Client, Two Calling Conventions
Azure Functions want their key in an x-functions-key header; Logic App workflows want a SAS signature in the query string. How a discovery record's type field became a calling-convention discriminator, and how one small HTTP service hid that difference from every caller in the estate. Part 3 of Service Discovery Without a Service Mesh.
A discovery record that hands you a URL and a key has answered two of the three questions. The third — how do I present that key? — turns out to be the one that would otherwise leak platform trivia into every caller in the estate.
Azure Functions authenticate with a function key sent as an x-functions-key HTTP header (or a ?code= parameter). Logic App (Standard) workflow triggers authenticate with a SAS signature — sp, sv and sig parameters that must ride the query string of the trigger URL. Same estate, same registry, two incompatible conventions. If consumers handled this themselves, every service would need to know what kind of thing each of its dependencies is, and converting a callee from a workflow to a function would be a breaking change for every caller.
This is exactly what the type field in the registry record exists to absorb. Part 2 ended with a parsed Instance — URL, Key, and an InstanceType of azfun or azlogicapp. This part is the class that consumes it: HttpClientService, the only place in the entire library where those two calling conventions exist.
The shape of the service
The interface is four verbs, each taking an Instance instead of a URL:
namespace Platform.ServiceDiscovery;
public interface IHttpClientService
{
Task<HttpResponseMessage> InvokePostApiAsync(Instance connectionDetails,
string methodName, JObject requestData);
Task<HttpResponseMessage> InvokeGetApiAsync(Instance connectionDetails,
string methodName, Dictionary<string, string> queryParams);
Task<HttpResponseMessage> InvokePutApiAsync(Instance connectionDetails,
string methodName, JObject requestData,
Dictionary<string, string> queryParams = null);
Task<HttpResponseMessage> InvokeDeleteApiAsync(Instance connectionDetails,
string methodName, Dictionary<string, string> queryParams);
}
That first parameter is the design. Callers never assemble a URL; they pass the resolved record plus a method name — the route segment of the operation they want, "CalculatePremium", "GetDocument" — and optional query parameters or a JSON body. The service composes the rest. (The JObject payloads are a period piece: the estate standardised on Newtonsoft for message bodies, so the library met it there. A string/HttpContent overload would have aged better — the DTO of the day is now baked into a public interface.)
DELETE is also a small archaeology note: the interface originally shipped without it, and it was added months later when the first consumer actually needed one. Starting with the verbs you need rather than the verbs HTTP has was correct; the add was a purely additive change that broke nobody.
The switch that hides the platform
Every verb funnels through the same setup logic. Here it is from the original generation, in the POST path:
switch (connectionDetails.Type)
{
case InstanceType.azfun:
client.BaseAddress = new Uri(connectionDetails.URL);
client.DefaultRequestHeaders.Add("x-functions-key", connectionDetails.Key);
break;
case InstanceType.azlogicapp:
client.BaseAddress = new Uri(connectionDetails.URL + "?" + connectionDetails.Key);
break;
}
client.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
var request = new HttpRequestMessage(HttpMethod.Post, methodName);
request.Content = new StringContent(requestData.ToString(),
Encoding.UTF8, "application/json");
HttpResponseMessage response = await client.SendAsync(request);
For a Function, the record's URL is the base (https://fn-pricing-dev.azurewebsites.net/api), the method name is a relative path appended to it, and the key travels as a header. For a Logic App, the record's key is not really a key at all — it's the entire SAS query string (api-version=...&sp=...&sv=...&sig=...) that the producer side captured from the workflow's listCallbackUrl when it registered the service, so it gets glued onto the URL with a ?. Two conventions, one switch, and nothing outside this class knows there are two kinds of service in the estate.
That last property paid for itself repeatedly. Callers wrote InvokePostApiAsync(instance, "ProcessOrdersRate", body) identically whether the target was a function or a workflow, and at least once a callee was reimplemented from one runtime to the other with no consumer change at all — the Terraform re-registered it with a new type, caches recycled, calls kept flowing. That is the discriminator doing exactly what the producer-side article promised: the registry is heterogeneous by design, and the entire cost of heterogeneity is concentrated in one switch statement.
The extension path is honest, though, not free: a third runtime means a new enum member and a new case here, shipped to consumers before any producer writes the new type — the version-skew rule from Part 2. An x-api-key header type for plain APIs behind API Management was the obvious candidate and would be perhaps ten lines.
Failures are logged, not thrown
The error-handling posture is worth pausing on:
if (!response.IsSuccessStatusCode)
{
_logger.LogError($"Error calling {connectionDetails.URL}. " +
$"Method: {methodName}. Payload: {requestData}. Response: {response}.");
}
return response;
No throw, no retry — log and return. The HttpResponseMessage goes back to the caller un-judged, because only the caller knows whether a 404 from the document store is an error or an answer. Pushing that decision down into a shared transport layer is how you end up with a library that throws on responses somebody considered normal. The endpoint layer in Part 4 is where response-to-outcome interpretation actually lives, and it has a structured Error envelope for the purpose.
Two genuine gaps in the posture, both deferred to the final reckoning but worth flagging where they live. First, the log line includes the payload. Priceless during integration, and a data-governance question forever after — request bodies in an error log outlive every retention conversation nobody had. Second, there is no resilience layer: no retry on 429/503, no timeout beyond HttpClient's default hundred seconds, no circuit breaker. Every caller either handled transient failure itself or, more commonly, didn't.
The confession, and the fix that was coming
Now the part I would not defend in a design review, from the same original generation:
var handler = new HttpClientHandler()
{
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
};
using (var client = new HttpClient(handler))
{
// ... one request ...
}
Two sins in six lines. A new HttpClient per call: under load this is the classic socket-exhaustion pattern — each disposed client abandons its connection to a TIME_WAIT grave, and a busy Function App can bleed ephemeral ports until outbound calls start failing in ways that look nothing like the cause. And certificate validation switched off: the property name literally starts with Dangerous, which is the API politely reading you your rights. It entered the codebase the way it always does — a dev environment with a self-signed endpoint, a deadline, a TODO — and then it was load-bearing.
I'm showing the uncorrected version deliberately, because the correction is a story of its own: the library's second generation moved to IHttpClientFactory with a typed client, one shared handler, and proper DI — and made an instructive choice about which of the two sins to keep. That rewrite, and how it shipped side-by-side with this code in the same NuGet package without breaking a single caller, is Part 7.
With resolution (Part 2) and transport (this part) in place, the library could have stopped — plenty of estates would call GetSubSystemInstance plus InvokePostApiAsync a complete discovery story. What it built on top instead is the part I find most interesting in hindsight: a convention layer where declaring a class named after a service is all the registration you do. That's next: The Class Name Is the Registry Key.