fix(server): 翻译失败后允许下次候选请求重试 (#1021)

* fix(server): 翻译失败后允许下次候选请求重试

* fix(server): 翻译失败项按次数退避,未发出的批次尾部不记失败

完全去掉失败缓存后,凭据错误、限流和确实没有译文的词会在每次候选刷新时被整批重发,
计费提供方会重复扣费;批次开头持续超时的词还会每次都耗尽预算,让尾部永远发不出去。

- 失败项按词退避:15 秒起步逐次翻倍,封顶 8 分钟,成功即清除
- 牛翻与自定义接口在批次预算耗尽时截短结果数组,共享层据此跳过未发出的尾部
- 退避时长抽成 CloudTranslation::FailureRetryDelay 并补单测

---------

Co-authored-by: fanlusky <1730976608@qq.com>
This commit is contained in:
古明四季andfanlusky authored and GitHub committed 2026-10-17 18:09:44 +08:00
1 parent 0bad4c1aaa
commit 96b64d20bd
8 files changed
+62 -23

No files matched your search

+24 -21
View File
@@ -20,7 +20,6 @@ namespace
{
// Wait until the candidate window has been idle for 500ms before a TMT request.
constexpr auto kIdleDelay = std::chrono::milliseconds(500);
constexpr auto kNegativeTtl = std::chrono::minutes(8);
constexpr size_t kMaxSourceChars = 40;
constexpr size_t kMaxCacheEntries = 4096;
@@ -36,7 +35,15 @@ std::string g_db_path;
CloudTranslation::ApplyCallback g_callback;
std::unordered_map<std::string, std::string> g_cache;
std::unordered_map<std::string, std::chrono::steady_clock::time_point> g_negative;
// 提供方对超时、限流、凭据错误和「没有译文」都只回空串,分不清哪次值得马上重试,所以失败项
// 按次数退避:网络恢复后很快能补上,持续失败的词也不会每次刷新候选都被重发、挤占批次预算。
struct FailureBackoff
{
unsigned failures = 0;
std::chrono::steady_clock::time_point retry_at;
};
std::unordered_map<std::string, FailureBackoff> g_failures;
std::string TargetLanguage(const TranslationConfig &config)
{
@@ -98,28 +105,22 @@ void RememberPositive(const std::string &identity, std::string gloss)
if (g_cache.size() >= kMaxCacheEntries)
g_cache.clear();
g_cache[identity] = std::move(gloss);
g_negative.erase(identity);
g_failures.erase(identity);
}
bool IsNegative(const std::string &identity)
bool IsBackingOff(const std::string &identity)
{
const auto found = g_negative.find(identity);
if (found == g_negative.end())
return false;
if (found->second <= std::chrono::steady_clock::now())
{
g_negative.erase(found);
return false;
}
return true;
const auto found = g_failures.find(identity);
return found != g_failures.end() && std::chrono::steady_clock::now() < found->second.retry_at;
}
void RememberNegative(const std::string &identity)
void RememberFailure(const std::string &identity)
{
std::lock_guard lock(g_mutex);
if (g_negative.size() >= kMaxCacheEntries)
g_negative.clear();
g_negative[identity] = std::chrono::steady_clock::now() + kNegativeTtl;
if (g_failures.size() >= kMaxCacheEntries && g_failures.find(identity) == g_failures.end())
g_failures.clear();
auto &entry = g_failures[identity];
entry.retry_at = std::chrono::steady_clock::now() + CloudTranslation::FailureRetryDelay(++entry.failures);
}
TencentTmt::Credentials ResolveCredentials(const TencentTmtConfig &config)
@@ -200,12 +201,14 @@ void ApplyTranslatedGroup(const std::vector<EnglishIme::TranslationQuery> &group
{
for (size_t i = 0; i < group.size(); ++i)
{
// 结果数组比请求短,说明提供方的批次预算先用完了,后面的词根本没有发出,不算失败。
if (i >= translated.size())
break;
const std::string identity = Identity(group[i], target_language, provider_scope);
const std::string raw = i < translated.size() ? translated[i] : std::string{};
const std::string gloss = CloudTranslation::FormatGloss(raw);
const std::string gloss = CloudTranslation::FormatGloss(translated[i]);
if (gloss.empty())
{
RememberNegative(identity);
RememberFailure(identity);
continue;
}
RememberPositive(identity, gloss);
@@ -301,7 +304,7 @@ void WorkerLoop()
results.push_back({query.key, query.direction, cached->second});
continue;
}
if (IsNegative(identity))
if (IsBackingOff(identity))
continue;
if (query.direction == EnglishIme::TranslationDirection::EnglishToChinese &&
CloudTranslation::IsCloudTranslatableEnglish(query.key))
+3
View File
@@ -136,7 +136,10 @@ std::vector<std::string> TextTranslateBatch(const Config &config, const std::vec
for (size_t i = 0; i < texts.size(); ++i)
{
if (std::chrono::steady_clock::now() - started >= kBatchBudget)
{
results.resize(i);
break;
}
const nlohmann::json body = {
{"text", texts[i]}, {"source_lang", UppercaseLanguage(source)}, {"target_lang", UppercaseLanguage(target)}};
const std::string payload = body.dump();
+2 -1
View File
@@ -15,7 +15,8 @@ bool IsSupportedEndpoint(const std::string &endpoint);
std::string ParseTranslationResponse(const std::string &response);
// Calls a DeepLX-compatible JSON endpoint once per source string. Empty
// entries mean that item failed.
// entries mean that item failed. When the batch time budget runs out the result
// is shorter than texts: the missing tail was never sent.
std::vector<std::string> TextTranslateBatch(const Config &config, const std::vector<std::string> &texts,
const std::string &source, const std::string &target);
} // namespace CustomTranslation
@@ -147,7 +147,10 @@ std::vector<std::string> TextTranslateBatch(const Config &config, const std::vec
for (size_t i = 0; i < texts.size(); ++i)
{
if (std::chrono::steady_clock::now() - started >= kBatchBudget)
{
results.resize(i);
break;
}
const std::string timestamp = MillisecondTimestamp();
// authStr is computed over the raw (unencoded) parameter values; the POST
// body sends the same values url-encoded.
+2 -1
View File
@@ -24,7 +24,8 @@ std::string BuildAuthString(const Config &config, const std::string &from, const
std::string ParseTranslationResponse(const std::string &response);
// Calls the NiuTrans /v2/text/translate endpoint once per source string. Empty
// entries mean that item failed.
// entries mean that item failed. When the batch time budget runs out the result
// is shorter than texts: the missing tail was never sent.
std::vector<std::string> TextTranslateBatch(const Config &config, const std::vector<std::string> &texts,
const std::string &source, const std::string &target);
} // namespace NiuTransTranslation
+13
View File
@@ -1,6 +1,7 @@
#include "translation_gloss.h"
#include <utf8.h>
#include <algorithm>
#include <cstdint>
#include <cctype>
@@ -9,6 +10,8 @@ namespace CloudTranslation
namespace
{
constexpr size_t kMaxPersistChars = 32;
constexpr auto kFirstRetryDelay = std::chrono::seconds(15);
constexpr auto kMaxRetryDelay = std::chrono::minutes(8);
bool IsAsciiSpace(unsigned char ch)
{
@@ -67,6 +70,16 @@ bool IsEmojiOrPictograph(uint32_t cp)
}
} // namespace
std::chrono::seconds FailureRetryDelay(unsigned failures)
{
if (failures == 0)
return std::chrono::seconds(0);
// 15s << 5 正好是 8 分钟,再往上移位只会被封顶,也避免大次数移位溢出。
const unsigned shift = (std::min)(failures - 1, 5u);
return (std::min)(std::chrono::seconds(kFirstRetryDelay * (1u << shift)),
std::chrono::duration_cast<std::chrono::seconds>(kMaxRetryDelay));
}
size_t Utf8Length(const std::string &text)
{
if (text.empty() || !utf8::is_valid(text.begin(), text.end()))
+3
View File
@@ -1,9 +1,12 @@
#pragma once
#include <chrono>
#include <string>
namespace CloudTranslation
{
// 同一词条连续失败 failures 次后,到下次允许重试前的等待:15 秒起步逐次翻倍,封顶 8 分钟。
std::chrono::seconds FailureRetryDelay(unsigned failures);
// Collapse whitespace for candidate-window gloss. Length is left to layout.
std::string FormatGloss(const std::string &text);
bool ShouldPersistGloss(const std::string &key, const std::string &formatted);
@@ -26,6 +26,18 @@ TEST_CASE(CloudTranslationPersistPolicyRejectsUselessGloss)
REQUIRE_EQ(CloudTranslation::TrimSecret(" AKIDabc \r\n"), std::string("AKIDabc"));
}
TEST_CASE(CloudTranslationFailureRetryDelayDoublesUpToEightMinutes)
{
using std::chrono::seconds;
REQUIRE(CloudTranslation::FailureRetryDelay(0) == seconds(0));
REQUIRE(CloudTranslation::FailureRetryDelay(1) == seconds(15));
REQUIRE(CloudTranslation::FailureRetryDelay(2) == seconds(30));
REQUIRE(CloudTranslation::FailureRetryDelay(5) == seconds(240));
REQUIRE(CloudTranslation::FailureRetryDelay(6) == seconds(480));
REQUIRE(CloudTranslation::FailureRetryDelay(7) == seconds(480));
REQUIRE(CloudTranslation::FailureRetryDelay(4000000000u) == seconds(480));
}
TEST_CASE(CloudTranslationOnlySendsChineseWithoutEmoji)
{
REQUIRE(CloudTranslation::IsCloudTranslatableChinese("水杉"));