Skip to content
Kumar Chandrachooda
Microservices

The 202 That Names the Future

Pacco's async gateway answers a POST with two names - the ID a resource will have and the operation that tracks whether it ever gets it. Following both through the Redis state machine, the saga header override and the per-user SignalR push, warts and all - a five-minute memory, a 404 with three meanings, and a status API anyone can read.

By Kumar Chandrachooda 25 Dec 2025 7 min read
An accepted request, a promised ID, a five-minute memory

A bare 202 is the least helpful success code in HTTP: accepted — accepted for what, and how would you ever know? Pacco's answer is to make the 202 carry two names for things that do not exist yet. The first is Resource-ID: the identity your order will have, minted at the gateway before any service has heard of it (part 3). The second is the operation id — the correlation id of your request, which doubles as the key under which the estate will narrate what became of it. The Ntrada gateway exposes it as the Request-ID response header; the Ocelot twin from part 4 spells it out as X-Operation: operations/{id}. Either way, the contract is the same: the response names the future, and the operations API is where the future gets settled.

This part follows both names through the estate: the Redis state machine that tracks the operation, the saga header that can overrule it, the SignalR push that tells exactly one browser, and the three warts that make the loop as instructive in failure as in design.

One request, one story

Assembled end to end, the loop looks like this:

  1. POST /orders hits the async gateway. It authenticates the JWT, mints orderId, binds customerId from the token, builds a correlation context — user id included — and publishes create_order to the orders exchange. The caller gets 202, Resource-ID, and the operation id.
  2. The Orders service consumes the command and does its domain work, eventually publishing order_created — or create_order_rejected.
  3. Pacco.Services.Operations, subscribed to all eighty of the estate's messages through the runtime-forged types of part 2, sees each hop. Its generic handlers map message kind to operation state: a command means Pending, an event means Completed, a rejected event means Rejected.
  4. Each state lands in Redis and is pushed over SignalR to the group users:{id} — the initiating user's browser, and nobody else's.
  5. A client without a socket can poll GET /operations/{operationId} instead.

Every piece of that is small. The interesting engineering is in the seams.

A state machine with terminal states

Services/OperationsService.cs is the whole tracker — Redis via IDistributedCache, one key per request, and a TrySetAsync that is a state machine in about ten lines:

var operation = await GetAsync(id);
if (operation is null)
{
    operation = new OperationDto();
}
else if (operation.State == OperationState.Completed || operation.State == OperationState.Rejected)
{
    return (false, operation);
}

// ...fill id, userId, name, state, code, reason...
await _cache.SetStringAsync(GetKey(id),
    JsonConvert.SerializeObject(operation),
    new DistributedCacheEntryOptions
    {
        SlidingExpiration = TimeSpan.FromSeconds(_options.ExpirySeconds)
    });

The guard is the design: once an operation reaches Completed or Rejected, further updates are refused. Messages in a distributed system arrive late, duplicated and out of order; the terminal-state check means a straggling Pending from a redelivered command cannot regress a finished operation. The states are a ratchet, not a record — first to a terminal state wins, and history is one overwritten value, not a log.

Two prices are paid quietly. First, TrySetAsync is get-then-set on a distributed cache with no lock or ETag; two racing messages can interleave reads and writes. With one consumer instance this is theoretical; scale Operations out and it stops being theoretical, though the ratchet bounds the damage to a lost intermediate update. Second — the key requests:{id} is written with SlidingExpiration of _options.ExpirySeconds, and appsettings.json sets requests.expirySeconds: 300. Hold that number; it is wart one.

The header that overrules the machine

The kind-to-state mapping has an override. Before defaulting, each generic handler asks the broker metadata a question:

var state = messageProperties.GetSagaState() ?? OperationState.Pending;

GetSagaState (in Handlers/Extensions.cs) reads an AMQP header literally named Saga and parses it into Pending, Completed or Rejected. The header exists because of OrderMaker, the estate's Chronicle-driven saga client: when it publishes the commands that assemble an order, it stamps Saga: Pending into the headers, and every service's MessageBroker — the copied glue file each repo owns — forwards exactly that one header when it publishes the events that follow. The consequence for Operations: while a saga is mid-flight, the intermediate events that would normally mean Completed arrive wearing Saga: Pending, and the operation stays honestly pending until the saga itself declares an end state. One operation id can now describe a multi-service story, not just a single hop — data movement by header rather than payload, and the saga's own side of that bargain is told in the saga that rides a header.

It is a genuinely clever reuse of the tracker. Note what it depends on, though: ten copies of a MessageBroker.cs all remembering to forward the header. The override is only as reliable as the estate's least-synchronised copy-paste.

A push channel with a handshake

The delivery arm is SignalR, and its authentication is unusual enough to show. Hubs/PaccoHub.cs has no [Authorize]; clients connect anonymously and then introduce themselves:

public async Task InitializeAsync(string token)
{
    // validate JWT with Convey's IJwtHandler...
    var group = Guid.Parse(payload.Subject).ToUserGroup();   // "users:{id}"
    await Groups.AddToGroupAsync(Context.ConnectionId, group);
    await ConnectAsync();                                     // sends "connected"
}

A valid token joins the connection to users:{sub}; every publish in HubService targets a group — operation_pending, operation_completed and operation_rejected all go PublishToUserAsync(operation.UserId, ...), with the user id taken from the correlation context the gateway stamped at the edge. The estate's identity discipline pays off here: the browser learns only about operations initiated with its own JWT. Two soft findings in the same file: a failed handshake replies "disconnected" but never calls Context.Abort(), so a rejected connection stays open, merely group-less and deaf; and the packages are Microsoft.AspNetCore.SignalR 1.1.0 with the 1.1.5 Redis backplane — the deprecated 2.x-era NuGets on a 3.1 app, wired through a config-gated signalR.backplane: "redis" that makes the fan-out horizontally scalable in principle. The repo even ships its own demo client: a 25-line JWT test cockpit at wwwroot/ui/index.html — title proudly misspelt “Pacoo SignalR” — where you paste a token, click Connect, and watch your operations stream in. The push channel's darker sibling — a gRPC stream on the same service with none of this care — is the next part's story.

Three warts, in ascending severity

Wart one: the five-minute memory. expirySeconds: 300, sliding. Five minutes after the last touch, the operation is gone, and GET /operations/{id} returns 404. This is a status cache, not an audit log — reasonable, except the service is dressed as though it were more: AddInfrastructure() calls .AddMongo(), appsettings.json configures a operations-service database, and no repository, document or query ever touches Mongo. The durable store is registered, configured and dead — plumbing that documents an intention nobody implemented. (The same builder chain calls .AddRedis() twice, which is harmless and tells you how carefully this file was read.) If you copy this pattern, decide on purpose whether operations are ephemeral; Pacco decided by default.

Wart two: 404 has three meanings. The gateway returns the operation id before any message is consumed. Until the first bus hop lands, the status endpoint 404s. After five idle minutes, it 404s again. And for an id that never existed, it 404s identically. “Not yet”, “not any more” and “never” are indistinguishable, and clients must poll a 404 with faith. Nothing documents this — fittingly, the estate's own nineteen-request .rest walkthrough never calls the operations endpoint at all; the demo scenario simply trusts every 202. A 202 Accepted from the status endpoint for known-but-unstarted ids, or a tombstone with Retry-After, would each dissolve the ambiguity.

Wart three: anyone can read the future. Both gateway configs route GET /operations/{operationId} with auth: false, and the service applies no authorisation of its own — the SignalR channel scopes by user, but the polling channel is open to any caller holding the GUID. The payload includes the operation name, the user id and the failure reason. Correlation ids are not secrets: they leak into logs, traces, headers and browser history. Bearer-by-GUID is the sort of decision a sample makes for demo convenience — polling before sign-in is one fewer step — and precisely the sort that survives the copy-paste into systems where operation names alone are confidential. The fix is a line: require auth, compare operation.UserId to the caller's subject.

My rule of thumb after reading this loop: a 202 is an IOU, so design the redemption — its window, its auth, and its 404 semantics — with the same care as the resource it promises. Pacco designed the issuance beautifully and left the redemption on defaults.

The tracker turns out to have a second mouth. Alongside the carefully scoped SignalR hub, Operations exposes a gRPC stream that will happily narrate every user's operations to any caller — no token, no groups, and a resource leak per subscriber. Same data, same service, two security models: two push channels, two security models.