Как проверить повтор офлайн-записей iOS на облачном Mac

Как проверить повтор офлайн-записей iOS на облачном Mac

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

Сначала определите идентичность операции

Сохраняйте каждую отправку пользователя как постоянную задачу: идентификатор операции, тело запроса, ключ идемпотентности, время создания и статус. Тело фиксируется при добавлении задачи в очередь. Повторная отправка может менять число попыток и статус, но не должна создавать новый ключ идемпотентности. Отдельная операция возникает только тогда, когда пользователь снова явно нажимает «Создать».

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

Критерий приёмки — не «отправить запрос только один раз», а «создать лишь одну бизнес-запись, даже если одна операция пользователя отправлена несколько раз».

Имитируйте потерю ответа с помощью локального сервера

Приведённый ниже тестовый сервер при первом получении ключа сохраняет запись, а затем возвращает 503. При повторном запросе с тем же ключом он возвращает сохранённый результат. Так воспроизводится ситуация, в которой клиент не может понять по ответу, удалось ли сохранить данные. Сохраните код на облачном Mac в файл fault_server.py:

import json
from http.server import BaseHTTPRequestHandler, HTTPServer

records = {}
attempts = {}

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path != "/items":
            self.send_error(404)
            return
        key = self.headers.get("Idempotency-Key")
        length = int(self.headers.get("Content-Length", "0"))
        body = self.rfile.read(length)
        if not key or not body:
            self.send_error(400)
            return
        try:
            payload = json.loads(body)
        except json.JSONDecodeError:
            self.send_error(400)
            return
        attempts[key] = attempts.get(key, 0) + 1
        if key in records and records[key]["body"] != payload:
            self.send_error(409)
            return
        if key not in records:
            records[key] = {"id": len(records) + 1, "body": payload}
            self.send_error(503)
            return
        result = json.dumps({
            "id": records[key]["id"],
            "attempts": attempts[key],
            "record_count": len(records)
        }).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(result)))
        self.end_headers()
        self.wfile.write(result)

HTTPServer(("127.0.0.1", 8081), Handler).serve_forever()

Запустите python3 fault_server.py. В симуляторе iOS настройте приложение так, чтобы в тестовой среде оно обращалось к http://127.0.0.1:8081/items. Сервер слушает только локальный loopback-адрес; не открывайте этот порт напрямую для внешней сети. Тестовый сервер хранит записи в памяти процесса, поэтому перезапуск скрипта удалит их. При проверке перезапуска приложения не перезапускайте скрипт. В рабочем сервисе записи об идемпотентности необходимо хранить постоянно, а атомарность при параллельных запросах обеспечивать ограничением уникальности в базе данных.

Проверьте четыре сценария поэтапно

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

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

Сценарий Действие Условие прохождения
Первая отправка Отправить задачу и получить 503 Задача сохранена, ключ не изменился
Повтор после потери ответа Повторно отправить исходную задачу Получен 200, record_count равен 1
Перезапуск приложения После первого 503 завершить и снова запустить приложение, затем восстановить очередь Используется исходный ключ, запись по-прежнему только одна
Непредвиденное изменение тела Отправить другое тело с тем же ключом Получен 409, задача переведена в состояние, требующее расследования

Что проверить в симуляторе

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

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

Задайте чёткие условия остановки очереди

Не обрабатывайте все коды состояния одинаково. При разрыве соединения, тайм-ауте и 503 из примера сохраняйте исходную задачу и повторяйте отправку позже. Код 409 означает конфликт тела запроса для данного ключа: автоматические повторы нужно остановить и сохранить диагностические сведения. Отмечать задачу выполненной можно только после однозначного подтверждения успеха. Для реального API остальные ошибки клиента также нужно разбирать по отдельности согласно контракту интерфейса, а не повторять любые запросы подряд.

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

Итоговая проверка

Перед слиянием изменений проведите ещё одну ручную проверку: после получения ошибки закройте приложение, снова откройте его и дождитесь повторной отправки. Убедитесь, что в интерфейсе видна только одна запись, локальная задача в итоге получила статус выполнения, а record_count в ответе сервера по-прежнему равен 1. Затем ещё раз действительно нажмите «Создать» и проверьте, что появились новый ключ и новая запись. Первая проверка доказывает, что старую операцию можно безопасно повторить; вторая — что правило идемпотентности не мешает новой операции пользователя.

Часто задаваемые вопросы

Можно ли после ответа 503 поставить в очередь новую операцию?

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

Нужно ли отключать сеть облачного Mac для такой проверки?

Нет. Постановку в очередь проверьте через подменяемый транспорт, а реальную повторную HTTP-отправку — через локальный сервер.

Выделенный Mac для следующего эксперимента

Выберите физический Mac mini, узел и период оплаты. Модель и доступность уточняйте в консоли — там отображаются актуальные данные.

Посмотреть конфигурации облачного Mac