Отмена доходит до внешнего собеседника, а токен не покидает единой точки

- контекст проложен от воркера и обоих входов до внешних вызовов: ffmpeg и
  ffprobe заводятся через exec.CommandContext, SpeechKit и Object Storage
  принимают ctx вместо context.Background, скачивание записи идёт запросом с
  контекстом. Прежде остановка сервиса не доходила до чужой работы вовсе
- прерванный шаг приговора не выносит: убитый по контексту ffmpeg отдаёт
  «signal: killed», от настоящего отказа неотличимо ни типом, ни errors.Is, и
  различает их только ctx.Err(). Задача остаётся на повтор, попытку не тратит и
  отправителю о несуществующем сбое не сообщает; воркер не считает остановку
  отказом, а задача не забирается вовсе, если нас уже остановили
- клиента Bot API заводит единая точка internal/adapter/telegram: токен стоит в
  пути каждого обращения, а http.Client кладёт адрес в *url.Error целиком.
  Чистка на месте употребления закрывала один вызов из пяти — теперь свой Do
  чистит отказ, подменённый логгер вычищает токен из строк самой библиотеки, а
  транспорт бота токена не получает вовсе
- принятие операции распознавания защищено от отмены своим пределом: SpeechKit
  мог её принять и начать считать деньги, а потерянный идентификатор заставил
  бы повтор оплатить ту же запись второй раз
- приём по HTTP доводит запись до задачи независимо от отправителя: на
  контексте запроса один обрыв соединения терял полностью загруженную запись
- ответ Telegram с не-2xx кодом больше не становится записью: прежде тело
  отказа доезжало до хранилища и умирало на ffprobe, уводя диагностику
This commit is contained in:
av
2026-08-13 10:27:54 +03:00
parent d8d6bcc193
commit f494dcb83e
22 changed files with 855 additions and 137 deletions
+9 -1
View File
@@ -1,6 +1,7 @@
package http
import (
"context"
"log/slog"
"net/http"
"time"
@@ -71,7 +72,14 @@ func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
}
}()
job, err := h.trsService.CreateJobFromApi(file, header.Filename)
// Запись доехала целиком, поэтому задача заводится независимо от того,
// дождётся ли отправитель ответа: на контексте запроса приём терял бы
// полностью загруженную запись от одного обрыва соединения, а забрать
// результат он может и позже — по `GET /status/{id}`. Значения контекста
// (журнал запроса, сессия) при этом сохраняются, теряется только отмена.
ctx := context.WithoutCancel(e.Request.Context())
job, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename)
if err != nil {
// Второй раз отказ не логируем: приём назван конвенцией логирующей
// границей и уже написал о нём. Транспорт переводит ошибку в ответ.
+32 -2
View File
@@ -2,6 +2,7 @@ package http
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -39,7 +40,12 @@ type stubMetaViewer struct {
err error
}
func (m *stubMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
func (m *stubMetaViewer) GetInfo(ctx context.Context, _ string) (*contract.AudioInfo, error) {
// Настоящий `ffprobe` заведён с контекстом и по отмене умирает; стаб,
// который контекст игнорирует, сделал бы проверку приёма неспособной упасть.
if err := ctx.Err(); err != nil {
return nil, err
}
if m.err != nil {
return nil, m.err
}
@@ -50,7 +56,7 @@ func (m *stubMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
// этого файла её не зовёт.
type stubConverter struct{}
func (c *stubConverter) Convert(string, string) error { return nil }
func (c *stubConverter) Convert(context.Context, string, string) error { return nil }
// TestTgSender: приём по HTTP в Telegram не отвечает, но сервису отправитель нужен.
type TestTgSender struct{}
@@ -727,3 +733,27 @@ func TestGetTranscribeJobStatus_NotFound(t *testing.T) {
assert.Equal(t, "Job not found", response["error"])
}
// Отправитель, у которого соединение оборвалось после полной загрузки, задачу
// всё равно получает: запись доехала целиком, а результат он заберёт позже по
// `GET /status/{id}`. Приём на контексте запроса терял бы такую запись молча —
// решение владельца от 2026-08-13.
func TestAcceptedRecordSurvivesSenderDisconnect(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
req := createMultipartRequest(t, "sample.m4a", []byte("аудио"))
// Так выглядит ушедший отправитель: контекст запроса отменяется сервером,
// когда соединение закрылось.
ctx, cancel := context.WithCancel(req.Context())
cancel()
req = req.WithContext(ctx)
w := httptest.NewRecorder()
env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Result().StatusCode, "тело ответа: %s", w.Body.String())
jobs, err := env.app.FindAllRecords(migrations.JobsCollection)
require.NoError(t, err)
assert.Len(t, jobs, 1, "задача заведена, несмотря на ушедшего отправителя")
}