Skip to main content

Command Palette

Search for a command to run...

Azure OpenAI Content Filter Errors in .NET APIs: Causes and Fixes

Updated
12 min readView as Markdown
Azure OpenAI Content Filter Errors in .NET APIs: Causes and Fixes

The first time an Azure OpenAI content filter error hit one of our production .NET APIs, it did not look like a content filter error at all. It looked like a flaky provider: a burst of 400 Bad Request responses from a support-desk endpoint that had been quiet for weeks, all from the same three customers, all retried three times by our resilience pipeline before failing. The prompts were customer support tickets. Nothing about them was remotely unsafe. The filter had fired on a phrase inside an attached error log.

That is the thing about content filtering in Azure OpenAI: it fails in two completely different ways, and most .NET codebases only handle one of them. If you are reading this because you just saw content_filter in a log line, the patterns below go deeper on Patreon, where the annotated source for a guarded IChatClient pipeline is wired end to end with the retry policy, the telemetry, and the fallback path already connected.

Handling a blocked prompt correctly is really a resilience question, not a safety question: you have to know which failures are worth retrying, which need a cheaper fallback model, and which should surface to the user immediately. Chapter 15 of the AI-Powered .NET APIs course covers exactly that layer - retries, timeouts, and fallback models - against a real ASP.NET Core support API rather than a console demo.

AI-Powered .NET APIs

What the Azure OpenAI Content Filter Error Actually Means

Azure OpenAI runs both your prompt and the model's completion through a separate classification system powered by Azure AI Content Safety. Per the Azure content filtering documentation, it scores text across four harm categories - hate, sexual, violence, and self-harm - at four severity levels: safe, low, medium, and high. Optional filters add prompt shields (jailbreak and indirect attack detection), protected material for text and code, groundedness, and PII detection.

The critical detail for .NET developers is that a filtered prompt and a filtered completion are two different failures with two different shapes:

What was filtered HTTP status Signal in the response Is it retryable?
The prompt (input) 400 error.code is content_filter No. The same prompt fails every time.
The completion (output) 200 finish_reason is content_filter No, but it is recoverable.
Nothing (filter unavailable) 200 content_filter_results.error is present Yes, but treat output as unvetted.

A single try/catch around your IChatClient call catches the first row and silently sails past the other two. That gap is what produces the worst class of bug here: an endpoint that returns a truncated, half-finished answer with a 200 OK and no indication anything went wrong.

Below are the five causes I actually see in production .NET codebases, in the order they tend to bite.

Cause 1: The Prompt Was Blocked and the Call Threw a 400

This is the loud one. Azure returns HTTP 400 with a body whose error.code is content_filter and whose param is prompt. The message reads "The response was filtered".

In modern .NET the exception type depends on which SDK you are on. The current Azure.AI.OpenAI 2.x and OpenAI 2.x packages are built on System.ClientModel, so you catch ClientResultException and read its Status. The older Azure.AI.OpenAI 1.x threw Azure.RequestFailedException instead, which is why so much sample code on the internet no longer compiles.

// Microsoft.Extensions.AI 10.x over Azure.AI.OpenAI 2.x
try
{
    return await chatClient.GetResponseAsync(messages, options, ct);
}
catch (ClientResultException ex) when (ex.Status == 400 &&
    ex.Message.Contains("content_filter", StringComparison.Ordinal))
{
    throw new PromptBlockedException("Prompt rejected by content safety.", ex);
}

Mapping it to your own exception type matters more than it looks. A PromptBlockedException is something your global exception handler can turn into a deliberate 422 Unprocessable Entity with a Problem Details body, instead of leaking a raw provider 400 to the caller. If you have not centralised that yet, our walkthrough of resilient LLM calls in .NET covers where this belongs in the pipeline.

Cause 2: The Completion Was Filtered and Nothing Threw

This is the quiet one, and it is the cause I see missed most often. The request succeeds. You get a 200. You get a ChatResponse object. The Text is empty or cut off mid-sentence, because the model started generating, tripped an output filter, and stopped.

Microsoft's own guidance is blunt about this: always check the finish reason. In Microsoft.Extensions.AI that is a first-class value.

var response = await chatClient.GetResponseAsync(messages, options, ct);

if (response.FinishReason == ChatFinishReason.ContentFilter)
{
    // Text may be empty or truncated. Never render it as a normal answer.
    logger.LogWarning("Completion filtered for conversation {ConversationId}", conversationId);
    return AnswerResult.Blocked();
}

ChatFinishReason.ContentFilter ships in Microsoft.Extensions.AI.Abstractions 10.x alongside Stop, Length, and ToolCalls. If you are checking anything at all today, you are probably checking Length to detect truncation. Add ContentFilter next to it.

Streaming makes this worse, not better. With GetStreamingResponseAsync the service streams chunks until the filter fires, so the user watches a plausible answer appear and then stop dead. The finish reason arrives on the final ChatResponseUpdate, which means you must inspect the last update rather than assuming a clean stop. Our guide to streaming LLM responses in ASP.NET Core with IChatClient covers the Server-Sent Events plumbing this hooks into.

Cause 3: Your Retry Policy Is Burning Money on a Guaranteed Failure

A content filter 400 is deterministic. The same prompt will be blocked on every attempt, forever. Yet the default resilience pipeline most teams bolt on treats every 400 as a transient HTTP failure worth three attempts with exponential backoff.

In one API we inherited, that produced a very specific signature in the bill: a small number of users generating four times the expected request volume, all failing. The fix is to make the retry predicate explicit rather than status-code-shaped.

// Retry throttling and server faults. Never retry content_filter -
// a rejected prompt is rejected deterministically.
ShouldHandle = args => ValueTask.FromResult(
    args.Outcome.Exception is ClientResultException e &&
    (e.Status == 429 || e.Status >= 500))

Two things fall out of getting this right. Your p99 latency for blocked prompts drops from three backoff cycles to a single round trip, and your logs stop lying to you about the failure rate.

Cause 4: The RAG Context Tripped the Filter, Not the User

This is the cause that took us longest to diagnose, and it only appears once you add retrieval. The user's question is fine. The retrieved chunks you paste into the grounded prompt are not.

Support systems are the classic example: a knowledge base article about handling abusive callers, an incident postmortem quoting a threatening email, a moderation runbook. Embed those, retrieve them, and you have just sent a prompt that scores high on the violence or hate classifier - because of your own documents.

The diagnostic tell is that the same question succeeds sometimes and fails other times, with no change to user input. If retrieval is non-deterministic, so is your filter outcome.

Three fixes, in order of how much they help:

  1. Log the retrieved chunk IDs alongside every blocked prompt. Without this you are guessing. With it, one offending document usually accounts for most of the failures.

  2. Score documents at ingestion, not at query time. Running your knowledge base through Azure AI Content Safety once during the ingestion pipeline is far cheaper than discovering the problem per request.

  3. Keep retrieved context in a clearly delimited section of the prompt. Document delimiters are required for indirect attack detection to work at all, and they make it far easier to attribute a block to context rather than user input.

That last point overlaps heavily with injection defence. If poisoned or hostile retrieved content is a live concern for you, preventing prompt injection in ASP.NET Core AI APIs goes through the delimiter and untrusted-input patterns properly.

Cause 5: The Filter Never Ran and You Assumed It Did

There is a documented failure mode where the content filtering system is unavailable or times out. Azure does not fail the request. It completes it, unfiltered, and signals this by putting an error object inside content_filter_results with the code content_filter_error.

If your application has a compliance story that depends on filtering having happened, "the request returned 200" is not evidence that it did. Microsoft's best-practice list calls this out explicitly: verify there is no error object in content_filter_results. For most .NET apps this means reading the provider's raw response annotations rather than relying only on the abstraction layer, and deciding up front whether an unfiltered completion is acceptable in your domain.

Why Does Azure OpenAI Block a Prompt That Looks Harmless?

Because the classifier scores text, not intent, and your prompt is much bigger than the user's message. A "harmless" prompt that gets blocked almost always contains one of four things: retrieved knowledge base content, a pasted stack trace or log, prior conversation turns replayed as history, or a system prompt that enumerates the behaviours the model must refuse.

That last one catches people out regularly. Writing a detailed safety instruction into your system prompt means the prompt itself now contains explicit descriptions of harmful content, and the input classifier scores it exactly like any other text.

Three practical checks when a prompt is blocked and you cannot see why:

  • Reproduce with the user message alone. If it passes, the trigger is in the context you added.

  • Bisect the assembled prompt: system message, retrieved chunks, history, user turn.

  • Check whether your deployment uses a custom filter configuration. Thresholds are configurable per category at low, medium, or high, separately for prompts and completions, and a stricter-than-default policy on a shared Foundry resource is a very common surprise.

How to Stop Content Filter Errors From Reaching Users

Once you know the failure shapes, the handling is mechanical. This is the checklist we apply to every AI endpoint we ship:

  1. Check FinishReason on every response. Treat ContentFilter as a distinct outcome, not as an empty answer.

  2. Map the 400 to a typed exception and translate it into a Problem Details response with a status code you chose deliberately.

  3. Exclude 400 from retry. Retry 429 and 5xx only.

  4. Log the category, not just the failure. The annotations tell you which classifier fired and at what severity, which is the difference between a fixable content problem and a mystery.

  5. Give the user an honest message. "We could not process this request" beats a truncated answer that looks complete.

  6. Decide your fallback before you need one. For some workloads a smaller self-hosted model is a legitimate degradation path; for others, refusing is the correct behaviour.

  7. Alert on the rate, not the event. Isolated blocks are normal. A step change in the block rate usually means someone changed a system prompt or ingested a new document set.

The cost side of this is worth watching too, since blocked prompts still consume input tokens on every attempt. LLM model routing in .NET covers the tiering logic that keeps a fallback path from becoming its own budget problem.

Frequently Asked Questions

Why does Azure OpenAI return 400 content_filter instead of an empty response?

Because the prompt never reached the model. Azure classifies input before inference, and a prompt scored at or above your configured threshold is rejected outright with HTTP 400 and an error.code of content_filter. A completion that gets filtered is different: inference did run, so you get HTTP 200 with finish_reason set to content_filter and possibly partial text.

How do I detect a filtered response with Microsoft.Extensions.AI?

Compare ChatResponse.FinishReason against ChatFinishReason.ContentFilter after every call. It is a struct value in Microsoft.Extensions.AI.Abstractions 10.x, and it works the same way for non-streaming responses and for the final ChatResponseUpdate in a streaming sequence. Do not infer filtering from empty text - an empty answer has several other causes.

Should I retry an Azure OpenAI content filter error?

No. Both the 400 on a filtered prompt and the content_filter finish reason are deterministic for the same input, so retrying only multiplies latency and token spend. Retry 429 throttling responses and 5xx server errors; exclude 400 from your resilience pipeline's retry predicate explicitly rather than relying on a generic transient-fault handler.

Can I turn off content filtering for my Azure OpenAI deployment?

Only partially, and only with approval. Thresholds for the four harm categories are configurable at low, medium, or high separately for prompts and completions. Turning filtering fully off, or switching completions to annotate-only, requires being approved for modified content filters through Microsoft's Limited Access review. Plan on filtering staying on.

Why does my RAG endpoint hit the content filter when the user's question is safe?

Because the classifier sees the assembled prompt, including the retrieved chunks. Knowledge base articles about abuse handling, incident reports, or moderation policy routinely score high on the violence or hate classifiers. Log the retrieved document IDs with every block, screen your corpus at ingestion time, and keep retrieved context inside explicit document delimiters.


About the Author

I'm Celin Daniel, Co-founder of Coding Droplets. I've been building .NET and ASP.NET Core systems in production for 13+ years - APIs, distributed backends, enterprise platforms. Everything I write here comes from real shipping experience: patterns that held up, trade-offs that bit us, and lessons learned the hard way.

More from this blog

C

Coding Droplets

326 posts

Coding Droplets is your go-to resource for .NET and ASP.NET Core development. Whether you're just starting out or building production systems, you'll find practical guides, real-world patterns, and clear explanations that actually make sense.

From beginner-friendly tutorials to advanced architecture decisions. We publish fresh .NET content every day to help you grow at every stage of your career.