bridge: MessageID=хэш(дедуп), классификация permanent/transient SQS-ошибок, бесконечный ретрай подписки, валидация ns/deviceID

This commit is contained in:
Repinoid
2026-10-11 08:46:22 +03:00
parent 89b5660a8b
commit 93d47376b1
3 changed files with 60 additions and 13 deletions
+12 -11
View File
@@ -104,28 +104,29 @@ func connectMQTT(ctx context.Context, cfg *config.Config, log *slog.Logger, hand
}) })
opts.SetOnConnectHandler(func(c mqtt.Client) { opts.SetOnConnectHandler(func(c mqtt.Client) {
log.Info("bridge: MQTT connected") log.Info("bridge: MQTT connected")
// Подписка обязательна. Ретраим ограниченно. НЕ вызываем c.Disconnect(): // Подписка обязательна. Ретраим пока не удастся (или не оборвётся ctx/
// в paho это ЯВНОЕ отключение, после которого автореконнект НЕ срабатывает // соединение). НЕ вызываем c.Disconnect(): в paho это ЯВНОЕ отключение,
// — bridge оставался бы отключённым навсегда (ревью 2026-10-10, 🔴6). // после которого автореконнект НЕ срабатывает (ревью 2026-10-10, 🔴6).
// При сбое ждём следующего реконнекта (OnConnectHandler вызовется заново). // Раньше было 3 попытки и ожидание следующего реконнекта — но при живом
for attempt := 1; attempt <= 3; attempt++ { // соединении OnConnectHandler больше не вызовется, и bridge оставался бы
// без подписки навсегда (ревью 2026-10-11).
for {
if ctx.Err() != nil || !c.IsConnected() {
return
}
token := c.Subscribe(telemetryTopicFilter, 1, handler) token := c.Subscribe(telemetryTopicFilter, 1, handler)
if token.WaitTimeout(10*time.Second) && token.Error() == nil { if token.WaitTimeout(10*time.Second) && token.Error() == nil {
log.Info("bridge: subscribed", "filter", telemetryTopicFilter, "attempt", attempt) log.Info("bridge: subscribed", "filter", telemetryTopicFilter)
setSubscribed(onSubscribed, true) setSubscribed(onSubscribed, true)
return return
} }
log.Error("bridge: subscribe failed", "filter", telemetryTopicFilter, "attempt", attempt, "err", token.Error()) log.Error("bridge: subscribe failed, retrying", "filter", telemetryTopicFilter, "err", token.Error())
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-time.After(subscribeRetryInterval): case <-time.After(subscribeRetryInterval):
} }
} }
// Подписка не удалась — bridge не готов (/ready=503) до следующего
// реконнекта.
setSubscribed(onSubscribed, false)
log.Error("bridge: subscribe failed after retries, will retry on next reconnect", "filter", telemetryTopicFilter)
}) })
client := mqtt.NewClient(opts) client := mqtt.NewClient(opts)
+24 -2
View File
@@ -5,13 +5,15 @@
package bridge package bridge
import ( import (
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"log/slog" "log/slog"
"regexp"
"strings" "strings"
"time" "time"
mqtt "github.com/eclipse/paho.mqtt.golang" mqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/google/uuid"
) )
// TelemetryEnvelope — сообщение, перекладываемое в SQS (разбирается consumer). // TelemetryEnvelope — сообщение, перекладываемое в SQS (разбирается consumer).
@@ -42,6 +44,13 @@ func newMessageHandler(dispatcher *sqsDispatcher, log *slog.Logger) mqtt.Message
ns := parts[0] ns := parts[0]
deviceID := parts[2] deviceID := parts[2]
// Defense-in-depth: EMQX ACL — основной барьер, но при его ошибке/отключении
// бридж принял бы произвольный namespace. Дополнительно проверяем формат.
if !identRe.MatchString(ns) || !identRe.MatchString(deviceID) {
log.Warn("bridge: invalid namespace/device in topic, skipping", "topic", topic)
return
}
// Не-JSON payload оборачиваем в строку. // Не-JSON payload оборачиваем в строку.
rawPayload := json.RawMessage(payload) rawPayload := json.RawMessage(payload)
if !json.Valid(payload) { if !json.Valid(payload) {
@@ -55,7 +64,20 @@ func newMessageHandler(dispatcher *sqsDispatcher, log *slog.Logger) mqtt.Message
Topic: topic, Topic: topic,
Payload: rawPayload, Payload: rawPayload,
ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano), ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano),
MessageID: uuid.NewString(), MessageID: messageIDFor(ns, deviceID, topic, payload),
}) })
} }
} }
// identRe — безопасный идентификатор (namespace/device_id): буквы/цифры/дефис,
// не с дефиса, ≤64 символов. Defense-in-depth поверх EMQX ACL.
var identRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]{0,63}$`)
// messageIDFor — детерминированный id сообщения для дедупа.
// Повторная QoS1-доставка того же (device, topic, payload) даёт тот же id,
// поэтому ON CONFLICT (device_id, message_id) в БД дедуплицирует дубликаты
// (раньше uuid на каждый колбэк — дедуп на повторе не срабатывал).
func messageIDFor(ns, deviceID, topic string, payload []byte) string {
h := sha256.Sum256([]byte(ns + "|" + deviceID + "|" + topic + "|" + string(payload)))
return hex.EncodeToString(h[:])
}
+24
View File
@@ -12,6 +12,7 @@ package bridge
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"log/slog" "log/slog"
"sync" "sync"
"sync/atomic" "sync/atomic"
@@ -19,6 +20,7 @@ import (
"github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/sqs" "github.com/aws/aws-sdk-go-v2/service/sqs"
"github.com/aws/smithy-go"
) )
// Константы диспатчера. // Константы диспатчера.
@@ -176,6 +178,12 @@ func (d *sqsDispatcher) sendWithRetry(ctx context.Context, body string) error {
if err == nil { if err == nil {
return nil return nil
} }
// Permanent ошибки (неверный параметр/размер/нет очереди/доступ) — ретрай
// бессмыслен: вернуть сразу, иначе воркер залип бы на poison-сообщении и
// заполнил буфер (дроп остальных).
if isPermanentSQSErr(err) {
return err
}
if ctx.Err() != nil { if ctx.Err() != nil {
// shutdown: ctx отменён, ретраить бессмысленно. // shutdown: ctx отменён, ретраить бессмысленно.
return err return err
@@ -190,3 +198,19 @@ func (d *sqsDispatcher) sendWithRetry(ctx context.Context, body string) error {
} }
} }
} }
// isPermanentSQSErr — true для ошибок SQS, которые не пройдут ни при каком
// ретрае (валидация, размер, отсутствие очереди, доступ). Остальное (throttling,
// 5xx, сеть) — retryable.
func isPermanentSQSErr(err error) bool {
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
switch apiErr.ErrorCode() {
case "InvalidParameterValue", "InvalidMessageContents", "MessageTooBig",
"QueueDoesNotExist", "InvalidAddress", "InvalidSecurity",
"AccessDenied", "InvalidBatchEntryId":
return true
}
}
return false
}