[backend/masto-client] Handle idempotency key header (ISH-75)

This commit is contained in:
Laura Hausmann 2024-02-15 22:51:06 +01:00
parent bbf1afce5b
commit 07edffa6b5
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
3 changed files with 47 additions and 3 deletions

View file

@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
namespace Iceshrimp.Backend.Controllers.Mastodon; namespace Iceshrimp.Backend.Controllers.Mastodon;
@ -19,7 +20,12 @@ namespace Iceshrimp.Backend.Controllers.Mastodon;
[EnableCors("mastodon")] [EnableCors("mastodon")]
[EnableRateLimiting("sliding")] [EnableRateLimiting("sliding")]
[Produces("application/json")] [Produces("application/json")]
public class StatusController(DatabaseContext db, NoteRenderer noteRenderer, NoteService noteSvc) : Controller { public class StatusController(
DatabaseContext db,
NoteRenderer noteRenderer,
NoteService noteSvc,
IDistributedCache cache
) : Controller {
[HttpGet("{id}")] [HttpGet("{id}")]
[Authenticate("read:statuses")] [Authenticate("read:statuses")]
[Produces("application/json")] [Produces("application/json")]
@ -119,7 +125,25 @@ public class StatusController(DatabaseContext db, NoteRenderer noteRenderer, Not
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
//TODO: handle scheduled statuses //TODO: handle scheduled statuses
//TODO: handle Idempotency-Key Request.Headers.TryGetValue("Idempotency-Key", out var idempotencyKeyHeader);
var idempotencyKey = idempotencyKeyHeader.FirstOrDefault();
if (idempotencyKey != null) {
var hit = await cache.FetchAsync($"idempotency:{idempotencyKey}", TimeSpan.FromHours(24),
() => $"_:{HttpContext.TraceIdentifier}");
if (hit != $"_:{HttpContext.TraceIdentifier}") {
for (var i = 0; i <= 10; i++) {
if (!hit.StartsWith('_')) break;
await Task.Delay(100);
hit = await cache.GetAsync<string>($"idempotency:{idempotencyKey}")
?? throw new Exception("Idempotency key status disappeared in for loop");
if (i >= 10)
throw GracefulException.RequestTimeout("Failed to resolve idempotency key note within 1000 ms");
}
return await GetNote(hit);
}
}
if (request.Text == null && request.MediaIds is not { Count: > 0 } && request.Poll == null) if (request.Text == null && request.MediaIds is not { Count: > 0 } && request.Poll == null)
throw GracefulException.BadRequest("Posts must have text, media or poll"); throw GracefulException.BadRequest("Posts must have text, media or poll");
@ -139,6 +163,10 @@ public class StatusController(DatabaseContext db, NoteRenderer noteRenderer, Not
var note = await noteSvc.CreateNoteAsync(user, visibility, request.Text, request.Cw, reply, var note = await noteSvc.CreateNoteAsync(user, visibility, request.Text, request.Cw, reply,
attachments: attachments); attachments: attachments);
if (idempotencyKey != null)
await cache.SetAsync($"idempotency:{idempotencyKey}", note.Id, TimeSpan.FromHours(24));
var res = await noteRenderer.RenderAsync(note, user); var res = await noteRenderer.RenderAsync(note, user);
return Ok(res); return Ok(res);

View file

@ -49,6 +49,12 @@ public static class DistributedCacheExtensions {
return fetched; return fetched;
} }
public static async Task<T> FetchAsync<T>(
this IDistributedCache cache, string key, TimeSpan ttl, Func<T> fetcher
) where T : class {
return await FetchAsync(cache, key, ttl, () => Task.FromResult(fetcher()));
}
public static async Task<T> FetchAsyncValue<T>( public static async Task<T> FetchAsyncValue<T>(
this IDistributedCache cache, string key, TimeSpan ttl, Func<Task<T>> fetcher this IDistributedCache cache, string key, TimeSpan ttl, Func<Task<T>> fetcher
) where T : struct { ) where T : struct {
@ -60,6 +66,12 @@ public static class DistributedCacheExtensions {
return fetched; return fetched;
} }
public static async Task<T> FetchAsyncValue<T>(
this IDistributedCache cache, string key, TimeSpan ttl, Func<T> fetcher
) where T : struct {
return await FetchAsyncValue(cache, key, ttl, () => Task.FromResult(fetcher()));
}
public static async Task SetAsync<T>(this IDistributedCache cache, string key, T data, TimeSpan ttl) { public static async Task SetAsync<T>(this IDistributedCache cache, string key, T data, TimeSpan ttl) {
using var stream = new MemoryStream(); using var stream = new MemoryStream();
await JsonSerializer.SerializeAsync(stream, data); await JsonSerializer.SerializeAsync(stream, data);

View file

@ -126,6 +126,10 @@ public class GracefulException(
return new GracefulException(HttpStatusCode.BadRequest, message, details); return new GracefulException(HttpStatusCode.BadRequest, message, details);
} }
public static GracefulException RequestTimeout(string message, string? details = null) {
return new GracefulException(HttpStatusCode.RequestTimeout, message, details);
}
public static GracefulException RecordNotFound() { public static GracefulException RecordNotFound() {
return new GracefulException(HttpStatusCode.NotFound, "Record not found"); return new GracefulException(HttpStatusCode.NotFound, "Record not found");
} }