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

@@ -133,7 +133,9 @@ question in that chat (for example from an extension) replaces the pending promp
aren't really one of those formats, reaches it as a path only.
- **Voice messages and audio files** are transcribed with OpenAI (`gpt-4o-transcribe` by default). The bot echoes
`🎤 <transcript>` (cut to 1024 characters) and the agent gets `[Voice message] <transcript>` (an audio file also gets its `[File received: …]`
line). Without an API key the bot replies with how to set one up (`OPENAI_API_KEY`).
line). Without an API key the bot replies with how to set one up (`OPENAI_API_KEY`). A transcription is tried up
to 3 times (1 s, then 2 s apart) when the request fails on the network, times out, or gets HTTP 408, 429 or 5xx;
other errors (a bad key, a rejected file) fail at once.
- Telegram lets bots download at most 20 MB. A bigger file is refused with a note, and nothing reaches the agent.
- A message with an attachment is never a command and never answers a pending question: its caption is prompt text.
- Anything else (stickers, video notes, locations, polls) gets `I can read text, photos, files and voice messages.`

View File

@@ -8,4 +8,8 @@ public interface ISpeechToText
}
/// <summary>A transcription failed; the message is fit for the user and never contains the API key.</summary>
public sealed class SpeechToTextException(string message, Exception? inner = null) : Exception(message, inner);
public sealed class SpeechToTextException(string message, Exception? inner = null) : Exception(message, inner)
{
/// <summary>Another attempt may succeed (network error, timeout, 408, 429, 5xx).</summary>
internal bool IsTransient { get; init; }
}

View File

@@ -10,6 +10,22 @@ public sealed class OpenAiSpeechToText(HttpClient http, SpeechOptions options) :
private const int MaxExcerpt = 200;
public async Task<string> TranscribeAsync(ReadOnlyMemory<byte> audio, string fileName, string mimeType, CancellationToken cancellationToken)
{
for (var attempt = 1; ; attempt++)
{
try
{
return await TranscribeOnceAsync(audio, fileName, mimeType, cancellationToken).ConfigureAwait(false);
}
catch (SpeechToTextException ex) when (ex.IsTransient && attempt < SpeechOptions.MaxAttempts)
{
await Task.Delay(options.RetryDelay * (1 << (attempt - 1)), cancellationToken).ConfigureAwait(false);
}
}
}
// A request message can be sent only once, so every attempt builds its own.
private async Task<string> TranscribeOnceAsync(ReadOnlyMemory<byte> audio, string fileName, string mimeType, CancellationToken cancellationToken)
{
using var form = new MultipartFormDataContent();
var file = new ReadOnlyMemoryContent(audio);
@@ -29,11 +45,11 @@ public sealed class OpenAiSpeechToText(HttpClient http, SpeechOptions options) :
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) // HttpClient.Timeout
{
throw new SpeechToTextException(Prefix + "the request timed out.", ex);
throw new SpeechToTextException(Prefix + "the request timed out.", ex) { IsTransient = true };
}
catch (HttpRequestException ex)
{
throw new SpeechToTextException(Prefix + Hide(ex.Message), ex);
throw new SpeechToTextException(Prefix + Hide(ex.Message), ex) { IsTransient = true };
}
using var _ = response;
try
@@ -42,13 +58,17 @@ public sealed class OpenAiSpeechToText(HttpClient http, SpeechOptions options) :
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
throw new SpeechToTextException(Prefix + "the request timed out.", ex);
throw new SpeechToTextException(Prefix + "the request timed out.", ex) { IsTransient = true };
}
if (!response.IsSuccessStatusCode)
{
var hidden = Hide(body); // hide the key before cutting, so half a key can't leak
var excerpt = hidden.Length > MaxExcerpt ? hidden[..MaxExcerpt] : hidden;
throw new SpeechToTextException($"{Prefix}HTTP {(int)response.StatusCode} {excerpt}".TrimEnd());
var status = (int)response.StatusCode;
throw new SpeechToTextException($"{Prefix}HTTP {status} {excerpt}".TrimEnd())
{
IsTransient = status is 408 or 429 or >= 500,
};
}
try

View File

@@ -18,6 +18,12 @@ public sealed class SpeechOptions
/// <summary>Optional ISO-639-1 hint.</summary>
public string? Language { get; set; }
/// <summary>Tries per transcription; network errors, timeouts, 408, 429 and 5xx are retried.</summary>
public const int MaxAttempts = 3;
/// <summary>The wait before the second attempt, doubled before the third. Internal so tests can zero it.</summary>
internal TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(1);
public bool IsEnabled => !string.IsNullOrWhiteSpace(ApiKey);
public string ResolvedModel => string.IsNullOrWhiteSpace(Model) ? DefaultModel : Model.Trim();

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);
}
}