n4nAI

Semantic Kernel plugins tutorial: function error handling

Learn semantic kernel native function error handling with practical patterns for retries, fallbacks, and structured error types in plugin development.

n4n Team3 min read608 words

Audio narration

Coming soon — every post will get a voice note here.

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.SemanticKernel NuGet)
  • 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 Exception broadly — you lose the semantic signal. Catch your PluginException hierarchy or the structured result.
  • Fixed sleep in retries — exponential backoff with jitter prevents thundering herd. The RetryPolicy above caps at 30s; add Random.Shared.NextDouble() * 0.5 for jitter if you need it.
  • No timeout on HttpClient — the default is infinite. Always set Timeout and respect CancellationToken.
  • Swallowing cancellationOperationCanceledException must 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:

  1. Define a typed exception hierarchy with IsRetryable and RetryAfter so callers can make decisions.
  2. Wrap retries in a reusable policy that respects those fields and backs off exponentially.
  3. Compose fallback chains — primary → cache → static default — each a pure async function.
  4. Add a circuit breaker at the edge to fail fast when the upstream is hard down.
  5. Return structured results from the function so the planner sees Success, Error.Code, Error.Retryable without parsing exceptions.

The patterns here are framework-agnostic. You can drop the RetryPolicy, FallbackPolicy, and CircuitBreaker into any Semantic Kernel project today.

Tagssemantic-kernelpluginserror-handlingnative-functions

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All semantic kernel plugins & native functions posts →