~/definitionOfDone.dev/blog/a-shared-client-that-decides-nothing.md

9 min read

A Shared HTTP Wrapper That Decides Nothing

Most integration-heavy systems I've seen have a shared function that all outbound HTTP goes through. In an architecture review you'd point at it as evidence of discipline: one sea…

TL;DR

  • Routing every outbound call through one shared function feels like discipline. It only helps if that function owns policy — timeouts, retries, correlation, failure classification — and not just syntax.
  • A wrapper that reorders arguments and forwards them is a naming convention with a function signature. Real value, but not the value people think they have.
  • Without a timeout, your function timeout is your timeout. A dependency that degrades from 200ms to 40s doesn't fail — it silently consumes your concurrency and your budget.
  • Five things a client should own: a named dependency, a default timeout, opt-in retry, automatic correlation propagation, and error classification at the boundary.
  • Retry stays explicit. A shared client that retries by default is more dangerous than one that never retries, because retrying a non-idempotent write is how you create duplicates at scale.
  • Same gap shows up inbound: queue visibility timeouts equal to consumer timeouts, queues with no redrive policy. Real primitives, defaults left unchosen.
  • The test: when you find a shared wrapper, don't ask whether it exists. Ask what decisions it makes.

Most integration-heavy systems I've seen have a shared function that all outbound HTTP goes through. In an architecture review you'd point at it as evidence of discipline: one seam, one place to change, one place to add resilience.

Then you open it.

jssnippet
export async function makeServiceCall(method, headers, baseUrl, url, qs, body, agent, responseType) {
  try {
    return await httpClient({
      method,
      baseURL: baseUrl,
      url: url + buildQueryString(qs),
      headers,
      data: body !== '' && body !== null ? body : undefined,
      httpsAgent: agent,
      responseType
    });
  } catch (err) {
    console.error('service call failed', err);
    throw err;
  }
}

Twenty-odd lines. Eight positional parameters. It logs and rethrows.

No timeout. No retry. No circuit breaker. No correlation header. No metric.

Hundreds of call sites, every one of them unbounded in time and unattributable across a service boundary. The seam exists and buys nothing — and the reason why is worth naming precisely, because it's the difference between a wrapper that will eventually help you and one that never will.

Syntax versus policy

The wrapper owns the syntax of making a call: parameter order, how the query string is assembled, that an empty body becomes undefined.

It does not own the policy: how long to wait, what to do on failure, what identifies this request downstream, whether a struggling dependency should be given a rest.

A seam that owns only syntax gives you consistent-looking code. That's not nothing — greppability and uniformity have value. But it isn't what people mean when they say "all our HTTP calls go through one place", and the gap between those two claims is where outages live.

The tell is what happens when someone decides to add a timeout. In a policy-owning client that's one line in one file and every call site inherits it. Here it's also technically one line — and that's exactly why it never happens. Because the wrapper was never the place decisions were made, nobody thinks of it as the place decisions go. It's plumbing. You don't go to plumbing to make architectural choices.

The eight positional parameters make this concrete:

jssnippet
await makeServiceCall('post', headers, process.env.ORDERS_URL, '/v1/orders', '', payload);

That '' is the query-string slot, occupied by a placeholder so the body lands in position six. Now imagine adding a timeout. Position nine? Everyone who wants a timeout but not a custom agent writes null, null first. The signature actively resists extension — which means the abstraction structurally cannot grow into one.

An options object would have made growth natural. That isn't a style preference. It's the difference between a seam that can accumulate policy and one that can't.

What the absence actually costs

No timeout means your function timeout is your timeout. This is the one that surprises people, because the failure isn't an outage.

When a dependency degrades — doesn't fail, just starts taking forty seconds — every invocation calling it now runs for forty seconds. Concurrency is occupied. If you're on a reserved concurrency limit, you exhaust it and start throttling requests that had nothing to do with that dependency. Cost climbs linearly with duration. And if the function sits behind API Gateway, the caller gets a gateway-generated 504 at 29 seconds while your function keeps running, and keeps being billed, for another eleven.

Your system's behaviour under partial degradation is entirely determined by a library default nobody chose.

No correlation header means no trace across the boundary. Plenty of systems generate a per-invocation ID and tag every log line with it — useful within a function, useless the moment a request crosses into someone else's system. When an upstream team asks which of your calls failed at 14:32, the answer becomes timestamp archaeology.

Propagating a correlation ID is one header. It's the single highest-leverage line of code in an integration layer, and the seam to add it already exists.

No retry anywhere means retries get reinvented locally. Usually badly. The version I'd expect to find is something detecting a timeout by substring-matching the response body for the word "timeout" — which isn't a criticism of whoever wrote it, it's what you get when policy isn't available in the shared path and someone needs it today.

A reliable signal, incidentally: if the one function in your codebase that does set a timeout bypasses the shared wrapper and calls the HTTP library directly, that tells you everything about whether the wrapper is an abstraction.

What a useful client owns

Not a framework. Something narrow that makes a small number of decisions:

jssnippet
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

export async function call(dependency, {
  method, url, headers = {}, body,
  timeout = 5000, retry = 'never', maxAttempts = 3
}) {
  const outboundHeaders = { ...headers, 'x-correlation-id': currentCorrelationId() };
  const execute = () =>
    httpClient({ method, url, headers: outboundHeaders, data: body, timeout });

  for (let attempt = 1; ; attempt++) {
    const started = Date.now();
    try {
      const res = await execute();
      metrics.timing(`dependency.${dependency}.latency`, Date.now() - started);
      return res;
    } catch (err) {
      metrics.increment(`dependency.${dependency}.failure`);

      if (retry !== 'safe' || !isTransient(err) || attempt >= maxAttempts) {
        throw toDomainError(err, dependency);
      }

      // Backoff with jitter. Retrying immediately concentrates load on the
      // dependency that is already struggling, and synchronised retries
      // across concurrent invocations are how a wobble becomes an outage.
      await sleep(2 ** attempt * 100 * (0.5 + Math.random()));
    }
  }
}

One structural note, because it's the thing that separates this from a sketch: the request lives in execute, defined once and called from the loop. The obvious-looking alternative — a retry branch that calls some other function to redo the request — needs a second implementation of the same call, and the two drift. A timeout added to one, correlation headers fixed in the other.

Five properties, and they matter more than the code:

A named dependency. Every call declares who it's talking to. That name becomes the metric dimension, the log field, the circuit-breaker key and the thing on your dashboard. Without it, "outbound HTTP" is one undifferentiated blob that tells you nothing when a single dependency degrades.

A default timeout. Five seconds, or whatever's right — but a number a human chose. Overridable per call, because some calls legitimately take longer. The default being a decision rather than an accident is the whole point.

Retry that is opt-in, explicit, bounded and backed off. retry: 'never' is the default and should be. Making it a named argument at the call site forces the person who knows whether the operation is safe to repeat to actually say so. And when it is enabled, it needs an attempt cap and a delay with jitter — an unbounded or immediate retry is not a gentler failure than no retry, it's a louder one.

Correlation propagation, automatic and unavoidable. The one thing that should never be a call-site decision.

Error classification at the boundary. This is the natural place for it, because it's the only place that knows which dependency failed.

And an options object, not eight positions. It has to be able to grow.

The same gap, inbound

Resilience isn't only about outbound calls, and the inbound side usually shows the same shape of omission — real primitives, defaults left unchosen.

Queue visibility timeout equal to consumer timeout. Both thirty seconds, because both are defaults. The guidance is that visibility timeout should comfortably exceed function timeout — six times is the usual rule of thumb — for a concrete reason: a function that runs to its wall clock can have its message become visible again and delivered to a second invocation while the first is still working. For anything non-idempotent, that's duplicate work caused entirely by two defaults meeting.

A queue with no redrive policy. Poison messages get redelivered forever. On a FIFO queue it's worse — ordering guarantees mean one consistently-failing message blocks its entire message group indefinitely.

What's notable about both is that they're rarely ignorance. Most estates have redrive policies on four queues and miss the fifth. Resilience configuration is per-resource, hand-written and unverified, so the one that got missed stays missed.

Which is exactly the shape of problem a static check catches: every queue has a redrive policy; every visibility timeout exceeds its consumer's timeout. Same category of check as validating template parameters, same afternoon of work.

The counter-argument, which is real

Deep clients have costs and I don't want to pretend otherwise.

Hidden behaviour is hard to debug. A retry you didn't know about turns one log line into three and one duplicate record into two. When resilience is implicit, understanding what actually happened requires knowing what the client does.

One team's policy becomes everyone's. A five-second default is wrong for the report generator that legitimately takes forty. Overridability handles it — but only if someone notices before the timeouts start firing.

Thick clients accrete. Today it's timeouts. Then caching, request signing, header manipulation, response normalisation, a plugin system. Now it's a framework and onboarding includes learning it.

So the argument isn't "wrap everything". It's narrower: if you're going to have a shared seam anyway — and most systems do — it should own the decisions that are wrong to make per call site. Timeouts, correlation and dependency naming are wrong to make per call site, because leaving them to individual developers guarantees inconsistency and the failure mode is invisible until production.

Retry is the interesting one and it should stay explicit, which leads straight to the next question: what makes an operation safe to repeat at all?

The test

When you find a shared wrapper, don't ask whether it exists. Ask: what decisions does it make?

If the honest answer is "it reorders arguments and forwards them", you have a naming convention. That's fine — but stop treating it as a resilience strategy, because it isn't one and believing otherwise is worse than knowing you have nothing.

The distance between those two is about thirty lines of code. It's almost always the thirty lines nobody scheduled, precisely because the seam already existed and looked like it was doing the job.

newer Module Scope Is Not Request Scope older The Deduplication Key That Was Set to Random