Главная / Статьи

Внутри сервиса уведомлений с несколькими поставщиками: очереди, резервные варианты, доверие

Пошаговый обзор дизайна надежной службы уведомлений: хранение учетных данных, резервные варианты поставщиков, повторные попытки работы с BullMQ и использование DLQ, обеспечение справедливости для пользователей и проверка подписей webhook.

Издатель: REACTAPP.TOP12 мин чтения2659 слов

Источники: Building NotifyKit: The Engineering Behind Reliable Notifications

Отправка электронного письма или SMS кажется простой задачей: нужно лишь обратиться к API провайдера и продолжить. Однако в реальных условиях один-единственный вызов функции send() превращается в проблему распределенных систем, поскольку провайдеры могут выходить из строя, ограничивать частоту запросов, доставлять сообщения в неправильном порядке, а попытки повторной отправки могут приводить к появлению дубликатов. В этой статье подробно рассматривается архитектура open-source-сервиса уведомлений с поддержкой нескольких абонентов, построенного на Node.js, BullMQ и Redis; рассматриваются семь аспектов: хранение конфиденциальных данных, сбои провайдеров, проектирование очередей, обеспечение порядка доставки, надежность доставки, справедливое обращение с абонентами и проверка webhook-сообщений. Для каждого из этих аспектов приводятся принятые решения, обоснования и осознанные ограничения, что позволяет использовать эти принципы в собственных системах.

Два вида конфиденциальных данных и две стратегии хранения

Платформа уведомлений хранит несколько учетных данных, и для каждого из них существует простой вопрос: понадобится ли когда-нибудь системе восстановить исходное значение?

Ключи поставщика: шифруются, так как их необходимо восстановить

Клиенты сами предоставляют API-ключи своего поставщика электронной почты, и платформа должна передавать эти ключи поставщику при каждой отправке. Это исключает использование хеширования; значение должно быть восстанавливаемым. В процессе разработки сначала проверяется каждый ключ на соответствие его поставщику, затем он шифруется с помощью AES-256-GCM перед хранением. GCM — это режим с аутентификацией, поэтому помимо конфиденциальности он позволяет обнаруживать изменения: если зашифрованный текст был изменен, его невозможно расшифровать, вместо того чтобы получить правдоподобные, но неверные учетные данные.

Чистый текст никогда не попадает в базу данных, журналы или кэш. Он дешифруется ровно в двух местах: для отображения замаскированного превью на панели управления и в процессе отправки запроса непосредственно перед обращением к внешнему поставщику. Ограничение процесса дешифровки несколькими четко определенными вариантами кода значительно упрощает аудит на случай случайного утечки.

API-ключи, выдаваемые платформой: хэш, поскольку их нужно только проверять

API-ключи, которые платформа выдает своим клиентам, работают по противоположной логике. Системе никогда не требуется восстанавливать их, достаточно лишь подтвердить, что предоставленный ключ является подлинным. Поэтому ключ отображается один раз при создании, а сохраняется только его хэш SHA-256. При каждом запросе входящий ключ хэшируется и сравнивается со сохраненным результатом. Потерянный ключ невозможно восстановить, его необходимо сгенерировать заново.

Здесь подходит быстрый хеш вроде SHA-256, поскольку ключи представляют собой длинные случайные строки с высокой энтропией. Такой же выбор был бы неверным для паролей людей, которым требуется специально медленный алгоритм, такой как bcrypt, scrypt или Argon2, чтобы противостоять попыткам угадывания.

Секреты подписи webhook: что-то среднее

Секрет, используемый для подписи отправляемых webhook, генерируется платформой, как и API-ключ, но его необработанное значение требуется каждый раз при подписи webhook, как и ключ поставщика. Поэтому в состоянии покоя он шифруется с использованием AES-256-GCM, дешифруется непосредственно перед каждой операцией подписи и хранится только в памяти во время использования.

Работа с ненадежными поставщиками с упорядоченной альтернативой

Любая система, связанная с одним поставщиком, сталкивается с его проблемами в виде перебоев в работе, ограничений на объем отправок и тайм-аутов. Сервис снижает такую уязвимость за счет списка приоритетов для каждого клиента. Клиент может зарегистрировать несколько поставщиков, например SendGrid, Resend и Postmark, и определить для них порядок приоритетов. Если в запросе на отправку не указан поставщик, список кандидатов формируется на основе этого порядка приоритетов, начиная с самого высокоприоритетного.

Сервис проходит по списку кандидатов. Если какой-либо поставщик не справляется из-за простоя, ограничений или низкой скорости работы, ошибка фиксируется, и пробуется следующий поставщик. Первая успешная отправка прекращает процесс, и клиент даже не замечает использования резервного варианта, если только все поставщики не сработают.

Клиенты также могут назначить сообщение конкретному поставщику, опционально указав один резервный вариант; в этом случае пробуется только эта пара поставщиков. Не все ошибки рассматриваются одинаково:

  • Постоянные сбои, такие как отклонённые учётные данные или некорректный запрос, приводят к сбою немедленно, поскольку повторная попытка не может их устранить.
  • Временные сбои, такие как истечение времени ожидания, ограничения по частоте запросов и ответы провайдера с кодом 5xx, обрабатываются путём повторных попыток с задержками.

Когда сбои возникают со всем списком, ошибка доходит до очереди, где действует собственная политика повторных попыток. Механизм резервного варианта не устраняет сбои провайдера; он даёт каждому сообщению больше шансов на успешную передачу, прежде чем сбой станет заметен для клиента.

Один нюанс, который стоит учесть при планировании: истечение времени ожидания не доказывает, что провайдер отклонил сообщение. Провайдер может принять запрос, но не суметь ответить вовремя, и в таком случае повторная попытка может привести к двукратной доставке той же уведомлению. Обычно для решения этой проблемы используют передачу ключа идемпотентности, если провайдер его поддерживает, или допуск временных дубликатов для сообщений низкой важности. Наша статья об ключах идемпотентности для Node.js POST-эндпоинтов подробно рассматривает этот подход.

Архитектура очереди

Отправка уведомления внутри запроса API, который его инициировал, связывает время ожидания вызывающего пользователя с самым медленным поставщиком услуг. Медленный или недоступный поставщик будет задерживать HTTP-запрос клиента до тех пор, пока он не будет выполнен или не истечет время ожидания. Сервис избегает этой проблемы, разделяя процессы приема запросов и их обработки.

Разделение запроса и его обработки

API лишь проверяет запрос, создает запись Job в базе данных и добавляет задачу в очередь. BullMQ на Redis отвечает за планирование, причем существует отдельная очередь для каждого типа задач:

  • notifications-email — для электронных писем клиентам
  • notifications-webhook — для вебхуков клиентов
  • platform-email — для внутренних уведомлений самого сервиса
  • notifications-failed — в качестве очереди для задач, которые исчерпали количество попыток выполнения

Отдельные очереди позволяют, чтобы один тип задач накапливался или настраивался без влияния на остальные. Для более подробного рассмотрения проектирования задач в BullMQ см. создание надежных систем фоновых задач с использованием BullMQ и Redis.

Попытки повторения учитываются по всем проходам, а не по отдельным провайдерам

Каждая задача имеет три попытки с экспоненциальным откладыванием, и важно понимать, что означает «попытка». Речь не идет о попытке к каждому поставщику отдельно. Одна попытка — это полный обход поставщиков клиента в порядке приоритета с остановкой при первом успехе. Если все поставщики в рамках одного обхода не справляются, задача потребовала одной попытки, и следующая попытка начинается снова с поставщика с наивысшим приоритетом. Только после трех неудачных обходов задача вместе с ошибкой переносится в очередь для неработающих сообщений, а оригинальная задача отмечается как FAILED.

База данных является источником правды даже в случае сбоя самой очереди. Задание клиента Job или запись PlatformEmailLog для внутренней почты сохраняются с статусом PENDING до того, как они будут добавлены в очередь. Если к Redis невозможно подключиться или при добавлении задания возникает ошибка, она фиксируется, о сбое сообщается обратно, а запись в базе данных обновляется до статуса FAILED с указанием причины. Никакой запрос не исчезает незаметно между API и очередью.

Для чего предназначена очередь непрочитанных сообщений

Когда задание исчерпывает три попытки выполнения, копия его данных попадает в очередь notifications-failed, где хранятся все данные задания, ошибка и время события; при этом запись в базе данных также ставится в статус FAILED с той же причиной. Двойная фиксация сбоя сделана намеренно, поскольку каждая запись предназначена для разных аудиторий:

  • Строка базы данных предназначена для клиентов и обслуживается через GET /jobs/:id.
  • Запись в DLQ предназначена для операторов — это временная копия, предназначенная для мониторинга и принятия действий.

DLQ имеет ценность только тогда, когда за ней ведется наблюдение. Каждая поступающая задача запускает уведомление в Slack, используя уже существующий механизм уведомлений для случаев сбоев электронной почты платформы. Это превращает очередь в сигнал раннего предупреждения: всплеск задач с статусом «мертвая почта» в короткий промежуток времени обычно указывает на какую-то системную проблему, такую как сбой у поставщика или неудачная развертка.

Ничто не покидает DLQ автоматически. Попытка выполнения «мертвой» задачи происходит только тогда, когда клиент вызывает POST /jobs/:id/retry, что сбрасывает её статус в PENDING и возобновляет обработку.

Приоритет и одновременность выполнения

Задания имеют один из трех уровней приоритета: CRITICAL, NORMAL или LOW, которые соответствуют встроенной поддержке приоритетов в BullMQ. Трафик бесплатного тарифа всегда имеет приоритет LOW, чтобы он не конкурировал с трафиком за плату. Письма от платформы полностью игнорируют эту логику: они всегда добавляются в очередь с приоритетом CRITICAL и используют более короткий интервал повторных попыток.

Пулы рабочих процессов постоянно берут задания, причем каждый из них обрабатывает до пяти заданий одновременно, то есть в каждом пуле может быть обработано до пяти заданий за один раз.

Порядок обработки: целенаправленный разрыв

Сервис не гарантирует обработку заказов в определенном порядке для каждого клиента. Механизмы синхронизации и приоритезации настроены для максимальной пропускной способности, и при наличии пяти параллельных процессоров вместе с возможностью повторных попыток два сообщения, отправленные последовательно, могут быть доставлены в обратном порядке. Для большинства уведомлений это не имеет значения, поскольку они независимы друг от друга. Проблема возникает только в случаях причинно-следственных связей, когда порядок имеет значение, например, когда одно сообщение должно логически следовать за другим.

Существует два основных решения, каждое из которых имеет свою цену:

  • Сериализация по клиентам. Обрабатывать задания конкретного клиента через отдельный канал, тем самым гарантируя порядок, но ценой снижения уровня параллелизма для этого клиента.
  • Сделание сообщений устойчивыми к нарушению порядка. Добавление временных меток и предоставление потребителям возможности самим интерпретировать порядок доставки, что перекладывает ответственность на то, как клиенты воспринимают получаемые сообщения.

Это ограничение известно и было принято в пользу производительности. Текущее использование ещё не оправдывает затраты, поэтому решение откладывается до тех пор, пока реальные данные клиентов не покажут, какой вариант компромисса более подходит. Четкое документирование такой проблемы часто лучше, чем её частичное решение.

Возможность доставки: предотвращение сбоев до и после отправки

Электронные письма могут возвращаться обратно, попадать в спам или исчезать в зависимости от репутации отправителя и сигналов, накапливающихся со временем. Сервис решает эту проблему в двух аспектах.

До отправки: проверка домена

Когда клиент хочет отправлять сообщения с собственного домена, сервис регистрирует этот домен у выбранного клиентом поставщика и получает DNS-записи, подтверждающие право собственности. Эти записи различаются у разных поставщиков, поэтому их показывают клиенту для внесения в его DNS-хост. Отправка с этого домена остается заблокированной до прохождения проверки.

После отправки: действия в ответ на события от поставщика

Поставщики сообщают о доставке, открытии писем, кликах, возвратах и жалобах на спам через webhooks. Сервис хранит эти события и реагирует в зависимости от типа:

  • Жесткий возврат (адрес отсутствует или его доступ навсегда заблокирован) автоматически блокирует получателя, а последующие попытки отправки на этот адрес блокируются до того, как будет вызван любой поставщик.
  • Жалоба на спам также приводит к аналогичной блокировке.
  • , такое как полная почтовая корзина или временно недоступный сервер, фиксируется только потому, что проблема, скорее всего, временная.
  • Правило заключается в том, что к блокировке приводят только сигналы, указывающие на постоянную проблему. Это не решает проблему доставки полностью, поскольку репутация формируется и снижается под влиянием факторов, выходящих за рамки отдельного сообщения, но это предотвращает повторную отправку сообщений на адреса, известные по своей неисправности, что само по себе является одним из факторов, влияющих на репутацию.

    Предотвращение взаимного ущерба между пользователями

    Все клиенты используют одни и те же очереди, пулы рабочих процессов и экземпляр Redis; ничто не изолировано физически. Справедливость обеспечивается трьмя механизмами контроля, применяемыми на разных этапах.

    Первый барьер: лимиты скорости для каждого клиента на уровне API

    Прежде чем запрос превратится в задание, он проходит проверку лимита частоты запросов с использованием Redis для каждого клиента в течение 60 секунд; размер лимита определяется типом тарифа:

    • FREE: 5 запросов в минуту
    • INDIE: 50 запросов в минуту
    • STARTUP: 200 запросов в минуту

    Резкий скачок запросов от одного аккаунта отклоняется на этапе входа и не приводит к перегрузке очереди.

    Второй этап проверки: приоритет после одобрения

    Одобренные задания обрабатываются в порядке приоритета, причем задания бесплатного тарифа всегда имеют приоритет LOW. Рабочие процессы всегда сначала обрабатывают задания с более высоким приоритетом, поэтому даже если у бесплатного аккаунта накапливаются задания, уведомления платных клиентов обрабатываются раньше.

    Третий этап проверки: ежемесячные квоты

    В долгосрочной перспективе квоты ограничивают объем работы. Бесплатный тариф позволяет отправлять 100 сообщений по электронной почте и через webhook в месяц. У платных тарифов квоты действуют только для webhook, а отправка электронных писем не ограничена.

    Что еще отсутствует

    Не существует строгой изоляции ресурсов: нет отдельных процессов для каждого клиента. Два клиента с одинаковым уровнем приоритета делят общую пропускную способность, и в рамках одного уровня задания обрабатываются примерно по принципу «кто первый пришел, тот первым обслужен». При текущем объеме работы это не имело значения, поскольку нагрузка укладывается в возможности общей инфраструктуры, и ни один клиент заметно не замедлял работу другого. При увеличении масштабов потребуется изоляция ресурсов по отдельным клиентам или справедливая схема планирования (например, чередование обработки заданий среди клиентов внутри одного уровня приоритета).

    Проверка webhook-сообщений в обоих направлениях

    Каждый интегрированный поставщик отправляет события обратно через вебхуки: подтверждения доставки, сообщения о невозможности доставки, отчёты о спаме и изменения статуса оплаты. Каждый точка приёма — это публичный URL. Без проверки отправителя любой может опубликовать фальшивое событие «доставлено», подделать сообщение о невозможности доставки, чтобы заблокировать адрес кого-то другого, или, что наиболее опасно, сфабриковать событие оплаты, чтобы отметить подписку как оплаченную.

    Входящие сообщения: по одной схеме на поставщика

    Общего стандарта не существует, поэтому каждая интеграция требует отдельных мер безопасности:

    • SendGrid подписывает данные пакетов с помощью ECDSA, и перед тем как доверять этим данным, проверяется подпись с использованием публичного ключа SendGrid.
    • Resend передаёт вебхуки через Svix, который использует схему HMAC с временной меткой, защищающую от каких-либо изменений и повторной отправки данных.
  • Postmark не подписывает вебхуки, поэтому его конечная точка зависит от метода HTTP Basic Auth с секретом, уникальным для каждого клиента.
  • Paystack проверяет вебхуки платежей с помощью HMAC, использующего алгоритм SHA-512.
  • Polar верифицирует вебхуки платежей с использованием библиотеки standardwebhooks.
  • В случае с Postmark существует ловушка, которую легко упустить: секрет необходимо сравнивать в постоянном времени, например с помощью функции crypto.timingSafeEqual в Node, а не с помощью обычной проверки на равенство строк. Обычное сравнение может завершиться уже при наличии одного отличающегося символа, и эти различия во времени могут позволить злоумышленнику восстановить секрет по одному символу за раз, вместо того чтобы пытаться взломать всю строку целиком.

    Ни один из этих механизмов нельзя заменить другим, и каждая интеграция должна быть разработана и реализована с нуля. Общим для всех них является принцип: ничто, что поступает на конечную точку webhook, не обрабатывается до подтверждения его происхождения.

    Выходные сообщения: как заставить клиентов доверять платформе

    Аналогичная проблема возникает в обратном направлении, когда сервис отправляет webhook-сообщения на конечные точки клиентов. Если клиент настроил секрет для подписи, каждое сообщение содержит заголовки X-Webhook-Timestamp и X-Webhook-Signature. Подпись формируется с помощью алгоритма HMAC-SHA256 на основе строки timestamp.payload, что связывает содержимое сообщения с конкретным моментом времени. Приемник пересчитывает подпись и также отклоняет слишком старые временные метки, что предотвращает повторную отправку захваченных запросов позже.

    Клиенты полностью контролируют свой секрет: они могут его генерировать, проверять его статус, обновлять или удалять. Даже при отсутствии настроенного секрета вебхуки всё равно доставляются, просто без подписи. Это способствует обратной совместимости, но также означает, что получатели должны включить верификацию для защиты. Для дополнительной информации с точки зрения получателя см. Понимание ключей идемпотентности в концах POST Node.js.

    Минимальный fallback и проверка подписи

    Fallback и проверку проще удержать корректными в двух маленьких функциях, чем на схеме. deliver идёт по провайдерам по порядку и возвращает первого, кто принял сообщение. Повтор всей задачи — решение очереди, а не этого цикла.

    type Provider = {
      name: string;
      send: (to: string, body: string) => Promise<void>;
    };
    
    const deliver = async (providers: Provider[], to: string, body: string) => {
      const errors: string[] = [];
      for (const provider of providers) {
        try {
          await provider.send(to, body);
          return provider.name;
        } catch (err) {
          errors.push(`${provider.name}: ${(err as Error).message}`);
        }
      }
      throw new Error(`all providers failed\n${errors.join('\n')}`);
    };

    Исходящие вебхуки разворачивают ту же идею. Подпись покрывает timestamp.payload, а получатель отклоняет и неверный MAC, и метку старше пяти минут. timingSafeEqual сравнивает дайджесты, не раскрывая, какие байты совпали.

    import { createHmac, timingSafeEqual } from 'node:crypto';
    
    const sign = (secret: string, timestamp: string, payload: string) =>
      createHmac('sha256', secret).update(`${timestamp}.${payload}`).digest('hex');
    
    const verify = (
      secret: string,
      timestamp: string,
      payload: string,
      signature: string,
    ) => {
      const ageMs = Math.abs(Date.now() - Number(timestamp));
      if (!Number.isFinite(ageMs) || ageMs > 5 * 60 * 1000) return false;
      const expected = Buffer.from(sign(secret, timestamp, payload));
      const received = Buffer.from(signature);
      if (expected.length !== received.length) return false;
      return timingSafeEqual(expected, received);
    };

    Основные выводы

    • Решите, использовать ли шифрование или хеширование, определив, нужно ли когда-либо восстановить исходное значение.
    • Рассматривайте резервные варианты поставщика и повторные попытки обработки в очереди как отдельные уровни, и чётко определите, что означает одна «попытка».
    • Сохраняйте надёжную запись в базе данных перед добавлением элемента в очередь, чтобы сбои в работе очереди не приводили к потере запроса.
  • Очередь для некорректных сообщений остаётся полезной только тогда, когда по ней отправляются уведомления и существует чёткий путь для повторной попытки.
  • Подавляйте её действие только при постоянных сигналах, таких как отказы в доставке и жалобы на спам.
  • Внедряйте механизмы контроля справедливости (ограничения скорости, приоритеты, квоты) и чётко указывайте, каких мер изоляции вы пока не предоставляете.
  • Проверяйте каждый входящий webhook с использованием собственной схемы провайдера, применяйте сравнения за константное время и подписывайте исходящие webhooks с временной меткой, чтобы предотвратить повторную отправку.
  • Укажите известные ограничения, такие как порядок обработки сообщений, и откладывайте дорогостоящие решения до тех пор, пока данные не покажут наиболее подходящий компромисс.