Skip to content
VoteFirst Docs
Dashboard

Swift

Offline and retries

Phones lose signal, servers ask for a pause, and somebody presses a button twice. This is what the package does about each, and what is left for you.

Retries

Every request, read or write, is attempted up to three times. A read is idempotent, so there is nothing to weigh, and a write carries a key that makes a repeat safe, so it can be retried on the same terms.

FailureRetried
No connection, a dropped connection, a timeoutYes
rate_limited, when the wait asked for is shortYes
Any 5xxYes
An idempotency conflict, meaning an earlier copy of this write is still runningYes
A plain 409, meaning the vote is already thereNo. Repeating it changes nothing
4xx of any other kindNo. Asking again the same way gets the same answer

The pause between attempts is a quarter of a second, then half. If the server names a wait in Retry-After, that wins, up to two seconds. Past that the error is handed to you instead, because somebody is watching a spinner: a minute of silence is not a spinner, it is a hang, and only you can decide whether to say so or to wait.

Attempts3
Pause between them0.25 seconds, then 0.5
Longest wait it will sleep through2 seconds

None of the three is a setting. They are what a person watching a spinner will put up with, and an app that needs different numbers can retry the call itself.

Timeouts and cancelling

A request gives up after 30 seconds. On a device with no route to the network it fails immediately rather than waiting for one, which is what puts a write into the queue instead of holding it open for days.

Cancelling the task that made the call cancels the request under it, and the call throws CancellationError rather than a VoteFirstError. A view that disappears mid load therefore draws nothing about it, which is the right outcome: nobody wants an error about a screen they already left.

In your app
let task = Task { try await VoteFirst.features() }
task.cancel()

The offline queue

A write made with no connection throws and is kept.

In your app
do {
    _ = try await VoteFirst.upvote(featureID: 21519)
} catch let error as VoteFirstError {
    if case .network = error {
        // it is queued; draw it as voted
    }
}

What is queued: the method, the path, the body, and the idempotency key it was first attempted with. It is a file in the application support directory, per project, so it survives being killed and relaunched.

Only a network failure queues. A write the server refused is not queued, because asking again would be refused again.

Minting the voter is itself a request, so on a device with no network it is the first thing to fail. The write is queued anyway, and the voter is minted when the queue drains. An app opened for the first time on a plane behaves like one that went offline mid session.

In your app
let waiting = await VoteFirst.waiting   // how many are queued
let sent = await VoteFirst.send()       // how many left the queue

send() mints or renews the voter first, because the server stores a write's response against the voter as well as against the key, and a replay has to arrive as the voter who made it. It then replays each write in the order it was made. A write that succeeds leaves the queue. A write refused for a reason that will not change leaves it too, because keeping it would mean retrying it forever. A write that fails for a reason worth retrying stops the drain there, and everything behind it keeps its place.

The built in screens do this on their own: they send the queue when a screen appears and when somebody pulls to refresh, and they draw a line saying how many are waiting. An app with no built in screens has to call send() itself, and the moment to do it is when your own reachability says the connection is back.

Nothing is retried in the background. The package registers no background task and wakes up for nothing. The queue drains when your app is running and something asks it to.

VoteFirst.forget() empties the queue with everything else.

Idempotency

Every write carries an Idempotency-Key, minted once when the write is first attempted and reused by every attempt and every replay after it. That is the whole point of it. A fresh key per attempt would let a retry after an ambiguous timeout file a second suggestion, which is exactly what the key exists to prevent.

The server keeps the response against the voter, the key and the route, for 24 hours. What that means from here:

What happenedWhat the second attempt gets
The first attempt reached the server and the answer was lostThe original answer, unchanged. Nothing is written twice
The first attempt is still running409 with a short wait, which the package retries for you
The first attempt was refusedThe key is released, so the same key can be used again
More than 24 hours passedA new write

You never see a key. It is minted, sent, stored with the queue entry, and forgotten.

One edge is worth knowing about. The server claims the key before it runs the write and records the answer after, so a connection that dies in between leaves the write done and the claim open. Every replay of that key is answered 409 until the claim is abandoned, which takes a minute. Nothing in the package cancels a write in flight, so you meet this only if you cancel a task that is mid write. The write landed; the answer is lost; asking again after a minute reports the truth.

Caching

The package uses its own URLSession with its own cache in its own directory, never URLSession.shared. A shared session means a shared cache, so a package using it would evict your app's own cached responses to make room for its own.

What it allows itself
public init(memoryCapacity: Int = 4 * 1024 * 1024,
            diskCapacity: Int = 20 * 1024 * 1024)

Every read revalidates rather than trusting the server's one minute freshness window. That window is worth almost nothing here and it costs the thing that matters: a board pulled to refresh straight after a vote was showing the count from before it. Revalidating costs a round trip and no payload. Every read carries an ETag, and a board that has not changed answers 304 with nothing in it.

To hand the client a session of your own, pass a transport.

In your app
let client = try VoteFirstClient(key: "vf_pk_...",
                                 transport: URLSessionTransport(session: .shared))

Budgets

Your project gets:

A minuteIn a burst
Reads3000300
Writes60060
New voters30030

The bucket is your project, not the device. Every copy of your app draws from the same one, which is what makes the burst the number to design against rather than the per minute figure.

Going over does not refuse one request. It turns that budget off for your whole project for sixty seconds, and every request against it answers rateLimited with Retry-After: 60 in the meantime. That is a long wait, longer than the package will sleep through, so it arrives as an error for you to handle.

The one to watch is minting voters. Thirty one people opening your app for the first time in the same second is over the burst. Every call to the identity route counts against it, including renewals, so an app calling me() on every launch is spending from it too.

The response carries what is left, if you want to watch it.

Response headers
X-RateLimit-Limit:     3000
X-RateLimit-Remaining: 299
X-RateLimit-Reset:     1787170834

Designing against all of it

Four habits, and none of them are much work.

Draw the change before the server confirms it. A vote button that waits for a round trip feels broken on a train. Move the count, send the write, and put it back if it is refused. The built in board does exactly this.

Treat a network error as success with a delay. The write is queued. Telling somebody it failed is wrong; telling them nothing is better; a quiet waiting to send is best.

Call send() when the connection comes back. Nothing else will.

Ask the board what is allowed before drawing the control. A vote button on a closed column is a button that fails when it is pressed, and no amount of error handling makes that feel good.

Next