Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/what-does-a-202-actually-promise && read --section="top" 0%
.NET

What Does a 202 Actually Promise

One YAML key turns an HTTP route into a message publisher - and between BasicPublish returning and the gateway writing 202 there is nothing at all.

By Kumar Chandrachooda 24 Feb 2026 7 min read
An acknowledgement confirmed with a tick beside an envelope released into empty space

The customer's order was accepted. You have the log line, the HTTP 202, and the response headers with a correlation id in them. What you do not have is the order — nothing downstream ever saw it, the exchange has no record, and the only thing between the caller's confidence and reality is a socket write that happened to be flushed into a connection the broker had already closed.

Part 4 followed extension ordering into the observability layer. This part follows one extension all the way to the wire, because the RabbitMQ package makes a promise in a status code that the code behind it does not keep.

Four lines of YAML become a channel adapter

- upstream: /
  method: POST
  auth: false
  use: rabbitmq
  config:
    exchange: sample.exchange
    routing_key: sample.routing.key

samples\Ntrada.Samples.Api\ntrada.yml:111-117

That is the whole feature. use: rabbitmq swaps the downstream HTTP handler for a publishing one, and the two entries under config: name the exchange and routing key. In Hohpe and Woolf's vocabulary this is a Channel Adapter — a component that lets a system that speaks one protocol reach a channel that speaks another — and expressing one in six lines of configuration, with a real payload-binding and schema-validation pipeline in front of it, is a genuinely nice piece of declarative design. The gateway becomes a synchronous front door onto an asynchronous back end without anybody writing a publisher.

The tail of the handler

_rabbitMqClient.Send(message, routingKey, exchange, messageId, correlationId, spanContext,
    messageContext, _options.Headers);

if (!string.IsNullOrWhiteSpace(executionData.RequestId))
{
    context.Response.Headers.Add(RequestIdHeader, executionData.RequestId);
}
// ...trace and resource id headers, then response hooks...

context.Response.StatusCode = 202;

extensions\Ntrada.Extensions.RabbitMq\Handlers\RabbitMqHandler.cs:80-111

And the send itself, at the far end of the client:

channel.BasicPublish(exchange, routingKey, properties, body);

extensions\Ntrada.Extensions.RabbitMq\Clients\RabbitMqClient.cs:79

Now enumerate what is not between those two lines, because the absences are the article.

  • No publisher confirms. channel.ConfirmSelect() appears nowhere in the package. Without it, BasicPublish is fire-and-forget at the protocol level: the client writes a basic.publish frame to the socket and returns. It does not wait for the broker to acknowledge receipt.
  • No mandatory flag and no BasicReturn handler. The four-argument BasicPublish overload defaults mandatory to false, so a message with no matching binding is discarded by the broker in silence.
  • No DeliveryMode = 2. The properties object receives MessageId, CorrelationId, Timestamp and Headers, and nothing else. Messages are transient, so a message that does reach the broker still evaporates on a broker restart before a consumer takes it.
  • No transaction. TxSelect() is never called either.
  • No try/catch. Send has no exception handling of any kind, and neither does HandleAsync.
  • No await. Send returns void, not Task. HandleAsync is async, but this is a synchronous, blocking socket write sitting on the request thread inside an asynchronous pipeline.

So what BasicPublish returning actually means is: bytes were handed to a socket. Not “the broker has it.” Not “it was routed to a queue.” Not “it will survive a restart.” The gateway converts that into 202 Accepted, which in HTTP means the request has been accepted for processing.

Put in conversation-pattern terms, the route composes Quick Acknowledgment on the HTTP side with Fire-and-Forget on the messaging side. Quick Acknowledgment's entire value proposition is that the acknowledgment is safe — the system has taken durable custody of the work and the caller may now stop worrying about it. Fire-and-Forget makes no such claim. The composition is unsound: the caller has been told the work is accepted at a moment when nothing durable has taken custody of it.

What a dead broker looks like from the outside

Two cases, and they fail differently.

Broker down when the first request arrives. IConnection is registered as a lazily-created singleton, so the DI factory has never run. It runs now, connectionFactory.CreateConnection(...) throws BrokerUnreachableException, and the exception propagates out of the resolution of RabbitMqHandler, out of the endpoint, and into ErrorHandlerMiddleware, which converts every exception it catches into a 400 Bad Request:

{"errors":[{"code":"error","message":"None of the specified endpoints were reachable"}]}

A dead broker is reported to the API's clients as their mistake. Worse, because the singleton factory threw, the container caches nothing — so every subsequent request retries the full connection handshake, and a broker struggling to come back up receives a connection storm from a gateway with no circuit breaker anywhere near the AMQP path. Polly is configured in this estate, but it is on the HTTP client and does not touch AMQP.

Broker dies mid-flight, after a connection existed. _connection.CreateModel() throws AlreadyClosedException, which is another 400. Or — and this is the one nobody expects — the connection is stale but not yet detected, BasicPublish writes into a socket that is going away, Send returns normally, the log line says "Sending a message with routing key…", and the caller gets a 202 for a message that never existed.

Automatic recovery is never configured explicitly; in RabbitMQ.Client 5.1.2 it defaults to on, which helps the connection come back and does nothing at all for the messages lost in the gap.

Three failures, three status codes, in the wrong order

There is one more outcome on this route, and it makes the error contract worth stating in full. RabbitMqHandler validates the payload before publishing:

if (!executionData.IsPayloadValid)
{
    await _payloadValidator.TryValidate(executionData, context.Response);
    return;
}

RabbitMqHandler.cs:61-65

And TryValidate writes the errors and never touches the status code:

var response = new {errors = executionData.ValidationErrors};
var payload = JsonConvert.SerializeObject(response);
httpResponse.ContentType = "application/json";
await httpResponse.WriteAsync(payload);

return false;

src\Ntrada\Requests\PayloadValidator.cs:25-30

No StatusCode assignment, and the handler returns before reaching line 111. ASP.NET Core's default is 200. So one route produces:

Outcome Status
Payload fails JSON-schema validation 200 with an errors array
Message handed to a socket 202
Broker unreachable, or route misconfigured 400 with a .NET exception message

The client's fault gets a success code, the gateway's fault gets a client-error code, and the one case where nobody knows what happened gets the most confident code of the three. That is the whole error contract, and it is exactly inverted.

The half of this that is right

It would be unfair to leave it there, because several decisions in this package are correct and one of them is excellent.

The IConnection singleton is right. AMQP connections are expensive TCP-plus-handshake affairs meant to be long-lived and shared, and registering one as a singleton is the answer. The channel per publishusing var channel = _connection.CreateModel(); — looks wasteful, and it is: one channel open and one channel close per HTTP request, each an AMQP round trip. But IModel is not thread-safe, and that was a well-known and frequently-violated fact about the 5.x client. A singleton channel shared across concurrent ASP.NET Core requests is a genuine data-corruption bug; a channel pool is real work. A channel per publish is the correct and safe naive answer, and choosing safety over throughput was the defensible 2019 call with that client library.

The best change in the package's history is two lines. Commit 2f8cf41, “Request ID → Correlation ID in rabbitmq” (2019-11-10), set var correlationId = executionData.RequestId; and passed it through. Before it, the AMQP CorrelationId was a fresh GUID minted inside the client, unrelated to anything the caller could see. After it, the value in the Request-ID response header and the value in the message's CorrelationId property are the same string. That is the moment this gateway became joinable across the HTTP/AMQP boundary, and it cost two lines.

And publisher confirms are not free. ConfirmSelect() plus WaitForConfirmsOrDie(timeout) before returning would make the 202 honest at the cost of a synchronous broker round trip on every request. For a component that sits in front of everything, that is a real latency decision, not an oversight to be tutted at.

The defensible engineering position is “we chose throughput, and the 202 is a lie we accept.” The indefensible part is that nothing in the configuration surface lets an operator choose otherwise — there is no confirms: key, no persistent: key, no mandatory: key — and nothing in the README or the sample records that a choice was made at all.

If you take one rule from this: a status code is a promise about custody, and you owe your callers a way to find out which kind of custody they got. One YAML key would have been enough.

Next, the message property the broker itself refused to accept — a feature born and killed in four days.