Semantic kernel native function error handling is the difference between a plugin that fails silently in production and one that degrades gracefully. This tutorial walks through the patterns that actually work: structured exception types, retry policies with exponential backoff, circuit breakers, and fallback chains you can compose declaratively.
Prerequisites
- .NET 8 SDK or later
- Semantic Kernel 1.19+ (
Microsoft.SemanticKernelNuGet) - Basic familiarity with kernel functions and plugin authoring
Create a new console project and add the package:
dotnet new console -n SKErrorHandlingDemo
cd SKErrorHandlingDemo
dotnet add package Microsoft.SemanticKernel --version 1.19.0
The problem with unhandled failures
Native functions throw. Network calls time out. Upstream APIs return 429 or 5xx. If you let those bubble up raw, the planner sees a generic Exception and has no semantic signal to act on — no retry hint, no fallback trigger, no user-facing explanation.
// Program.cs - naive version that fails badly
using Microsoft.SemanticKernel;
var builder = Kernel.CreateBuilder();
builder.Plugins.AddFromType<WeatherPlugin>();
var kernel = builder.Build();
var result = await kernel.InvokeAsync("WeatherPlugin", "GetForecast", new() { ["city"] = "Seattle" });
Console.WriteLine(result);
// WeatherPlugin.cs - naive implementation
using Microsoft.SemanticKernel;
public class WeatherPlugin
{
private readonly HttpClient _http = new() { BaseAddress = new Uri("https://api.weather.example.com") };
[KernelFunction]
public async Task<string> GetForecast(string city)
{
var response = await _http.GetAsync($"/forecast?city={Uri.EscapeDataString(city)}");
response.EnsureSuccessStatusCode(); // throws on 4xx/5xx
return await response.Content.ReadAsStringAsync();
}
}
Run it against a flaky endpoint and you get an unhelpful stack trace. The planner cannot distinguish “try again in 2 seconds” from “this city doesn’t exist.”
Structured error types the planner can understand
Define a small exception hierarchy that carries semantic meaning. The planner and your calling code can catch specific types and decide what to do.
// Exceptions/PluginExceptions.cs
namespace SKErrorHandlingDemo.Exceptions;
public abstract class PluginException : Exception
{
public bool IsRetryable { get; }
public TimeSpan? RetryAfter { get; }
protected PluginException(string message, bool isRetryable, TimeSpan? retryAfter = null, Exception? inner = null)
: base(message, inner)
{
IsRetryable = isRetryable;
RetryAfter = retryAfter;
}
}
public class TransientFailureException : PluginException
{
public TransientFailureException(string message, TimeSpan? retryAfter = null, Exception? inner = null)
: base(message, isRetryable: true, retryAfter, inner) { }
}
public class RateLimitedException : PluginException
{
public RateLimitedException(string message, TimeSpan retryAfter, Exception? inner = null)
: base(message, isRetryable: true, retryAfter, inner) { }
}
public class PermanentFailureException : PluginException
{
public PermanentFailureException(string message, Exception? inner = null)
: base(message, isRetryable: false, inner: inner) { }
}
public class NotFoundException : PluginException
{
public NotFoundException(string resource, Exception? inner = null)
: base($"{resource} not found", isRetryable: false, inner: inner) { }
}
Now rewrite the plugin to throw these instead of letting HttpRequestException escape.
// WeatherPlugin.cs - structured errors
using Microsoft.SemanticKernel;
using SKErrorHandlingDemo.Exceptions;
using System.Net;
public class WeatherPlugin
{
private readonly HttpClient _http;
public WeatherPlugin(HttpClient http)
{
_http = http;
_http.BaseAddress = new Uri("https://api.weather.example.com");
}
[KernelFunction]
public async Task<string> GetForecast(string city, CancellationToken cancellationToken = default)
{
var request = new HttpRequestMessage(HttpMethod.Get, $"/forecast?city={Uri.EscapeDataString(city)}");
HttpResponseMessage response;
try
{
response = await _http.SendAsync(request, cancellationToken);
}
catch (TaskCanceledException ex) when (ex.CancellationToken == cancellationToken)
{
throw; // let cancellation bubble
}
catch (Exception ex)
{
throw new TransientFailureException("Network error calling weather API", TimeSpan.FromSeconds(2), ex);
}
return response.StatusCode switch
{
HttpStatusCode.OK => await response.Content.ReadAsStringAsync(cancellationToken),
HttpStatusCode.NotFound => throw new NotFoundException($"City '{city}'"),
HttpStatusCode.TooManyRequests => ParseRetryAfter(response) is { } retryAfter
? throw new RateLimitedException("Rate limited by weather API", retryAfter)
: throw new RateLimitedException("Rate limited by weather API", TimeSpan.FromSeconds(60)),
HttpStatusCode.InternalServerError or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable
=> throw new TransientFailureException("Weather API unavailable", TimeSpan.FromSeconds(5)),
_ => throw new PermanentFailureException($"Weather API returned {(int)response.StatusCode}")
};
}
private static TimeSpan? ParseRetryAfter(HttpResponseMessage response)
{
if (response.Headers.RetryAfter?.Delta is { } delta) return delta;
if (response.Headers.TryGetValues("Retry-After", out var values) &&
int.TryParse(values.FirstOrDefault(), out var seconds))
{
return TimeSpan.FromSeconds(seconds);
}
return null;
}
}
Register the HttpClient with DI so you can configure timeouts and handlers centrally:
// Program.cs - DI setup
using Microsoft.Extensions.DependencyInjection;
using Microsoft.SemanticKernel;
using SKErrorHandlingDemo.Exceptions;
var services = new ServiceCollection();
services.AddHttpClient<WeatherPlugin>(client =>
{
client.Timeout = TimeSpan.FromSeconds(10);
client.DefaultRequestHeaders.UserAgent.ParseAdd("SKErrorHandlingDemo/1.0");
});
services.AddTransient<WeatherPlugin>();
var kernelBuilder = Kernel.CreateBuilder();
kernelBuilder.Services.AddSingleton(services.BuildServiceProvider());
kernelBuilder.Plugins.AddFromType<WeatherPlugin>();
var kernel = kernelBuilder.Build();
Retry policy as a reusable delegate
Don’t sprinkle try/catch loops everywhere. Wrap the retry logic in a delegate that understands your exception hierarchy.
// Resilience/RetryPolicy.cs
using SKErrorHandlingDemo.Exceptions;
public static class RetryPolicy
{
public static async Task<T> ExecuteAsync<T>(
Func<CancellationToken, Task<T>> action,
CancellationToken cancellationToken = default,
int maxAttempts = 3,
TimeSpan? baseDelay = null)
{
var delay = baseDelay ?? TimeSpan.FromSeconds(1);
Exception? lastException = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
return await action(cancellationToken);
}
catch (PluginException ex) when (ex.IsRetryable)
{
lastException = ex;
var wait = ex.RetryAfter ?? delay;
if (attempt < maxAttempts)
{
Console.WriteLine($"[Retry {attempt}/{maxAttempts}] {ex.Message}. Waiting {wait.TotalSeconds}s...");
await Task.Delay(wait, cancellationToken);
delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 2, 30)); // exponential backoff, cap at 30s
}
}
}
throw new AggregateException($"Failed after {maxAttempts} attempts", lastException!);
}
public static Task ExecuteAsync(
Func<CancellationToken, Task> action,
CancellationToken cancellationToken = default,
int maxAttempts = 3,
TimeSpan? baseDelay = null)
=> ExecuteAsync(async ct => { await action(ct); return true; }, cancellationToken, maxAttempts, baseDelay);
}
Now invoke through the policy instead of calling the kernel function directly:
// Program.cs - using retry policy
using Microsoft.SemanticKernel;
using SKErrorHandlingDemo.Exceptions;
using SKErrorHandlingDemo.Resilience;
var kernel = /* ... build kernel as before ... */;
var forecast = await RetryPolicy.ExecuteAsync(async ct =>
{
var result = await kernel.InvokeAsync("WeatherPlugin", "GetForecast", new() { ["city"] = "Seattle" }, ct);
return result.GetValue<string>()!;
});
Console.WriteLine(forecast);
Expected output on transient failure:
[Retry 1/3] Network error calling weather API. Waiting 2s...
[Retry 2/3] Network error calling weather API. Waiting 4s...
{"city":"Seattle","forecast":"cloudy","temp":58}
Fallback chains for graceful degradation
When retries exhaust, you often want a fallback — cached data, a simpler model, a static response. Compose this as a pipeline.
// Resilience/FallbackPolicy.cs
public static class FallbackPolicy
{
public static async Task<T> ExecuteAsync<T>(
IEnumerable<Func<CancellationToken, Task<T>>> strategies,
CancellationToken cancellationToken = default)
{
var exceptions = new List<Exception>();
foreach (var strategy in strategies)
{
try
{
return await strategy(cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
exceptions.Add(ex);
Console.WriteLine($"[Fallback] Strategy failed: {ex.Message}. Trying next...");
}
}
throw new AggregateException("All fallback strategies failed", exceptions);
}
}
Wire it up with a primary call, a cached fallback, and a static default:
// Program.cs - fallback chain
using Microsoft.SemanticKernel;
using SKErrorHandlingDemo.Resilience;
var kernel = /* ... */;
var cache = new MemoryCache<string, string>(new MemoryCacheOptions());
async Task<string> Primary(CancellationToken ct)
{
var result = await kernel.InvokeAsync("WeatherPlugin", "GetForecast", new() { ["city"] = "Seattle" }, ct);
var json = result.GetValue<string>()!;
cache.Set("Seattle", json, TimeSpan.FromMinutes(10));
return json;
}
async Task<string> FromCache(CancellationToken ct)
{
if (cache.TryGetValue("Seattle", out var cached))
{
Console.WriteLine("[Fallback] Serving stale cache");
return cached!;
}
throw new InvalidOperationException("Cache miss");
}
async Task<string> StaticDefault(CancellationToken ct)
{
Console.WriteLine("[Fallback] Serving static default");
return """{"city":"Seattle","forecast":"unknown","temp":null,"source":"static"}""";
}
var forecast = await FallbackPolicy.ExecuteAsync(new[]
{
ct => RetryPolicy.ExecuteAsync(() => Primary(ct), ct),
FromCache,
StaticDefault
});
Console.WriteLine(forecast);
Expected output when API is down but cache is warm:
[Retry 1/3] Weather API unavailable. Waiting 5s...
[Retry 2/3] Weather API unavailable. Waiting 10s...
[Retry 3/3] Weather API unavailable. Waiting 20s...
[Fallback] Strategy failed: Failed after 3 attempts. Trying next...
[Fallback] Serving stale cache
{"city":"Seattle","forecast":"cloudy","temp":58}
Circuit breaker to stop hammering a dead service
If the upstream is hard down, retrying just adds load. A circuit breaker trips after N failures and fails fast for a cooldown period.
// Resilience/CircuitBreaker.cs
using System.Threading.Tasks;
public sealed class CircuitBreaker : IDisposable
{
private readonly int _failureThreshold;
private readonly TimeSpan _openDuration;
private readonly object _lock = new();
private int _failureCount;
private DateTime _openedAt;
private State _state = State.Closed;
public CircuitBreaker(int failureThreshold = 5, TimeSpan? openDuration = null)
{
_failureThreshold = failureThreshold;
_openDuration = openDuration ?? TimeSpan.FromMinutes(1);
}
public async Task<T> ExecuteAsync<T>(Func<CancellationToken, Task<T>> action, CancellationToken ct = default)
{
CheckState();
try
{
var result = await action(ct);
OnSuccess();
return result;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
OnFailure();
throw;
}
}
private void CheckState()
{
lock (_lock)
{
if (_state == State.Open && DateTime.UtcNow - _openedAt >= _openDuration)
{
_state = State.HalfOpen;
Console.WriteLine("[CircuitBreaker] Half-open: allowing probe request");
}
if (_state == State.Open)
{
throw new InvalidOperationException("Circuit breaker is open");
}
}
}
private void OnSuccess()
{
lock (_lock)
{
_failureCount = 0;
if (_state == State.HalfOpen)
{
_state = State.Closed;
Console.WriteLine("[CircuitBreaker] Closed: service recovered");
}
}
}
private void OnFailure()
{
lock (_lock)
{
_failureCount++;
if (_state == State.HalfOpen || _failureCount >= _failureThreshold)
{
_state = State.Open;
_openedAt = DateTime.UtcNow;
Console.WriteLine($"[CircuitBreaker] Opened after {_failureCount} failures. Cooldown: {_openDuration.TotalSeconds}s");
}
}
}
public void Dispose() { }
private enum State { Closed, Open, HalfOpen }
}
Use it as the outermost wrapper:
// Program.cs - full pipeline
using Microsoft.SemanticKernel;
using SKErrorHandlingDemo.Resilience;
var kernel = /* ... */;
var cache = new MemoryCache<string, string>(new MemoryCacheOptions());
var breaker = new CircuitBreaker(failureThreshold: 3, openDuration: TimeSpan.FromSeconds(30));
async Task<string> Primary(CancellationToken ct)
{
var result = await kernel.InvokeAsync("WeatherPlugin", "GetForecast", new() { ["city"] = "Seattle" }, ct);
var json = result.GetValue<string>()!;
cache.Set("Seattle", json, TimeSpan.FromMinutes(10));
return json;
}
async Task<string> FromCache(CancellationToken ct)
{
if (cache.TryGetValue("Seattle", out var cached)) return cached!;
throw new InvalidOperationException("Cache miss");
}
async Task<string> StaticDefault(CancellationToken ct)
=> """{"city":"Seattle","forecast":"unknown","temp":null,"source":"static"}""";
var forecast = await breaker.ExecuteAsync(async ct =>
await FallbackPolicy.ExecuteAsync(new[]
{
ct => RetryPolicy.ExecuteAsync(() => Primary(ct), ct),
FromCache,
StaticDefault
}, ct));
Console.WriteLine(forecast);
Expected output when circuit opens:
[Retry 1/3] Weather API unavailable. Waiting 5s...
[Retry 2/3] Weather API unavailable. Waiting 10s...
[Retry 3/3] Weather API unavailable. Waiting 20s...
[Fallback] Strategy failed: Failed after 3 attempts. Trying next...
[Fallback] Serving stale cache
{"city":"Seattle","forecast":"cloudy","temp":58}
...
[CircuitBreaker] Opened after 3 failures. Cooldown: 30s
[CircuitBreaker] Half-open: allowing probe request
[Retry 1/3] Weather API unavailable. Waiting 5s...
...
[CircuitBreaker] Closed: service recovered
Wiring it into the kernel for planner visibility
The planner sees function results, not your wrapper code. To make errors actionable for the planner, return structured results from the function itself — not exceptions.
// WeatherPlugin.cs - planner-friendly results
using Microsoft.SemanticKernel;
using System.Text.Json.Serialization;
public class ForecastResult
{
[JsonPropertyName("success")] public bool Success { get; init; }
[JsonPropertyName("data")] public string? Data { get; init; }
[JsonPropertyName("error")] public ErrorInfo? Error { get; init; }
public static ForecastResult Ok(string data) => new() { Success = true, Data = data };
public static ForecastResult Fail(string code, string message, bool retryable, int? retryAfterSeconds = null)
=> new() { Success = false, Error = new ErrorInfo(code, message, retryable, retryAfterSeconds) };
}
public record ErrorInfo(string Code, string Message, bool Retryable, int? RetryAfterSeconds);
public class WeatherPlugin
{
private readonly HttpClient _http;
public WeatherPlugin(HttpClient http) => _http = http;
[KernelFunction]
public async Task<ForecastResult> GetForecast(string city, CancellationToken ct = default)
{
try
{
var request = new HttpRequestMessage(HttpMethod.Get, $"/forecast?city={Uri.EscapeDataString(city)}");
var response = await _http.SendAsync(request, ct);
return response.StatusCode switch
{
HttpStatusCode.OK => ForecastResult.Ok(await response.Content.ReadAsStringAsync(ct)),
HttpStatusCode.NotFound => ForecastResult.Fail("NOT_FOUND", $"City '{city}' not found", false),
HttpStatusCode.TooManyRequests => ForecastResult.Fail(
"RATE_LIMITED", "Rate limited", true,
(int?)response.Headers.RetryAfter?.Delta?.TotalSeconds ?? 60),
HttpStatusCode.InternalServerError or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable
=> ForecastResult.Fail("TRANSIENT", "Service unavailable", true, 5),
_ => ForecastResult.Fail("UPSTREAM_ERROR", $"HTTP {(int)response.StatusCode}", false)
};
}
catch (TaskCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
return ForecastResult.Fail("NETWORK_ERROR", ex.Message, true, 2);
}
}
}
Now the planner gets a typed result it can inspect:
// Planner can now do:
var result = await kernel.InvokeAsync("WeatherPlugin", "GetForecast", new() { ["city"] = "Seattle" });
var forecast = result.GetValue<ForecastResult>();
if (!forecast.Success && forecast.Error?.Retryable == true)
{
// planner knows to wait and retry, or invoke a different skill
await Task.Delay(TimeSpan.FromSeconds(forecast.Error.RetryAfterSeconds ?? 5));
// retry or fallback
}
Testing the error paths
Unit test each failure mode without hitting the network. Use HttpMessageHandler mocking.
// WeatherPluginTests.cs
using Microsoft.SemanticKernel;
using RichardSzalay.MockHttp;
using Xunit;
public class WeatherPluginTests
{
[Fact]
public async Task ReturnsStructuredErrorOn404()
{
var mockHttp = new MockHttpMessageHandler();
mockHttp.When("https://api.weather.example.com/forecast?city=Unknown")
.Respond(System.Net.HttpStatusCode.NotFound);
var client = mockHttp.ToHttpClient();
client.BaseAddress = new Uri("https://api.weather.example.com");
var plugin = new WeatherPlugin(client);
var result = await plugin.GetForecast("Unknown");
Assert.False(result.Success);
Assert.Equal("NOT_FOUND", result.Error?.Code);
Assert.False(result.Error?.Retryable);
}
[Fact]
public async Task ReturnsRetryableErrorOn500()
{
var mockHttp = new MockHttpMessageHandler();
mockHttp.When("https://api.weather.example.com/forecast?city=Seattle")
.Respond(System.Net.HttpStatusCode.InternalServerError);
var client = mockHttp.ToHttpClient();
client.BaseAddress = new Uri("https://api.weather.example.com");
var plugin = new WeatherPlugin(client);
var result = await plugin.GetForecast("Seattle");
Assert.False(result.Success);
Assert.Equal("TRANSIENT", result.Error?.Code);
Assert.True(result.Error?.Retryable);
Assert.Equal(5, result.Error?.RetryAfterSeconds);
}
[Fact]
public async Task ReturnsSuccessOn200()
{
var mockHttp = new MockHttpMessageHandler();
mockHttp.When("https://api.weather.example.com/forecast?city=Seattle")
.Respond("application/json", """{"city":"Seattle","temp":72}""");
var client = mockHttp.ToHttpClient();
client.BaseAddress = new Uri("https://api.weather.example.com");
var plugin = new WeatherPlugin(client);
var result = await plugin.GetForecast("Seattle");
Assert.True(result.Success);
Assert.Contains("72", result.Data);
}
}
Run with dotnet test — all three pass.
What to avoid
- Catching
Exceptionbroadly — you lose the semantic signal. Catch yourPluginExceptionhierarchy or the structured result. - Fixed sleep in retries — exponential backoff with jitter prevents thundering herd. The
RetryPolicyabove caps at 30s; addRandom.Shared.NextDouble() * 0.5for jitter if you need it. - No timeout on HttpClient — the default is infinite. Always set
Timeoutand respectCancellationToken. - Swallowing cancellation —
OperationCanceledExceptionmust bubble. The retry and fallback policies above re-throw it correctly. - Stateful singletons for circuit breakers — if you run multiple kernel instances, share the breaker via DI (
services.AddSingleton<CircuitBreaker>()) so they coordinate.
Summary
Semantic kernel native function error handling works when you:
- Define a typed exception hierarchy with
IsRetryableandRetryAfterso callers can make decisions. - Wrap retries in a reusable policy that respects those fields and backs off exponentially.
- Compose fallback chains — primary → cache → static default — each a pure async function.
- Add a circuit breaker at the edge to fail fast when the upstream is hard down.
- Return structured results from the function so the planner sees
Success,Error.Code,Error.Retryablewithout parsing exceptions.
The patterns here are framework-agnostic. You can drop the RetryPolicy, FallbackPolicy, and CircuitBreaker into any Semantic Kernel project today.