Все статьи
Deep dive8 мин чтения

И Apple, и Google могут доставить один и тот же возврат на ваш сервер более одного раза, а дублирующиеся уведомления о возвратах обходятся вам дорого, если реагировать на каждое из них

Apple повторяет уведомление о возврате до пяти раз, а Google Play работает через Pub/Sub с гарантией доставки не менее одного раза, поэтому один и тот же возврат может прийти на ваш сервер более одного раза. Вот как обрабатывать дублирующиеся уведомления о возвратах, не списывая баланс и не расходуя квоту API дважды.

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

Главное

  • Apple повторяет App Store Server Notification V2 пять раз, через 1, 12, 24, 48 и 72 часа после последней попытки, всякий раз, когда ваш сервер не отвечает статусом HTTP в диапазоне от 200 до 206. С учётом первой попытки один возврат может прийти до шести раз.
  • Каждая настоящая повторная попытка Apple несёт один и тот же notificationUUID, поэтому именно это поле, а не идентификатор транзакции, является вашим ключом дедупликации.
  • Real-time Developer Notifications в Google Play работают поверх Cloud Pub/Sub, который гарантирует доставку не менее одного раза и не гарантирует порядок, поэтому одно и то же сообщение может прийти дважды или в другом порядке. Google советует проверять messageId на уникальность, прежде чем что-либо обрабатывать.
  • Периодические уведомления CONSUMPTION_REQUEST от Apple не являются повторными попытками. Apple продолжает присылать новые в течение всего открытого окна возврата, каждое со своим notificationUUID, поэтому дедупликация по notificationUUID корректно сохраняет каждое из них.
  • Один и тот же transactionId в Apple может нести более одного решения, например REFUND_DECLINED, за которым позже следует REFUND, поэтому дедупликация только по идентификатору транзакции отбрасывает отдельное событие, которое вам было нужно.
  • Отклонение дубликата возвратом 4xx или 5xx лишь заставит магазин повторить попытку. Выполняйте дедупликацию внутри собственной базы данных и всегда возвращайте статус успеха.
  • Обработчик возвратов, который не является идемпотентным, повторно совершает действие при второй доставке. Он списывает баланс дважды, отменяет выплату дважды или расходует платную квоту Play Developer и App Store Server API, перепроверяя возврат, который уже закрыл.

Ваш сервер получит одно и то же событие возврата более одного раза, и оба магазина спроектировали это намеренно. Apple повторяет App Store Server Notification до пяти раз, когда ваша конечная точка не отвечает корректно. Google Play доставляет свои Real-time Developer Notifications через Cloud Pub/Sub, который обещает доставку не менее одного раза и ничего не обещает о порядке. Поэтому вопрос не в том, придёт ли дубликат. Вопрос в том, что делает ваш код, когда во второй раз видит тот же возврат. Ошибитесь здесь, и вы спишете баланс дважды, отмените выплату дважды или израсходуете платную квоту API, перепроверяя возврат, который уже закрыли. Вот как дублирующиеся уведомления о возвратах на самом деле доходят до вас, какие повторы являются настоящими дубликатами, а какие только выглядят таковыми, и как обрабатывать их так, чтобы вторая доставка ничего не стоила.

Уведомление о возврате доставляется не менее одного раза, что не то же самое, что ровно один раз

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

Apple повторяет пять раз в течение трёх дней

Когда Apple отправляет App Store Server Notification V2, она ожидает, что ваш сервер ответит статусом HTTP в диапазоне от 200 до 206. Всё остальное, 4xx или 5xx, сообщает Apple, что доставка не удалась, и Apple повторяет попытку. Расписание фиксированное: пять повторов, через 1, 12, 24, 48 и 72 часа после предыдущей попытки. С учётом первой попытки одно событие возврата может прийти до шести раз, растянувшись примерно на неделю. Каждый из этих повторов несёт один и тот же notificationUUID. Это поле является вашим ключом дедупликации. Если вы уже записали notificationUUID, доставка, которую вы держите в руках, является повтором, и правильная реакция, это не сохранять ничего нового и всё равно вернуть 200.

Google Play работает через Pub/Sub, который обещает доставку не менее одного раза и ничего не говорит о порядке

Real-time Developer Notifications в Google Play публикуются в тему Cloud Pub/Sub. Гарантия доставки Pub/Sub, это не менее одного раза, и он вообще не даёт никаких гарантий порядка. Это значит, что одно и то же сообщение может быть доставлено на вашу конечную точку более одного раза, а два сообщения об одной покупке могут прийти в неверном порядке. Собственное руководство Google недвусмысленно: распакуйте поле data в формате base64, прочитайте messageId и проверьте, что вы его ещё не видели, прежде чем что-либо обрабатывать. Дублирующийся messageId, это повтор, который вы пропускаете. Два разных уведомления об одной покупке всё же должны попадать в одну и ту же запись, поэтому привязывайте сохранённое состояние также к purchaseToken и позвольте более позднему событию обновить строку, которую создало более раннее.

ПлатформаМодель доставкиДедупликация поСигнал успехаЕсли вы не подтвердите
App Store Server Notifications V2До 6 попыток: первая плюс 5 повторов через 1, 12, 24, 48, 72 часаnotificationUUIDHTTP от 200 до 206Apple повторяет по фиксированному расписанию, затем прекращает
Google Play RTDN через Pub/SubНе менее одного раза, без гарантии порядкаPub/Sub messageId, привязка сущности по purchaseTokenHTTP 200 в ответ на push или явное подтверждениеPub/Sub повторно отправляет после истечения крайнего срока подтверждения

Повторы, которые не являются дубликатами

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

Apple присылает новые CONSUMPTION_REQUEST, а не повторы

Во время открытого запроса на возврат по расходуемой покупке Apple не отправляет один CONSUMPTION_REQUEST и ждёт. Она отправляет новые периодически на протяжении всего открытого окна возврата, пока возврат не будет закрыт. Сотрудники Apple подтвердили, что это не повторы, и подсказка кроется в поле, по которому вы выполняете дедупликацию: каждый новый CONSUMPTION_REQUEST несёт другой notificationUUID. Поэтому дедупликация по ключу notificationUUID делает правильную вещь автоматически. Она объединяет настоящие повторы и сохраняет каждый отдельный запрос. Чего вы не должны делать, так это выполнять дедупликацию по идентификатору транзакции и типу уведомления, потому что это заглушило бы каждый CONSUMPTION_REQUEST после первого и стоило бы вам 12-часового окна для сбора доказательств по тем, которые вы отбросили.

Одна транзакция может нести более одного решения

Один transactionId может произвести более одного исхода возврата за свою жизнь. Apple может отправить REFUND_DECLINED, а затем, позже, REFUND по той же транзакции, и разработчики сообщают о получении трёх и более уведомлений, связанных с возвратом, по одному идентификатору транзакции. Каждое является отдельным событием со своим собственным notificationUUID. Если вашим ключом дедупликации является идентификатор транзакции, второе решение выглядит как дубликат первого, и вы так и не узнаете, что возврат в итоге был предоставлен. Идентификатор транзакции группирует события. Он не идентифицирует их.

Роботизированный захват поднимает одну дублирующуюся посылку с конвейерной линии в боковой отсек, образ дедупликации повторяющихся уведомлений о возвратах

Во что на самом деле обходится вам дубликат

Уведомление о возврате, это не индикатор статуса. Оно запускает реальные действия: вы отзываете доступ, вы списываете расходуемый баланс, вы отменяете выплату автору, вы вызываете App Store Server API или Play Developer API, чтобы подтвердить состояние. Выполните любое из этих действий второй раз по дубликату, и цена будет реальной.

Проследите за деньгами. Отзыв доступа дважды безвреден, потому что доступ уже отозван. Списание баланса дважды безвредным не является: пользователь, который купил набор монет и вернул его, может быть уведён в отрицательный баланс, который вашей службе поддержки затем придётся разбирать вручную. Отмена выплаты дважды отзывает деньги, которые вы уже вернули один раз, и теперь вы должны автору извинения и исправление. И каждый дубликат, который вы повторно обрабатываете через API магазина, тратит квоту, которую Google явно предупреждает беречь, поэтому всплеск повторной доставки Pub/Sub во время сбоя может загнать вас в ограничение частоты запросов ровно в тот день, когда вы можете позволить себе это меньше всего.

Окна для сбора доказательств по возврату повышают ставки на стороне Apple. Если наивная дедупликация заглушит повторяющиеся CONSUMPTION_REQUEST, которые Apple отправляет в течение открытого окна возврата, вы можете пропустить тот, на который вам нужно было ответить, а CONSUMPTION_REQUEST, на который вы не отвечаете в течение 12 часов, это возврат, который Apple часто предоставляет по умолчанию. Это не двойное списание. Это потерянная продажа плюс вычислительные ресурсы, вызовы API, хранилище и выплаты, которые вы уже потратили на доставку покупки, ничего из чего возврат не возвращает.

Режим сбояЧто идёт не такВо что это обходится
Повторное списание баланса по дублирующемуся REFUNDРасходуемый баланс пользователя уходит в минусВремя поддержки вручную на выверку и плохой клиентский опыт
Двойная отмена выплатыВы отзываете деньги, которые уже вернули один разИсправление автору и уборка в бухгалтерии
Повторная обработка через API магазинаДублирующиеся вызовы расходуют квоту Play Developer или App Store Server APIОграничение частоты запросов во время сбоя, который вызвал повторную доставку
Излишняя дедупликация CONSUMPTION_REQUESTВы отбрасываете отдельный запрос на возврат как ложный дубликатПропущенное 12-часовое окно, поэтому Apple предоставляет возврат по умолчанию

Как обрабатывать дублирующиеся уведомления о возвратах без повторного действия

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

  • Выполняйте дедупликацию по идентификатору доставки магазина, а не по транзакции. Используйте notificationUUID для Apple и Pub/Sub messageId для Google Play. Храните его с ограничением уникальности, чтобы конкурирующий дубликат проиграл гонку вместо повторного действия.
  • Сделайте нижестоящее действие идемпотентным по собственным условиям. Привязка к идентификатору доставки останавливает повторную обработку, но также напишите эффект так, чтобы отзыв, списание или отмена сначала проверяли текущее состояние и были безопасны для повторного запуска.
  • Сначала сохраняйте, потом подтверждайте. Запишите событие в свою базу данных, прежде чем вернуть 200 или подтвердить сообщение Pub/Sub. Если вы подтвердите первым, а запись не удастся, магазин будет считать сообщение доставленным и больше никогда его не отправит, и теперь вы потеряли его навсегда.
  • Всегда возвращайте статус успеха, даже для дубликата. От 200 до 206 для Apple, 200 в ответ на push Pub/Sub для Google. Отклонение повтора ошибкой лишь заставит магазин повторить попытку.
  • Группируйте по сущности, идентифицируйте по событию. Привязывайте сохранённое состояние покупки к purchaseToken или originalTransactionId, чтобы доставки в неверном порядке обновляли одну строку, но относитесь к каждому notificationUUID или messageId как к отдельному событию, потому что одна покупка законно производит несколько.

Короткий чек-лист, прежде чем доверять своему вебхуку возвратов

  • Доставки Apple дедуплицируются по notificationUUID, и повтор ничего нового не записывает, но всё равно возвращает 200.
  • Доставки Google Play дедуплицируются по Pub/Sub messageId, проверяемому до любой обработки.
  • Состояние покупки привязано к purchaseToken или originalTransactionId, чтобы события в неверном порядке попадали в одну запись.
  • Каждый побочный эффект возврата, отзыв, списание или отмена, безопасен для запуска более одного раза.
  • Ваш обработчик записывает событие до того, как подтвердит его, а не после.
  • Повторяющиеся CONSUMPTION_REQUEST рассматриваются как отдельные запросы, а не дубликаты, поэтому ни одно открытое окно возврата не отбрасывается.

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

Частые вопросы

Почему мой сервер получает одно и то же уведомление о возврате из App Store более одного раза?
Потому что Apple повторяет App Store Server Notification V2 до пяти раз, через 1, 12, 24, 48 и 72 часа после последней попытки, всякий раз, когда ваш сервер не отвечает статусом HTTP в диапазоне от 200 до 206. Каждый повтор несёт один и тот же notificationUUID, поэтому вы можете его распознать и пропустить.
Какое поле использовать для дедупликации App Store Server Notifications?
Используйте notificationUUID. Настоящий повтор всегда повторяет один и тот же notificationUUID, тогда как каждое по-настоящему новое событие, включая каждый новый CONSUMPTION_REQUEST, получает другой, поэтому дедупликация по notificationUUID пропускает повторы, не отбрасывая отдельные события.
Являются ли повторяющиеся уведомления CONSUMPTION_REQUEST дубликатами, которые мне следует игнорировать?
Нет. Apple отправляет новые уведомления CONSUMPTION_REQUEST периодически в течение открытого окна возврата, и сотрудники Apple подтверждают, что это не повторы. У каждого свой notificationUUID, поэтому обрабатывайте каждое. Их отбрасывание рискует пропустить 12-часовое окно, которое Apple даёт вам на ответ.
Как выполнять дедупликацию Real-time Developer Notifications в Google Play?
Прочитайте Pub/Sub messageId из каждого уведомления и сверьте его с теми, что вы уже обработали, прежде чем действовать, потому что Pub/Sub доставляет не менее одного раза и может отправить одно и то же сообщение более одного раза. Google прямо рекомендует это, чтобы избежать дублирующейся обработки и растраты квоты API.
Следует ли возвращать ошибку, чтобы отклонить дублирующееся уведомление о возврате?
Нет. Возврат 4xx или 5xx сообщает магазину, что доставка не удалась, поэтому он всё равно повторяет попытку. Выполняйте дедупликацию внутри собственной базы данных и всегда возвращайте статус успеха, HTTP от 200 до 206 для Apple или подтверждение 200 для push Google Play.

Источники и дополнительное чтение

RefundHalt

Автопилот возвратов для App Store и Google Play

Читайте дальше

Deep dive8 мин чтения

Возврат средств по Family Sharing отменяет один платёж, но может оставить пятерых других людей, которые продолжают пользоваться вашим приложением, и только ваш сервер может отключить им доступ

Возврат средств по Family Sharing отменяет один платёж, но может оставить до пяти членов семьи на ваших платных функциях. Apple отправляет REVOKE и ожидает, что ваш сервер прекратит доступ. Вот как работают возвраты по семейному доступу и во что обходится один такой возврат.

Playbook8 мин чтения

Обработка возвратов ломается незаметно, поэтому протестируйте возвраты за встроенные покупки в sandbox раньше, чем это сделает реальный клиент

Ваша обработка возвратов запускается только после того, как клиент уже ушёл, поэтому ошибка в ней остаётся невидимой, пока не начнёт стоить реальных денег. Оба магазина позволяют сначала запустить возврат в тестовой среде. Вот как протестировать возвраты за встроенные покупки в App Store и Google Play до того, как возврат станет реальным.

Следующий запрос на возврат уже в пути.

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