Discourse AI: um evento de erro do Gemini em uma resposta em streaming é ignorado e o texto parcial é salvo como uma tradução completa

Descrição

Quando a API do Gemini, da Google, falha no meio de uma resposta em streaming, o Discourse AI trata a resposta como concluída. Com a tradução por IA ativada, o fragmento recebido antes da falha é armazenado como a tradução finalizada da publicação ou do título do tópico. Nenhum erro é registrado e o backfill nunca tenta novamente o item, pois uma linha de tradução agora existe.

Observado em um site auto-hospedado executando release/2026.7 (2026.7.3, commit f1caa6321287f918644fba9ff8943579fdcf027a) com o plugin discourse-ai incluído. O LLM é gemini-3.8-flash através do provedor Google, com o nível de serviço definido como flex. Flex é o nível em que a Google reduz a carga, portanto, produz regularmente essas falhas no meio do streaming; o tratamento descrito abaixo não depende do nível.

Nesses casos, a Google responde com HTTP 200, transmite um ou mais eventos normais e, em seguida, envia um evento de erro no mesmo stream:

data: {"candidates": [ ... ],"usageMetadata": { ... },"serviceTier": "flex","modelVersion": "gemini-3.8-flash","responseId": "..."}

data: {"error": {"code": 503,"message": "This model is currently experiencing high demand. Spikes in demand are usually temporary. Please try again later.","status": "UNAVAILABLE"}}

O log de auditoria registra response_status 200 e uma pequena contagem de response_tokens. Exemplos do que foi armazenado: uma publicação de 917 caracteres salva como 26 caracteres (apenas sua linha de saudação), publicações contendo apenas links salvas como os primeiros 8 a 24 caracteres da URL e títulos de tópicos cortados no meio da palavra.

Medido neste site: 23 de 394 chamadas de tradução respondidas no nível Flex (5,8%) terminaram dessa forma. Nenhuma das 952 chamadas no nível padrão para o mesmo modelo apresentou esse comportamento.

Causa raiz

  1. DiscourseAi::Completions::Endpoints::Gemini#decode_chunk lê apenas candidates de cada evento de stream analisado. Um evento cuja chave de nível superior é error não gera partes e é ignorado sem nenhuma verificação.
  2. DiscourseAi::Completions::Endpoints::Base decide entre sucesso, nova tentativa e falha com base apenas no status HTTP (response.code.to_i != 200). O status é 200 aqui, portanto, a lógica existente de nova tentativa para 503 nunca é acionada.
  3. O stream então termina normalmente e o chamador recebe o texto acumulado até o momento. DiscourseAi::Translation::PostLocalizer e TopicLocalizer o salvam.

O mesmo código está presente no main desde 2026-10-03.

Correção sugerida

Tratar um objeto error de nível superior em um evento de streaming do Gemini como uma conclusão falha: descartar a saída parcial e lançar CompletionFailed, para que o tratamento existente de novas tentativas se aplique a códigos repetíveis, como 503, e os chamadores nunca recebam um fragmento como uma resposta completa.

Reproduzir

A falha depende da carga da Google, portanto, não pode ser forçada. Em um site que usa um modelo Gemini no nível Flex com tradução por IA, as chamadas afetadas podem ser encontradas posteriormente no log de auditoria:

SELECT id, created_at, post_id, topic_id, response_status, response_tokens
FROM ai_api_audit_logs
WHERE feature_name = 'translation'
  AND raw_response_payload LIKE '%data: {"error"%'
  AND response_tokens > 0
ORDER BY created_at

Cada linha é uma chamada que retornou HTTP 200, produziu alguma saída e, em seguida, continha um evento de erro. As linhas correspondentes em post_localizations ou topic_localizations contêm o texto truncado.

Solução temporária

Mover os agentes de tradução para o nível de serviço padrão impediu novas ocorrências. Os fragmentos armazenados tiveram que ser encontrados com a consulta acima, excluídos e traduzidos novamente.

1 curtida