forked from admin/VolcanicAgenticCore
feat(telegram): retry transient speech-to-text failures up to 3 attempts
This commit is contained in:
@@ -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.
|
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
|
- **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: …]`
|
`🎤 <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.
|
- 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.
|
- 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.`
|
- Anything else (stickers, video notes, locations, polls) gets `I can read text, photos, files and voice messages.`
|
||||||
|
|||||||
@@ -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>
|
/// <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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,22 @@ public sealed class OpenAiSpeechToText(HttpClient http, SpeechOptions options) :
|
|||||||
private const int MaxExcerpt = 200;
|
private const int MaxExcerpt = 200;
|
||||||
|
|
||||||
public async Task<string> TranscribeAsync(ReadOnlyMemory<byte> audio, string fileName, string mimeType, CancellationToken cancellationToken)
|
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();
|
using var form = new MultipartFormDataContent();
|
||||||
var file = new ReadOnlyMemoryContent(audio);
|
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
|
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)
|
catch (HttpRequestException ex)
|
||||||
{
|
{
|
||||||
throw new SpeechToTextException(Prefix + Hide(ex.Message), ex);
|
throw new SpeechToTextException(Prefix + Hide(ex.Message), ex) { IsTransient = true };
|
||||||
}
|
}
|
||||||
using var _ = response;
|
using var _ = response;
|
||||||
try
|
try
|
||||||
@@ -42,13 +58,17 @@ public sealed class OpenAiSpeechToText(HttpClient http, SpeechOptions options) :
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
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)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var hidden = Hide(body); // hide the key before cutting, so half a key can't leak
|
var hidden = Hide(body); // hide the key before cutting, so half a key can't leak
|
||||||
var excerpt = hidden.Length > MaxExcerpt ? hidden[..MaxExcerpt] : hidden;
|
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
|
try
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ public sealed class SpeechOptions
|
|||||||
/// <summary>Optional ISO-639-1 hint.</summary>
|
/// <summary>Optional ISO-639-1 hint.</summary>
|
||||||
public string? Language { get; set; }
|
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 bool IsEnabled => !string.IsNullOrWhiteSpace(ApiKey);
|
||||||
|
|
||||||
public string ResolvedModel => string.IsNullOrWhiteSpace(Model) ? DefaultModel : Model.Trim();
|
public string ResolvedModel => string.IsNullOrWhiteSpace(Model) ? DefaultModel : Model.Trim();
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ public sealed class OpenAiSpeechToTextTests
|
|||||||
private static (OpenAiSpeechToText Stt, StubHandler Handler) Create(HttpStatusCode status, string body, string? language = null)
|
private static (OpenAiSpeechToText Stt, StubHandler Handler) Create(HttpStatusCode status, string body, string? language = null)
|
||||||
{
|
{
|
||||||
var handler = new StubHandler(status, body);
|
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);
|
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()
|
public async Task A_client_timeout_is_a_speech_failure_not_a_cancellation()
|
||||||
{
|
{
|
||||||
var http = new HttpClient(new HangingHandler()) { Timeout = TimeSpan.FromMilliseconds(50) };
|
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));
|
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);
|
Assert.Equal("OpenAI transcription failed: the request timed out.", ex.Message);
|
||||||
}
|
}
|
||||||
@@ -114,8 +114,84 @@ public sealed class OpenAiSpeechToTextTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task The_callers_cancellation_still_cancels()
|
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));
|
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
|
||||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => stt.TranscribeAsync(new byte[] { 1 }, "voice.ogg", "audio/ogg", cts.Token));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user