Не очень короткая статья о стойкости.

Здесь, в SwissBorg, мы полагаемся на многих внешних провайдеров для таких действий, как оплата в фиате и криптовалюте, уведомления, проверка биометрии, обмены и многое другое. За прошедшие годы нам пришлось научиться интегрироваться с ними таким образом, чтобы наша система продолжала работать, когда возникают проблемы.

Как выглядит типичная интеграция

Хотя каждая интеграция уникальна, у них есть некоторые общие черты. К ним относятся следующие.

  • API провайдера: обычно на основе HTTP, который мы вызываем для связи с провайдером.
  • Вебхуки: Звонки от провайдера; обычно для доставки информации о событии в наш API.
  • Требования к идемпотентности: определенные операции, которые должны выполняться только один раз, даже при наличии повторных попыток и сетевых сбоев.
  • Согласование: метод обеспечения соответствия нашей системы состоянию внешней системы, независимо от каких-либо сбоев.

API и как их вызывать

Генерировать или не генерировать? это вопрос

Большинство API, которые мы используем, основаны на HTTP. Иногда они поставляются со спецификацией OpenAPI. Это может натолкнуть вас на мысль: Отлично, мы можем просто сгенерировать клиент и сразу перейти к реализации бизнес-логики!. Сначала мы тоже на это надеялись. Теперь мы знаем лучше.

До сих пор мы не видели ни одного API, который бы правильно следовал своей спецификации OpenAPI. Иногда сама спецификация построена неправильно. Да, Вы прочли это правильно; каждая спецификация была либо искажена, либо не соответствовала описанию, а часто и то, и другое.

Первое, что мы научились делать, это поддерживать две отдельные версии спецификации: оригинальную и пропатченную с исправлениями всех недостатков, которые нам удалось выявить. Мы храним их оба в git, чтобы мы могли легко различать изменения, внесенные провайдером в более новых версиях спецификации, и патчи, примененные до этого момента. Это позволяет нам иметь хотя бы частично правильные спецификации для API. Затем эти исправления могут быть переданы поставщику, но нет гарантии, что они будут учтены или применены к официальной спецификации.

Итак, как мы можем сгенерировать код? Уже есть несколько проектов Scala, которые позволяют генерировать код из спецификации OpenAPI, поэтому мы сможем найти что-то, что нам подходит. К сожалению, после того, как мы ограничили наши возможности совместимыми с sttp, у нас осталось только два: scala-sttp генератор или sttp-openapi-generator.

После некоторых экспериментов мы узнали, что ни один из них нам не подойдет. Первый сгенерировал код для sttp v2, а второй не смог разобрать схемы наших провайдеров. Хотя мы твердо верим в решения с открытым исходным кодом и могли бы внести свой вклад в один из этих проектов, вместо этого мы решили написать собственный (очень простой) генератор кода.

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

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

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

Насколько строгим должен быть ваш синтаксический анализ?

Мы твердо верим в чисто функциональное программирование, поэтому выбрали circe в качестве нашей библиотеки синтаксического анализа. Несмотря на то, что сама библиотека никогда нас не подводила, мы все равно сталкивались с некоторыми серьезными проблемами, связанными с анализом ответов API.

В большинстве случаев проблема сводится к одному вопросу; насколько строгим должен быть ваш синтаксический анализ? С одной стороны, вы хотите, чтобы он быстро терпел неудачу, обнаруживал проблемы как можно скорее и останавливал поток до того, как в систему будут внесены какие-либо ошибки. С другой стороны, API — это живая и развивающаяся вещь, за которой стоит (потенциально глючная) система.

Как пример поле IBAN в API одного из наших провайдеров. IBAN — это довольно хорошо определенный формат, поэтому всякий раз, когда мы его получаем, может иметь смысл преобразовать его в наше внутреннее представление с безопасным типом, которое гарантирует, что значение соответствует спецификации. На своем опыте мы поняли, что это не лучшая идея. На ум приходит пример, когда один из наших провайдеров отправил номер счета в поле IBAN просто потому, что страна происхождения не использует IBAN. Учитывая, как рано происходило преобразование IBAN в потоке, это фактически остановило обработку некоторых транзакций. Никто не любит стрессовых исправлений для производства, я прав?

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

Повтор, повтор, повтор

Ваши звонки потерпят неудачу, это точно. И когда они это сделают, вы можете повторить операцию. В нашей системе мы используем 4 уровня повторных попыток:

  • Синхронный: при ошибке служба снова выполняет вызов. Обычно это используется для известных проблем, таких как периодически возникающие ошибки 404 и 401, вызванные возможной согласованностью на стороне провайдера. Cats-retry — отличная библиотека, которую мы используем для этого паттерна.
  • Асинхронный: при ошибке сообщение отправляется и повторно обрабатывается позже. Обычно это используется при сбое вызова API во время обработки сообщения. Наш выбор для асинхронного общения — Kafka, и подробнее об обработке ошибок можно прочитать здесь.
  • Вызывающей стороной: мы передаем ошибку вызывающей стороне, чтобы она могла принять решение о политике ошибок. Обычно это происходит в случае непредвиденных ошибок, когда наиболее частым вызывающим фактором является уровень оркестровки микросервисов.
  • От пользователя: это крайняя версия предыдущего пункта. Самый простой подход — сообщить об ошибке пользователю и позволить ему выполнить действие еще раз. Это худший UX, но самый простой с точки зрения системы.

Отслеживайте свои звонки

› SwissBorg: Привет, команда, ваш API не работает. Мы сталкиваемся с ошибкой X при выполнении запроса Y.

› Партнер: Здравствуйте, не могли бы вы предоставить точный запрос, который выполняется, и временную метку?

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

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

Кэширование

Чтобы выполнить свои требования, вашему сервису может потребоваться только доступ для чтения к API вашего провайдера. В таких случаях может возникнуть соблазн кэшировать данные на вашей стороне, чтобы уменьшить потенциальное время простоя. Кэширование здесь может означать кеш в памяти, внешний кеш, такой как Redis или Memcached, или просто сброс необходимых данных в базу данных. Но прежде чем вы это сделаете, вам нужно задать себе пару вопросов.

  • Будет ли ваша система действительно продолжать работать, несмотря на то, что провайдер не работает?
    Во многих случаях обслуживание одного экрана приложения и сбой на другом нельзя считать выигрышем.
  • Стоит ли улучшенный UX введенной сложности?
    Возможно, ваш провайдер каждую неделю выходит из строя, находясь на критическом пути для пользователя. Или, может быть, он не работает раз в квартал и из-за какой-то побочной функции, которая все равно редко используется. Кэширование не бывает бесплатным.
  • Могут ли кэшированные данные измениться? Если да, то в каких случаях и как часто?
  • Что является золотым источником правды для вашей системы?
    Как только вы начнете дублировать данные, вы столкнетесь со всеми связанными с этим проблемами — прежде всего с несоответствием между кешем и его источником.

Ответив на все эти забавные вопросы, мы внедряем кеширование только там, где оно дает значительную выгоду пользователю. В этом случае мы используем замечательную библиотеку под названием ScalaCache.

Обработка вебхука

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

Проверка звонящего

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

  • Подпись: провайдер создаст подпись для полезной нагрузки веб-перехватчика с помощью ключа. Самый безопасный вариант — когда провайдер использует пару ключей; подпись генерируется с использованием закрытого ключа и проверяется вами с помощью открытого ключа. Пример используемого здесь алгоритма — SHA256 с RSA.
    Альтернативный подход — использование общего ключа между вами и поставщиком. В этом случае может использоваться алгоритм HMAC SHA256.
  • Заголовок авторизации: Теоретически ваш провайдер может позволить вам настроить любую схему аутентификации, например, базовую аутентификацию, токен или что-то еще. Однако, по нашему опыту, он редко доступен, а если и есть, то ограничивается простым общим секретом.
  • Белый список IP-адресов: провайдер может поделиться с вами списком хостов, которые, как ожидается, будут отправлять вызовы веб-перехватчиков. Проблема в том, что нет надежного способа получить IP-адрес вызывающего абонента из http-запроса — это сильно зависит от всей инфраструктуры между вами и вызывающим абонентом.

Не звоните нам, мы позвоним вам

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

Парсинг снова

Это может констатировать очевидное, но поскольку нам больше ничего не нужно от идентификатора из события, так как мы больше ничего не анализируем. Таким образом, мы ограничиваем нашу подверженность ошибкам синтаксического анализа или DDoS-атакам, использующим синтаксический анализ json.

Идемпотентность

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

Потому что сеть никогда не бывает надежной

Всякий раз, когда вы отправляете запрос по сети, вы должны быть готовы к тому, что вы не получите ответа. В этом случае нет возможности узнать, прервалась ли связь на пути к провайдеру или на обратном пути. Другими словами, вы понятия не имеете, был обработан ваш запрос или нет.

Поскольку вы хотите повторно обрабатывать события

Даже если ничего плохого не произойдет, пока вы полагаетесь на асинхронную связь, может быть сложно или даже невозможно добиться «однократной доставки». Если вы решите использовать «доставку по крайней мере один раз», вы должны быть готовы к многократной доставке сообщения и, следовательно, к повторной обработке сообщения.

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

Идемпотентные ключи к спасению

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

А если на стороне провайдера ничего нет?

Вот тут и начинаются неприятности. Для этого сценария нет общего руководства, но есть неписаное правило: «если вы не получили ответ, вы можете только предположить, что запрос был выполнен». Иногда вы можете проверить этот сценарий, просто запросив у API изменения, которые вы хотели внести. В других случаях вам нужно немедленно сбросить все попытки повторных попыток и оставить дело для ручного разрешения, уточнив у провайдера, был ли запрос выполнен, например, вызов телефонного коммутатора в 60-х годах.

Примирение

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

  • Вызовы Webhook не были отправлены провайдером.
  • Вызовы веб-перехватчиков были потеряны из-за проблем с сетью.
  • Вызовы веб-перехватчиков не обрабатывались в нашей системе должным образом из-за ошибки.

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

Существует несколько способов реализации такого процесса. Как правило, вы хотите получить все объекты (например, транзакции) со стороны провайдера либо через API, либо через отдельные отчеты, и повторно обработать все, что было пропущено или не соответствует вашей системе.

Это может показаться простым в теории, но на практике это может превратиться в действительно запутанную часть системы. Есть ряд вопросов, которые могут усложнить течение.

  • Как сделать процесс быстрым?
  • Имеют ли объекты терминальное состояние? (например, транзакция не изменится после ее завершения)
  • Можем ли мы получить только обновленные объекты?
  • Могут ли объекты появляться не по порядку? (например, новый объект имеет более раннюю дату создания, чем ранее виденный)
  • Как часто мы можем запускать процесс?

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

Что дальше?

Мониторинг и оповещение

Невозможно предугадать все возможные ошибки интеграции, но это не значит, что ничего нельзя сделать! Мы всегда стараемся придерживаться классического принципа:

Обмани меня один раз, позор вам; Обмани меня дважды, позор мне.

Чтобы прислушаться к зову и оставаться мудрыми, мы постоянно добавляем в нашу систему мониторинг и оповещения.

Мы используем OpsGenie для управления нашими оповещениями и полагаемся на два источника данных для них: журналы через Elasticsearch и метрики через Prometheus. Эти два источника довольно хорошо дополняют друг друга — журналы можно использовать для обнаружения ошибочных предположений, обнаруженных службой, таких как ситуации, которые никогда не должны возникать, или просто ошибки синтаксического анализа, а метрики позволяют, например, увидеть состояние системы и обнаружить аномалии. увеличение количества неудачных транзакций.

Но самое главное – это непрерывность усилий. Вам не нужно заранее определять все свои правила, но вам нужно иметь готовую настройку, которая позволит вам очень легко добавлять их всякий раз, когда появляется новая проблема, система, которая может развиваться.

Улучшите свои навыки общения

Несмотря на то, что мы хотели бы сосредоточиться в основном на инженерных аспектах интеграции, редко когда удается ограничить нашу работу только этими аспектами. Во многих случаях — особенно при интеграции с более мелкими и более специализированными поставщиками услуг — вам необходимо постоянное взаимодействие с другой командой. Эти командные взаимодействия не сильно отличаются от тех, которые можно увидеть, например, в OSS, где вы задаете вопросы, сообщаете об ошибках и запрашиваете функции. Разница заключается в том, что вы не можете легко разветвить сервис, и вам нужно поддерживать хорошие отношения с другой стороной. К счастью, обе стороны заинтересованы в успешном сотрудничестве, поэтому это немного проще, чем в OSS.

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

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

Краткое содержание

Создание высокоустойчивой системы поверх множества внешних поставщиков — колоссальная задача. Успех в этом часто сводится к «политике нулевого доверия» — отказу от каких-либо гарантий надежности и готовности к любым проблемам, которые могут возникнуть. Это сложно, так как вы часто обнаружите, что реализуете механизмы, которые не являются строго обязательными в идеальном мире, но также могут быть полезными, так как это может быть действительно полезным и значительным удовлетворением.