Головна / Статті

Всередині сервісу сповіщень з кількома постачальниками: черги, альтернативні рішення, довіра

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

Видавець: REACTAPP.TOP12 хв читання2659 слів

Джерела: Building NotifyKit: The Engineering Behind Reliable Notifications

Надсилання електронного листа чи SMS здається справою, яка вимагає лише одного кроку: звернутися до API постачальника та продовжити. Однак у реальних умовах цей єдиний виклик функції send() тихо перетворюється на проблему розподілених систем, адже постачальники можуть встановлювати таймаути, обмежувати частоту запитів, виходити з ладу, доставляти повідомлення не в правильному порядку, а спроби повторної доставки можуть призводити до появи дублікатів. У цій статті детально розглядається архітектура сервісу сповіщень з відкритим кодом, призначеного для кількох користувачів, створеного на основі 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, підлягають повторній спробі з інтервалами відстрочки.

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

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

Архітектура черги

Надсилання сповіщення всередині запиту API, який його ініціював, пов’язує час виконання запиту від користувача з найповільнішим постачальником. Повільний або недоступний постачальник буде утримувати HTTP-запит клієнта у стані очікування, доки він нарешті не успішно не виконається або не закінчиться тайм-аутом. Сервіс уникає цього, розділяючи процес прийняття запиту та його виконання.

Розділення запиту та його виконання

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

  • notifications-email — для електронних листів клієнтам
  • notifications-webhook — для webhook-запитів клієнтів
  • platform-email — для внутрішніх сповіщень самого сервісу
  • notifications-failed — як черга для повідомлень про невдачу завдань, які вичерпали кількість спроб

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

Кількість спроб враховує всі проходження, а не окремих постачальників

Кожна робота має три спроби з експоненційним затримуванням, і важливою деталлю є те, що означає «спроба». Це не одна спроба на кожного постачальника. Одна спроба — це повний проходження постачальників клієнта у порядку пріоритету з зупинкою при першій успішній спробі. Якщо всі постачальники під час одного проходження зазнають невдачі, робота використала одну спробу, і наступна спроба знову починається з постачальника найвищого пріоритету. Лише після трьох невдалих проходжень робота разом із помилкою копіюється до черги для некоректних повідомлень, а оригінал позначається як FAILED.

База даних є джерелом істини навіть тоді, коли сама черга не працює. Замовлення клієнта Job або запис PlatformEmailLog для внутрішньої пошти записується з статусом PENDING ще до того, як він потрапить у чергу. Якщо до Redis неможливо дістатися або під час додавання завдання виникає помилка, вона фіксується, про невдачу повідомляють, а запис у базі даних оновлюється на FAILED із поясненням причини. Жоден запит не зникає непомітно між API та чергою.

Для чого існує черга для невдалих повідомлень

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

  • Рядок бази даних призначений для клієнта та підтримується через GET /jobs/:id.
  • Елемент DLQ призначений для оператора – це одноразова копія, створена для моніторингу та подальших дій.

DLQ має цінність лише тоді, коли за ним стежать. Кожна заявка, що надходить, викликає сповіщення у Slack, використовуючи той самий механізм сповіщень, що вже створений для проблем із електронною поштою платформи. Це перетворює чергу на сигнал раннього попередження: раптовий приплив заявок у категорії „dead-letter“ протягом короткого періоду зазвичай вказує на якусь системну проблему, наприклад перерву у роботі постачальника чи проблемну процедуру розгортання.

Ніщо не виходить з DLQ автоматично. Заявку, яка потрапила до категорії „dead-letter“, намагаються виконати знову лише тоді, коли клієнт викликає 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 електронних листів та вебхуків разом на місяць. У платних планах обмеження стосуються лише вебхуків; кількість електронних листів для них не обмежена.

    Що ще бракує

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

    Перевірка webhooks у обох напрямках

    Кожен інтегрований постачальник надсилає події назад через вебхуки: підтвердження доставки, повідомлення про відхилення листа, звіти про спам та зміни статусу оплати. Кожен кінцевий пункт прийому є публічною 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, що пов’язує тіло повідомлення з конкретною миттю. Приймач перераховує підпис та також відхиляє занадто старі значення часу, що запобігає повторній обробці перехопленого запиту пізніше.

    Клієнти мають повний контроль над своїм секретом: вони можуть його створити, перевірити його статус, змінити або видалити. Без налаштованого секрета webhooks все одно надсилаються, просто без підпису. Це сприяє зворотній сумісності, але також означає, що отримувачі повинні активувати верифікацію для захисту. Для додаткової інформації з боку отримувача дивіться Розуміння ключів ідемпотентності у кінцевих точках POST Node.js.

    Мінімальний 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 із часовою міткою, щоб запобігти їх повторній передачі.
  • Вкажіть свої відомі обмеження, наприклад щодо порядку обробки, та відкладіть дорогі рішення до моменту, коли дані покажуть, який компроміс є доцільним.