Test Idempotent Replay of an iOS Offline Write Queue on a Cloud Mac

Test Idempotent Replay of an iOS Offline Write Queue on a Cloud Mac

A user submits a support ticket on the subway, and the app reports a network error. Once the phone is back online, the iOS app retries the request—but the server now has two tickets. The queue is not necessarily at fault: the first request may have committed the write even though the success response never reached the phone. To test offline writes, you need to distinguish a failed request from a failed write.

Define the identity of an operation first

Store each user submission as a persistent task containing an operation ID, request body, idempotency key, creation time, and status. Fix the body when the task enters the queue. Retries may change the attempt count and status, but they must not generate a new idempotency key. Only an explicit second tap on “New” creates another operation.

The server must store the processing result by idempotency key. The same key with the same body returns the original result; the same key with a different body must be rejected, not silently overwritten. The client can remove a task only after receiving an unambiguous success result. Timeouts, disconnections, and server errors all mean the outcome is unknown; none is grounds for deleting the task.

The acceptance criterion is not “send the request only once.” It is “even if one user operation is sent multiple times, it creates only one business record.”

Simulate a lost response with a local endpoint

The test endpoint below commits a record the first time it receives a key, then returns 503. When it receives the same key again, it returns the saved result. This deliberately simulates a case where the client cannot tell from the response whether the write succeeded. Save the code as fault_server.py on the cloud Mac:

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()

Run python3 fault_server.py. In the iOS Simulator, point the app’s test-environment endpoint to http://127.0.0.1:8081/items. The server listens only on the local loopback address; do not expose the port directly to an external network. This test endpoint keeps records in process memory, so restarting the script clears them. Do not restart the script when testing an app restart. A production service also needs to persist idempotency records and use a database uniqueness constraint to guarantee atomicity under concurrent requests.

Run four acceptance scenarios in layers

Start by making the request-sending layer replaceable. In a queue unit test, have a substitute sender return a disconnection error. Assert that the task remains in persistent storage and that its body and key are unchanged. This avoids taking the entire cloud Mac offline or disrupting other work running on it.

Then connect the local endpoint and check each scenario in the table. Use a fresh key for each one. During testing, record the task ID, key, send count, and server response, but do not log sensitive fields from the request.

Scenario Action Pass condition
Initial submission Send the task and receive 503 Task remains queued; key is unchanged
Retry after a lost response Send the original task again Response is 200; record_count is 1
App restart After the first 503, terminate and relaunch the app, then restore the queue Original key is reused; there is still only one record
Body changes unexpectedly Send a different body with the same key Response is 409; task enters a state requiring investigation

Simulator checks

First confirm that the app is actually using the test endpoint. Then clear data for the test app itself to establish a clean baseline; do not wipe other projects from a shared simulator. After the initial submission, check that the local task still exists. Terminate and relaunch the app to trigger queue restoration, then check whether the second request carries the same key. This step usually reveals an app that stores keys only in memory.

The script counts only requests received by its current process and provides no query endpoint. Use the attempts and record_count values in the second response to verify that test run. If you need counts across test runs, add persistent storage to the fixture rather than treating in-process numbers as production metrics.

Set clear stopping conditions for the queue

Do not treat every status code the same. For disconnections, timeouts, and the example’s 503, keep the original task and retry later. A 409 means the body associated with the key conflicts; stop automatic retries and retain diagnostic information. Mark a task complete only after an unambiguous success response. For a real endpoint, assess other client errors individually against its API contract rather than retrying them all.

Limit concurrency as well: only one sender may work on a given task at a time. If both foreground restoration and background sync can drive the queue, acquire the right to send the task in the persistence layer first. The server must still enforce idempotency independently, because a client-side lock cannot prevent a lost response. A capped backoff can keep all queued tasks from flooding the server when connectivity returns, but it must not change the operation ID, key, or body.

Final check

Before merging, perform one more manual acceptance test. Exit the app after receiving the error, reopen it, and wait for the retry. Confirm that the UI shows only one record, the local task eventually becomes complete, and the server response still reports record_count as 1. Then make a genuine second tap on “New” and confirm that it creates a new key and record. The first check proves the old operation can be replayed safely; the second proves the idempotency rule does not block a new user operation.

Frequently asked questions

Should a 503 response create a new queued operation?

No. The first write may already have committed. Retry the original operation with its original idempotency key to avoid creating another record.

Do I need to disconnect the cloud Mac to test offline writes?

No. Test queueing through an injectable transport, then use a local server to exercise real HTTP replay after a lost response.

Get a dedicated Mac for your next experiment

Choose a physical Mac mini, node, and billing period to suit your needs. Models and availability are shown in real time in the console.

View cloud Mac rental plans