feat(telegram): retry transient speech-to-text failures up to 3 attempts

This commit is contained in:
EugeneTes
2026-10-05 07:02:23 +00:00
parent 094d518827
commit 64ea6f7fa1
5 changed files with 117 additions and 9 deletions

View File

@@ -31,7 +31,7 @@ public sealed class OpenAiSpeechToTextTests
private static (OpenAiSpeechToText Stt, StubHandler Handler) Create(HttpStatusCode status, string body, string? language = null)
{
var handler = new StubHandler(status, body);
var options = new SpeechOptions { ApiKey = Key, Language = language, BaseUrl = "https://stt.example/v1/" };
var options = new SpeechOptions { ApiKey = Key, Language = language, BaseUrl = "https://stt.example/v1/", RetryDelay = TimeSpan.Zero };
return (new OpenAiSpeechToText(new HttpClient(handler), options), handler);
}
@@ -106,7 +106,7 @@ public sealed class OpenAiSpeechToTextTests
public async Task A_client_timeout_is_a_speech_failure_not_a_cancellation()
{
var http = new HttpClient(new HangingHandler()) { Timeout = TimeSpan.FromMilliseconds(50) };
var stt = new OpenAiSpeechToText(http, new SpeechOptions { ApiKey = Key });
var stt = new OpenAiSpeechToText(http, new SpeechOptions { ApiKey = Key, RetryDelay = TimeSpan.Zero });
var ex = await Assert.ThrowsAsync<SpeechToTextException>(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None));
Assert.Equal("OpenAI transcription failed: the request timed out.", ex.Message);
}
@@ -114,8 +114,84 @@ public sealed class OpenAiSpeechToTextTests
[Fact]
public async Task The_callers_cancellation_still_cancels()
{
var stt = new OpenAiSpeechToText(new HttpClient(new HangingHandler()), new SpeechOptions { ApiKey = Key });
var stt = new OpenAiSpeechToText(new HttpClient(new HangingHandler()), new SpeechOptions { ApiKey = Key, RetryDelay = TimeSpan.Zero });
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", cts.Token));
}
/// <summary>Answers each attempt from the script in turn: a status with a body, or an exception to throw.</summary>
private sealed class ScriptedHandler(params object[] script) : HttpMessageHandler
{
public int Calls { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var step = script[Math.Min(Calls++, script.Length - 1)];
if (step is Exception ex) throw ex;
var (status, body) = ((HttpStatusCode, string))step;
return Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") });
}
}
private static OpenAiSpeechToText Create(ScriptedHandler handler) =>
new(new HttpClient(handler), new SpeechOptions { ApiKey = Key, RetryDelay = TimeSpan.Zero });
[Theory]
[InlineData(HttpStatusCode.InternalServerError)]
[InlineData(HttpStatusCode.BadGateway)]
[InlineData(HttpStatusCode.ServiceUnavailable)]
[InlineData(HttpStatusCode.TooManyRequests)]
[InlineData(HttpStatusCode.RequestTimeout)]
public async Task A_transient_status_is_retried_until_the_third_attempt_succeeds(HttpStatusCode status)
{
var handler = new ScriptedHandler((status, "busy"), (status, "busy"), (HttpStatusCode.OK, "{\"text\":\"hello\"}"));
Assert.Equal("hello", await Create(handler).TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None));
Assert.Equal(3, handler.Calls);
}
[Fact]
public async Task Three_transient_failures_give_up_with_the_last_error()
{
var handler = new ScriptedHandler((HttpStatusCode.ServiceUnavailable, "down"));
var ex = await Assert.ThrowsAsync<SpeechToTextException>(() => Create(handler).TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None));
Assert.Equal("OpenAI transcription failed: HTTP 503 down", ex.Message);
Assert.Equal(3, handler.Calls);
}
[Fact]
public async Task A_network_error_is_retried()
{
var handler = new ScriptedHandler(new HttpRequestException("connection reset"), (HttpStatusCode.OK, "{\"text\":\"hi\"}"));
Assert.Equal("hi", await Create(handler).TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None));
Assert.Equal(2, handler.Calls);
}
[Theory]
[InlineData(HttpStatusCode.BadRequest)]
[InlineData(HttpStatusCode.Unauthorized)]
[InlineData(HttpStatusCode.RequestEntityTooLarge)]
public async Task A_client_error_is_not_retried(HttpStatusCode status)
{
var handler = new ScriptedHandler((status, "nope"), (HttpStatusCode.OK, "{\"text\":\"hi\"}"));
await Assert.ThrowsAsync<SpeechToTextException>(() => Create(handler).TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None));
Assert.Equal(1, handler.Calls);
}
[Fact]
public async Task A_bad_body_is_not_retried()
{
var handler = new ScriptedHandler((HttpStatusCode.OK, "<html>gateway</html>"), (HttpStatusCode.OK, "{\"text\":\"hi\"}"));
await Assert.ThrowsAsync<SpeechToTextException>(() => Create(handler).TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", CancellationToken.None));
Assert.Equal(1, handler.Calls);
}
[Fact]
public async Task The_callers_cancellation_stops_the_wait_between_attempts()
{
var handler = new ScriptedHandler((HttpStatusCode.ServiceUnavailable, "down"));
var stt = new OpenAiSpeechToText(new HttpClient(handler), new SpeechOptions { ApiKey = Key, RetryDelay = TimeSpan.FromMinutes(5) });
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", cts.Token));
Assert.Equal(1, handler.Calls);
}
}