.Net 9 Hybrid Cache ile Idempotent Rest Api

Furkan Güngör
10 min readJan 13, 2025

--

Idempotent kavramı bir istemcinin aynı isteği birden fazla kez göndermesi durumunda, sonucun her zaman aynı olması gerektiğini ifade eder. Bu kavram, API’lerin güvenilirliğini ve kararlılığını sağlamak için kritik bir rol oynar. Bu yazıda, Idempotent Rest Apikavramının ne olduğunu, neden önemli olduğunu ve .Net 9 ile gelen Hybrid Cacheözelliğini kullanarak bunu nasıl gerçekleştirebileceğimizi anlatmaya çalışacağım.

Idempotent(?)

Basit bir tanım ile, bir operasyonun idempotent olması, bu operasyonun birden fazla kez tekrarlanması durumunda aynı sonucu üretmesi anlamına gelir. Başka bir deyişle, bir istemci aynı isteği kaç kez gönderirse göndersin, sunucu tarafında etkisi yalnızca bir kez ortaya çıkmalıdır.

Daha detaylı bilgi almak için RFC 9110 dökümanını inceleyebilirsiniz.

Idempotentkavramı, özellikle ağ sorunlarının yeniden denenmiş isteklere yol açabileceği dağıtılmış sistemlerde API’nizin güvenilirliğini önemli ölçüde artırır. Idempotency uygulayarak, istemci yeniden denemeleri nedeniyle oluşabilecek yinelenen işlemleri önleyebilirsiniz.

Http Methods

Bazı metotlar doğası gereği idempotenttir;

  • GET: Veri getirme işlemi, sunucuda bir değişikliğe yol açmadığı için idempotenttir.
  • PUT: Veri oluşturma ya da güncelleme işlemi, aynı veriyi tekrar tekrar gönderdiğinizde sonuç aynı kalacaktır.
  • DELETE: Bir kaynağı silmek için kullanıldığında, birden fazla silme isteği aynı sonucu verir (kaynak zaten silinmiş durumdadır)

Ancak POST metodu varsayılan olarak idempotent değildir. Aynı POST isteğini tekrar tekrar göndermek, çoğu durumda birden fazla kayıt oluşturulmasına neden olur.

Hybrid Cache

Caching, hızlı ve ölçeklenebilir uygulamalar oluşturmak için kullandığımız önemli yöntemlerden biridir. İki adet cache seçeneği vardır. In-Memory ve Distributed cache. Her birinin kendine özgü dezavantajları vardır. IMemoryCache hızlıdır ancak tek bir sunucuyla sınırlıdır. IDistributedCache birden fazla sunucuda çalışır.

.NET 9, her iki yaklaşımın en iyilerini birleştiren yeni bir kitaplık olan HybridCache’i sunar. Cache stampede gibi yaygın cache sorunlarını önler. Ayrıca tag based invalidation ve daha iyi performans izleme gibi yararlı özellikler de ekler.

IDistributedCache kullandıysanız, önbelleğe bir değer atamadan önce nesneyi JSON serialization getirmeniz gerekmektedir.Cache read için de aynı işlem geçerlidir. Ayrıca, birçok thread aynı anda önbelleğe ulaştığında, concurrency ile ilgili sorunlar yaşayabilirsiniz.

Yeni HybridCache sizin için gereken tüm işi yapar. Ayrıca, yazılım sistemlerinde birden fazla işlemin, thread veya sunucunun bir kaynağı aşırı yüklemesini veya paylaşılan verilere veya kaynaklara aynı anda erişirken gereksiz işler yapmasını önlemek için kullanılan teknikleri ifade eden “Stampede koruması”nı da uygular.

Hybrid Cache ile yapabilecekleriniz;

  • L1 : Memory Cache
  • L2 : Distributed Cache (Redis etc.)
  • Cache stampede
  • Tag-based cache invalidation
  • Configurable serialization

Hybrid Cache kullanmak için projenize Microsoft.Extensions.Caching.Hybrid eklemeniz gerekir.

builder.Services.AddHybridCache(); // Not shown: optional configuration API.
builder.Services.AddHybridCache(options =>
{
// Maximum size of cached items
options.MaximumPayloadBytes = 1024 * 1024 * 10; // 10MB
options.MaximumKeyLength = 512;

// Default timeouts
options.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(30),
LocalCacheExpiration = TimeSpan.FromMinutes(30)
};
});

Artık HybridCache özelliğini kullanabilirsiniz.

public class SomeService(HybridCache cache)
{
public async Task<SomeInformation> GetSomeInformationAsync
(string name, int id, CancellationToken token = default)
{
return await cache.GetOrCreateAsync(
$"someinfo:{name}:{id}", // Unique key for this combination.
async cancel => await SomeExpensiveOperationAsync(name, id, cancel),
token: token
);
}
}

GetOrCreate metodu ile ilgili unique key varsa direkt okuyacak ancak yoksa aldığı Action parametresi ile ilgili kodu çalıştıracaktır.

Eğer distributed cache kullanıyorsanız Hybrid Cache otomatik olarak kullandığınız teknolojiyi yakalar.

// Add Redis
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "your-redis-connection-string";
});

builder.Services.AddHybridCache();

Implementation

Uygulama için gerekli olan kavramları öğrendiğimize göre artık implementasyona başlayabiliriz. 🚀

.Net üzerinde geliştirdiğimiz Api’lere Idempotency özelliğini eklemek için bazı özelliklerden faydalanmamız gerekiyor. Öncelikle implementasyonu yaparken nelere dikkat etmemiz gerektiğini anlayalım. 🧐

  • Idempotency özelliğini kazandırdığımız endpointler için clientlar request atarken benzersiz bir anahtar oluşturmalıdırlar. Bu anahtarın adını X-Request-Id olarak tanımlayabiliriz.
  • Sunucu bu bilgiyi okur. Eğer bilgi yoksa requesti işlemez ve hata döndürür.
  • Eğer bilgi varsa bu anahtar ile daha önce gerçekleştirdiği bir işlem var mı diye kontrol eder.
  • Eğer varsa request’i işlemez ve daha önce gerçekleşen işlemin yanıtını döndürür.
  • Eğer yoksa request’i işleme alır ve ilgili yanıtı Hybrid Cache ile cacheler.

Bu özellikleri yerine getirebilmek için ilgili endpointlerden önce araya girecek bir yapıya ihtiyacımız var. ActionFilter özelliği ile ilgili endpointlerden önce araya girip gerekli kontroller yapılabilir. Ancak Minimal Api’lerde ActionFilter yerine IEndpointFilter kullanmamız gerekiyor. Her ikisi için de örnekler oluşturabiliriz. 🎉

Öncelikle projemize Hybrid Cache adımında anlatıldığı gibi gerekli konfigürasyonları yapmamız gerekiyor. Ardından ActionFilter için gerekli tanımlamaları yaparak başlayabiliriz.

/// <summary>
/// This Attribute provides idempotent endpoint functionality.
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public sealed class IdempotentAttribute(int cacheTimeInMinutes = IdempotentAttribute.DefaultCacheTimeInSeconds)
: Attribute, IAsyncActionFilter
{
private const int DefaultCacheTimeInSeconds = 60;
private readonly TimeSpan _cacheDuration = TimeSpan.FromSeconds(cacheTimeInMinutes);
}

Her endpoint için farklı bir cache süresi belirleyebiliriz. Default olarak 60 saniyedir.

IAsyncActionFilter override edebileceğimiz bir yöntem sunar.

    /// <summary>
/// Executes the action filter asynchronously, providing idempotent endpoint functionality.
/// </summary>
/// <param name="context">The context of the action being executed.</param>
/// <param name="next">The delegate to execute the next action filter or action method.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
if (!TryGetRequestId(context, out Guid requestId))
{
context.Result = new BadRequestObjectResult("Invalid or missing X-Request-Id header");
return;
}

var cacheKey = GetCacheKey(requestId);
var cache = context.HttpContext.RequestServices.GetRequiredService<HybridCache>();
var cachedResult = await GetCachedResultAsync(cache, cacheKey);

if (cachedResult is not null)
{
context.Result = CreateCachedResult(cachedResult);
return;
}

var executedContext = await next();
await CacheResponseAsync(cache, cacheKey, executedContext);
}

await next(); satırından önce olan kısımlar endpoint çalışmadan önce sonrası ise çalıştıktan sonra çalışır.

Şimdi bütün yardımcı metotların neler yaptığına bakalım. 🧐

    /// <summary>
/// Tries to retrieve the request ID from the HTTP headers.
/// </summary>
/// <param name="context">The context of the action being executed.</param>
/// <param name="requestId">The output parameter that will contain the parsed request ID if successful.</param>
/// <returns>
/// True if the request ID was successfully retrieved and parsed; otherwise, false.
/// </returns>
private static bool TryGetRequestId(ActionExecutingContext context, out Guid requestId)
{
requestId = Guid.Empty;
return context.HttpContext.Request.Headers.TryGetValue("X-Request-Id", out StringValues idempotenceKeyValue) &&
Guid.TryParse(idempotenceKeyValue, out requestId);
}

TryGetRequestId request’in headerları içerisinde X-Request-Id değerini arar ve bu değeri Guid veri tipine parse etmeye çalışır. İlgili işlemlerden sonra true veya false değeri döndürür. Eğer false ise hata mesajı döndürülür ve endpoint çalıştırılmaz.

    /// <summary>
/// Generates a cache key based on the provided request ID.
/// </summary>
/// <param name="requestId">The unique identifier for the request.</param>
/// <returns>A string representing the cache key.</returns>
private static string GetCacheKey(Guid requestId) => $"X-Request-Id-{requestId}";

GetCacheKey gelen X-Request-Id bilgisine göre bir cache key oluşturur.

    /// <summary>
/// Retrieves a cached result asynchronously based on the provided cache key.
/// </summary>
/// <param name="cache">The hybrid cache instance used to retrieve the cached result.</param>
/// <param name="cacheKey">The key used to identify the cached result.</param>
/// <returns>
/// A task that represents the asynchronous operation. The task result contains the cached result as a string, or null if no result is found.
/// </returns>
private static async Task<string?> GetCachedResultAsync(HybridCache cache, string cacheKey)
{
return await cache.GetOrCreateAsync(cacheKey, _ => ValueTask.FromResult(null as string));
}

GetCachedResultAsync oluşturulan cacheKey değeri ile cache check yapar. Eğer daha önce çalıştırılan bir endpoint ise ilgili değeri döndürür.

    /// <summary>
/// Creates an ObjectResult from the cached result string.
/// </summary>
/// <param name="cachedResult">The cached result as a JSON string.</param>
/// <returns>An ObjectResult containing the deserialized response value and status code.</returns>
private static ObjectResult CreateCachedResult(string cachedResult)
{
var response = JsonSerializer.Deserialize<IdempotentResponse>(cachedResult)!;
return new ObjectResult(response.Value) { StatusCode = response.StatusCode };
}

CreateCachedResult eğer ilgili endpoint daha önce çalıştırıldıysa, çalıştırılan endpoint’in cevabını döndürür.

/// <summary>
/// Caches the response asynchronously based on the provided cache key and executed context.
/// </summary>
/// <param name="cache">The hybrid cache instance used to store the response.</param>
/// <param name="cacheKey">The key used to identify the cached response.</param>
/// <param name="executedContext">The context of the executed action containing the result to be cached.</param>
/// <returns>A task that represents the asynchronous caching operation.</returns>
private async Task CacheResponseAsync(HybridCache cache, string cacheKey, ActionExecutedContext executedContext)
{
if (executedContext.Result is ObjectResult { StatusCode: >= 200 and < 300 } objectResult)
{
var statusCode = objectResult.StatusCode ?? StatusCodes.Status200OK;
IdempotentResponse response = new(statusCode, objectResult.Value);
await cache.SetAsync(cacheKey, response, new HybridCacheEntryOptions
{
Expiration = _cacheDuration,
LocalCacheExpiration = _cacheDuration
});
}
}

CacheResponseAsync endpoint çalıştırıldıktan sonra çalışır ve endpoint’in döndüğü cevabı cacheler. Eğer status code 400 ve daha büyük bir status code ise bu endpoint’in hata verdiğini anlamına geleceği için ilgili yanıtı cachelemez.

Kodun tamamı aşağıdaki gibidir;

using System.Text.Json;
using IdempotentSharp.Core.Responses;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Primitives;

namespace IdempotentSharp.AspNetCore.Attributes;

/// <summary>
/// This Attribute provides idempotent endpoint functionality.
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public sealed class IdempotentAttribute(int cacheTimeInMinutes = IdempotentAttribute.DefaultCacheTimeInSeconds)
: Attribute, IAsyncActionFilter
{
private const int DefaultCacheTimeInSeconds = 60;
private readonly TimeSpan _cacheDuration = TimeSpan.FromSeconds(cacheTimeInMinutes);

/// <summary>
/// Executes the action filter asynchronously, providing idempotent endpoint functionality.
/// </summary>
/// <param name="context">The context of the action being executed.</param>
/// <param name="next">The delegate to execute the next action filter or action method.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
if (!TryGetRequestId(context, out Guid requestId))
{
context.Result = new BadRequestObjectResult("Invalid or missing X-Request-Id header");
return;
}

var cacheKey = GetCacheKey(requestId);
var cache = context.HttpContext.RequestServices.GetRequiredService<HybridCache>();
var cachedResult = await GetCachedResultAsync(cache, cacheKey);

if (cachedResult is not null)
{
context.Result = CreateCachedResult(cachedResult);
return;
}

var executedContext = await next();
await CacheResponseAsync(cache, cacheKey, executedContext);
}
/// <summary>
/// Tries to retrieve the request ID from the HTTP headers.
/// </summary>
/// <param name="context">The context of the action being executed.</param>
/// <param name="requestId">The output parameter that will contain the parsed request ID if successful.</param>
/// <returns>
/// True if the request ID was successfully retrieved and parsed; otherwise, false.
/// </returns>
private static bool TryGetRequestId(ActionExecutingContext context, out Guid requestId)
{
requestId = Guid.Empty;
return context.HttpContext.Request.Headers.TryGetValue("X-Request-Id", out StringValues idempotenceKeyValue) &&
Guid.TryParse(idempotenceKeyValue, out requestId);
}

/// <summary>
/// Generates a cache key based on the provided request ID.
/// </summary>
/// <param name="requestId">The unique identifier for the request.</param>
/// <returns>A string representing the cache key.</returns>
private static string GetCacheKey(Guid requestId) => $"X-Request-Id-{requestId}";

/// <summary>
/// Retrieves a cached result asynchronously based on the provided cache key.
/// </summary>
/// <param name="cache">The hybrid cache instance used to retrieve the cached result.</param>
/// <param name="cacheKey">The key used to identify the cached result.</param>
/// <returns>
/// A task that represents the asynchronous operation. The task result contains the cached result as a string, or null if no result is found.
/// </returns>
private static async Task<string?> GetCachedResultAsync(HybridCache cache, string cacheKey)
{
return await cache.GetOrCreateAsync(cacheKey, _ => ValueTask.FromResult(null as string));
}

/// <summary>
/// Creates an ObjectResult from the cached result string.
/// </summary>
/// <param name="cachedResult">The cached result as a JSON string.</param>
/// <returns>An ObjectResult containing the deserialized response value and status code.</returns>
private static ObjectResult CreateCachedResult(string cachedResult)
{
var response = JsonSerializer.Deserialize<IdempotentResponse>(cachedResult)!;
return new ObjectResult(response.Value) { StatusCode = response.StatusCode };
}

/// <summary>
/// Caches the response asynchronously based on the provided cache key and executed context.
/// </summary>
/// <param name="cache">The hybrid cache instance used to store the response.</param>
/// <param name="cacheKey">The key used to identify the cached response.</param>
/// <param name="executedContext">The context of the executed action containing the result to be cached.</param>
/// <returns>A task that represents the asynchronous caching operation.</returns>
private async Task CacheResponseAsync(HybridCache cache, string cacheKey, ActionExecutedContext executedContext)
{
if (executedContext.Result is ObjectResult { StatusCode: >= 200 and < 300 } objectResult)
{
var statusCode = objectResult.StatusCode ?? StatusCodes.Status200OK;
IdempotentResponse response = new(statusCode, objectResult.Value);
await cache.SetAsync(cacheKey, response, new HybridCacheEntryOptions
{
Expiration = _cacheDuration,
LocalCacheExpiration = _cacheDuration
});
}
}
}

Artık ilgili endpoint için bu özelliği kullanabiliriz.

using IdempotentSharp.AspNetCore.Attributes;
using Microsoft.AspNetCore.Mvc;

namespace IdempotentSharp.Sample.AspNetCore.Controllers;

[ApiController]
[Route("[controller]")]
public class HomeController : ControllerBase
{
private readonly List<string> Response = ["value1", "value2", "value3"];
[HttpGet]
[Idempotent] // <- 🔨 Idempotent Attribute
public async Task<IActionResult> GetAsync()
{
return Ok(Response);
}
}

Şimdi aynı işlemi Minimal Api ile geliştirilen bir endpoint için tasarlayalım.

using System.Text.Json;
using IdempotentSharp.Core.Responses;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.DependencyInjection;

namespace IdempotentSharp.AspNetCore.MinimalApi.Filters;

/// <summary>
/// Provides idempotent functionality as an endpoint filter for Minimal API endpoints.
/// </summary>
public sealed class IdempotentFilter(int cacheTimeInMinutes = IdempotentFilter.DefaultCacheTimeInSeconds)
: IEndpointFilter
{
private const int DefaultCacheTimeInSeconds = 60;
private readonly TimeSpan _cacheDuration = TimeSpan.FromSeconds(cacheTimeInMinutes);

/// <summary>
/// Invokes the endpoint filter asynchronously, providing idempotent functionality.
/// </summary>
/// <param name="context">The context of the endpoint invocation.</param>
/// <param name="next">The delegate to execute the next filter or endpoint.</param>
/// <returns>A task that represents the asynchronous operation, containing the result of the endpoint invocation.</returns>
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
if (!TryGetRequestId(context.HttpContext, out Guid requestId))
{
return Results.BadRequest("Invalid or missing X-Request-Id header");
}

var cacheKey = GetCacheKey(requestId);
var cache = context.HttpContext.RequestServices.GetRequiredService<HybridCache>();
var cachedResult = await GetCachedResultAsync(cache, cacheKey);

if (cachedResult is not null)
{
return CreateCachedResult(cachedResult);
}

var result = await next(context);
await CacheResponseAsync(cache, cacheKey, result);

return result;
}

/// <summary>
/// Attempts to retrieve the request ID from the HTTP context headers.
/// </summary>
/// <param name="httpContext">The HTTP context containing the request headers.</param>
/// <param name="requestId">When this method returns, contains the parsed request ID if the header is present and valid; otherwise, Guid.Empty.</param>
/// <returns><c>true</c> if the request ID header is present and valid; otherwise, <c>false</c>.</returns>
private static bool TryGetRequestId(HttpContext httpContext, out Guid requestId)
{
requestId = Guid.Empty;
return httpContext.Request.Headers.TryGetValue("X-Request-Id", out var idempotenceKeyValue) &&
Guid.TryParse(idempotenceKeyValue, out requestId);
}

/// <summary>
/// Generates a cache key using the provided request ID.
/// </summary>
/// <param name="requestId">The unique identifier for the request.</param>
/// <returns>A string representing the cache key.</returns>
private static string GetCacheKey(Guid requestId) => $"X-Request-Id-{requestId}";

/// <summary>
/// Retrieves the cached result asynchronously using the provided cache key.
/// </summary>
/// <param name="cache">The hybrid cache instance to use for retrieving the cached result.</param>
/// <param name="cacheKey">The key used to identify the cached result.</param>
/// <returns>A task that represents the asynchronous operation, containing the cached result as a string, or null if no cached result exists.</returns>
private static async Task<string?> GetCachedResultAsync(HybridCache cache, string cacheKey)
{
return await cache.GetOrCreateAsync(cacheKey, _ => ValueTask.FromResult(null as string));
}

/// <summary>
/// Creates an HTTP result from the cached result string.
/// </summary>
/// <param name="cachedResult">The cached result as a JSON string.</param>
/// <returns>An <see cref="IResult"/> representing the deserialized cached response.</returns>
private static IResult CreateCachedResult(string cachedResult)
{
var response = JsonSerializer.Deserialize<IdempotentResponse>(cachedResult)!;
return Results.Json(response.Value, statusCode: response.StatusCode);
}

/// <summary>
/// Caches the response asynchronously if the result is a successful HTTP status code.
/// </summary>
/// <param name="cache">The hybrid cache instance to use for caching the response.</param>
/// <param name="cacheKey">The key used to identify the cached response.</param>
/// <param name="result">The result of the endpoint invocation to be cached.</param>
/// <returns>A task that represents the asynchronous caching operation.</returns>
private async Task CacheResponseAsync(HybridCache cache, string cacheKey, object? result)
{
if (result is IStatusCodeHttpResult { StatusCode: >= 200 and < 300 } statusCodeResult
and IValueHttpResult valueResult)
{
var statusCode = statusCodeResult.StatusCode ?? StatusCodes.Status200OK;
IdempotentResponse response = new(statusCode, valueResult.Value);

await cache.SetAsync(
cacheKey,
response,
new HybridCacheEntryOptions
{
Expiration = _cacheDuration,
LocalCacheExpiration = _cacheDuration
}
);
}
}
}
var summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};

app.MapGet("/weatherforecast", () =>
{
var forecast = Enumerable.Range(1, 5).Select(index =>
new WeatherForecast
(
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
Random.Shared.Next(-20, 55),
summaries[Random.Shared.Next(summaries.Length)]
))
.ToArray();
return Results.Ok(forecast);
})
.WithName("GetWeatherForecast")
.WithOpenApi()
.AddEndpointFilter<IdempotentFilter>();

Yukarıda kullanılan yapılar ile ilgili endpointlerimize Idempotency özelliğini kazandırabiliriz ancak dikkat etmemiz gereken bazı durumlar da vardır.

  • Cache süresi dikkatlice seçilmelidir.
  • Concurrency bu senaryolarda can sıkıcı olabilir. Distributed Lock gibi çözümler kullanabilirsiniz.

Burada anlatılan tüm senaryolar için bir NuGet paketi oluşturdum. Bu kadar kod yazmak istemezseniz kullanabilirsiniz. 🙂

--

--

Furkan Güngör
Furkan Güngör

Written by Furkan Güngör

Solution Developer — I want to change the world, give me the source code.

No responses yet