This document describes, as verified directly against source in this repository, how a request gets from an API controller to a cloud provider's SDK today. It exists because the controllers that touch cloud messaging do not all use the same path — this is the single place that spells out exactly which ones do and which don't, so that doesn't have to be re-derived from the codebase (or guessed from a diagram) every time.
Not every controller or background service is covered — only the ones that route to a cloud provider, or that this doc's callers (CHANGELOG.md, docs/COMPREHENSIVE-GUIDE.md) link here for.
| Component | Path to the cloud provider |
|---|---|
MessagesController, QueuesController, TopicsController |
→ IMessageOperationsService → CloudProviderRouter → ICloudMessagingProvider |
CrossCloudTraceController |
→ its own dispatch: Azure via IAzureTraceSearcher, non-Azure via a directly-injected IEnumerable<ICloudMessagingProvider> — not through CloudProviderRouter |
DlqHistoryController |
→ IDlqHistoryService → SQLite (DlqDbContext) — no cloud SDK call at all, it only reads/writes locally persisted DLQ Intelligence history |
DlqMonitorWorker (background) |
→ filters to Azure namespaces only, then scans via the Azure provider path; non-Azure namespaces are skipped with a log line, not attempted |
SimulatorController |
→ in-memory simulator store, reachable only when ASPNETCORE_ENVIRONMENT=Simulator |
The rest of this doc walks through each row.
%%{init: {'theme':'dark'}}%%
graph LR
C1["MessagesController"] --> IMOS
C2["QueuesController"] --> IMOS
C3["TopicsController"] --> IMOS
IMOS["IMessageOperationsService<br/>(MessageOperationsService)"] --> CPR
CPR["CloudProviderRouter<br/>.Resolve(namespace.Provider)"] --> AZP["AzureMessagingProvider"]
CPR --> AWSP["AwsMessagingProvider"]
CPR --> GCPP["GcpMessagingProvider"]
AZP --> AZR["Polly retry pipeline<br/>(inline in MessageReceiver/Sender)"] --> AZSDK["Azure.Messaging.ServiceBus"]
AWSP --> AWSR["AwsResiliencePipeline"] --> AWSSDK["AWSSDK.SQS / AWSSDK.SNS"]
GCPP --> GCPR["GcpResiliencePipeline"] --> GCPSDK["Google.Cloud.PubSub.V1"]
- All three controllers depend on
IMessageOperationsServiceonly — no controller holds a per-providerif (namespace.Provider == ...)branch anymore.CloudProviderRouter.Resolve()throwsInvalidOperationExceptionif a provider isn't registered;IsRegistered()lets callers check first without triggering the exception. - Every provider retries transient errors the same way: 3 attempts, exponential backoff
(1s base, 30s cap), jitter enabled.
- Azure: retry logic lives inline in
MessageReceiver/MessageSender(ServiceHub.Infrastructure/ServiceBus). - AWS:
AwsResiliencePipeline.Create()— retriesAmazonServiceExceptionwhen the SDK marks it retryable, or on 5xx/429. - GCP:
GcpResiliencePipeline.Create()— retriesRpcExceptionforUnavailable,DeadlineExceeded,Internal,ResourceExhausted,Aborted.
- Azure: retry logic lives inline in
- Provider registration today:
AddAzureProvider()is always called fromProgram.cs.AddAwsProvider()/AddGcpProvider()exist and are fully implemented and unit-tested, but are not called on this branch — Azure is the only live provider in a non-Simulator deployment. This is a deliberate wiring decision, not a bug (seeCLAUDE.md). Simulator mode (ASPNETCORE_ENVIRONMENT=Simulator) registers all three viaAddSimulatorProviders(), which is the only way to exercise the AWS/GCP code paths end-to-end without live credentials today.
| Operation | Azure | AWS SQS/SNS | GCP Pub/Sub |
|---|---|---|---|
| Peek dead-letter messages | ✅ | ✅ | ✅ (via convention subscription {name}-dlq) |
| Manual dead-letter (move a message to DLQ on demand) | ✅ | ✅ (MaxReceive redrive) |
❌ — Pub/Sub dead-lettering is policy-driven via MaxDeliveryAttempts; DeadLetterMessagesAsync returns a Validation failure explaining this |
| Message count | ✅ | ✅ | Normalized to Success(0) — Pub/Sub has no direct count API; mirrors the "unsupported read" convention used by GetScheduledMessagesAsync |
%%{init: {'theme':'dark'}}%%
graph LR
CCT["CrossCloudTraceController"] -->|"namespace.Provider == Azure"| ATS["IAzureTraceSearcher<br/>(AzureTraceSearcher)"]
CCT -->|"namespace.Provider != Azure"| CMP["IEnumerable<ICloudMessagingProvider><br/>injected directly, matched by ProviderType"]
ATS --> AZSDK["Azure.Messaging.ServiceBus"]
CMP -->|"provider registered"| PSDK["ListEntitiesAsync + PeekMessagesAsync<br/>per AWS/GCP entity"]
CMP -->|"provider NOT registered"| SKIP["SkipReason recorded in the<br/>namespace summary — not an error"]
CrossCloudTraceController does not use IMessageOperationsService or CloudProviderRouter.
It resolves namespaces by provider itself and dispatches to two different code paths:
- Azure namespaces search in parallel (max 5 concurrent, 30s overall timeout) via
IAzureTraceSearcher— extracted into its own service so the controller only orchestrates and aggregates results. - Non-Azure namespaces are matched against the injected
IEnumerable<ICloudMessagingProvider>byProviderType. If no provider is registered for that type, the namespace is markedWasSearched: falsewith a human-readableSkipReason(e.g."AWS provider is not enabled on this server.") rather than failing the whole trace or silently omitting the namespace.
This means AWS/GCP node search in Cross-Cloud Trace is fully implemented, not a "coming later"
feature — it's gated purely on whether AddAwsProvider()/AddGcpProvider() are called for the
running instance (see §1). Enable Simulator mode, or register a provider, to see it work
end-to-end.
Why this hasn't been unified with §1's router: no functional reason found in source — it
predates the IMessageOperationsService/CloudProviderRouter introduction and was carried forward
as-is. Flagging it here rather than describing it as unified elsewhere in the docs.
%%{init: {'theme':'dark'}}%%
graph LR
DHC["DlqHistoryController<br/>(GetHistory/GetById/Timeline/Notes/Summary/Export)"] --> IDHS["IDlqHistoryService<br/>(DlqHistoryService)"]
IDHS --> DB[("SQLite — DlqDbContext<br/>table: DlqMessages")]
- Every method on
IDlqHistoryServicetakesownerIdand filters on it —GetHistoryAsync,GetByIdAsync,GetTimelineAsync,UpdateNotesAsync,GetSummaryAsync,ExportAsync. A caller can only see or modify DLQ Intelligence records for their own owner ID; a different owner's message ID returnsNotFound, not the record. DlqMessage(the row persisted per dead-lettered message) has aCloudProvidercolumn (CloudProviderType, defaults toAzurefor rows written before the column existed). It's populated by whichever background process detected the dead-letter — currently onlyDlqMonitorWorker, which is Azure-only (see §4), so in practice every row today isAzureuntil AWS/GCP monitoring is added.- This whole path never calls a cloud SDK — it's a read/write against the local SQLite database
only.
DlqMonitorWorkeris what populates that database in the first place.
DlqMonitorWorker polls active namespaces on an interval, scans each for newly dead-lettered
messages, and persists findings via DlqHistoryService/DlqDbContext. It explicitly filters to
namespace.Provider == CloudProviderType.Azure before scanning:
- Non-Azure namespaces are skipped up front (not attempted-then-failed), with a debug log noting how many were skipped per poll cycle.
- This is a scope limitation, not a routing bug — AWS/GCP background DLQ monitoring doesn't exist
yet.
MessagesController's on-demandPeekDeadLetterMessagesAsync(§1) already works for AWS/GCP; it's only the automatic, scheduled scan that's Azure-only.
5. Simulator mode — environment-gated, not just hidden
%%{init: {'theme':'dark'}}%%
graph LR
ENV{"ASPNETCORE_ENVIRONMENT<br/>== Simulator ?"}
ENV -->|"yes"| DI["AddSimulatorProviders()<br/>registers in-memory Azure+AWS+GCP providers<br/>+ simulator store/clock/seeder"]
ENV -->|"yes"| ROUTE["SimulatorOnlyAttribute<br/>(IActionConstraint) allows the route to match"]
ENV -->|"no"| BLOCKED["SimulatorController actions don't match routing → 404<br/>(SimulatorOnlyAttribute fails closed)"]
Two independent layers both have to agree for Simulator endpoints to work, which is why this is described as defense-in-depth rather than a single check:
- DI registration (
Program.cs):AddSimulatorProviders()is only called whenbuilder.Environment.IsEnvironment("Simulator"). Outside that environment, the servicesSimulatorControllerdepends on aren't registered at all. - Routing (
SimulatorOnlyAttribute): anIActionConstraintonSimulatorControllerthat only accepts the action whenIWebHostEnvironment.IsEnvironment("Simulator")— if the check fails, or the environment service can't be resolved, the action doesn't match and ASP.NET Core returns a plain 404, not a 403 (so the existence of Simulator-only routes isn't even distinguishable from "route doesn't exist" in Production).
RateLimitingMiddleware keys its bucket on the authenticated OwnerId (from HttpContext.Items,
populated by the auth middleware earlier in the pipeline) when one is present — "owner:{id}" —
and falls back to the remote IP address — "ip:{addr}" — only for unauthenticated requests.
X-Forwarded-For is never trusted for this, since it's trivially spoofable by the client. This
matters behind a reverse proxy (e.g. Azure App Service): without owner-keying, every request would
present the same proxy IP and all tenants would share one bucket. Default limit: 300 requests/min
(RateLimit:MaxRequests, RateLimit:WindowDuration in appsettings.json).
MessagesController.cs, QueuesController.cs, TopicsController.cs, CrossCloudTraceController.cs,
DlqHistoryController.cs, MessageOperationsService.cs, CloudProviderRouter.cs,
ICloudMessagingProvider.cs, AzureTraceSearcher.cs/IAzureTraceSearcher.cs,
GcpMessageReceiver.cs, AwsMessageReceiver.cs, AwsResiliencePipeline.cs, GcpResiliencePipeline.cs,
DlqMonitorWorker.cs, DlqHistoryService.cs, IDlqHistoryService.cs, DlqMessage.cs,
SimulatorOnlyAttribute.cs, SimulatorController.cs, Program.cs, RateLimitingMiddleware.cs,
appsettings.json — all read directly in the session that produced this document (2026-07-07).
If behavior here looks wrong, re-check these files rather than assuming this doc is still current.