Tokens & errors¶
The SDK manages a per-install update token for you, and surfaces every
failure as a typed SDKError. You rarely need to touch enrollment
directly, but understanding the lifecycle helps you handle errors well.
Enrollment and the token lifecycle¶
The first time the SDK talks to the backend, it enrolls the install:
it exchanges your app secret for a scoped, revocable update token. That
token is persisted through a TokenStore (the Keychain by default
on Apple platforms) and survives app launches.
Every backend-facing method auto-enrolls if there is no stored token, so
you normally never call enroll() yourself:
checkForUpdate(...)appMetadata()reportInstall(...)downloadURL(forRelease:)downloadRelease(_:)iconData(for:)
If the stored token is rejected (a uniform 401), the SDK
transparently re-enrolls once and retries the call. From your code's
point of view the call just succeeds. You can trigger these steps
explicitly if you want to:
@discardableResult public func enroll() async throws -> SDKInstallToken
@discardableResult public func refresh() async throws -> SDKInstallToken
enroll() forces an enrollment; refresh() refreshes the current
token. Both return the resulting SDKInstallToken.
The token store¶
public protocol TokenStore: Sendable {
func load() throws -> SDKInstallToken?
func save(_ token: SDKInstallToken) throws
func clear() throws
}
By default the SDK stores the token in the Keychain, marked to persist
after first unlock on that device only. It is not synced to iCloud and
not included in backups. On platforms without a Keychain the default
is an in-memory store. You can pass your own TokenStore to
AppGantryUpdates(configuration:session:tokenStore:) if you need custom
storage.
Anti-enumeration: the uniform 401¶
The /sdk/v1 surface returns a uniform 401 whenever it cannot
accept a credential: an unknown app key, a missing or malformed
Authorization header, a token that has expired or been revoked, and a
token that belongs to a different app all answer the same thing. The
cases are deliberately indistinguishable, so an attacker cannot probe
which app keys exist. The SDK does not try to tell them apart either: on
a 401 it simply re-enrolls and retries once. If enrollment itself is
unauthorized, the call surfaces the error to you. See
Every failure is the same 401.
Errors: SDKError¶
Every failure is an SDKError. It is Sendable and Equatable, and it
conforms to LocalizedError with a user-safe errorDescription.
public enum SDKError: Error, Sendable, Equatable {
case notConfigured
case notEnrolled
case api(status: Int, body: APIErrorBody)
case unexpectedStatus(Int)
case decoding(String)
case invalidURL(String)
case transport(String)
public var isUnauthorized: Bool // true when the api status is 401
}
Every /sdk/v1 failure body has the same shape, carried in the
.api(status:body:) case:
Handle it like any typed Swift error:
do {
let info = try await updates.checkForUpdate()
// ...
} catch let error as SDKError {
switch error {
case .api(let status, let body):
print("server said \(status): \(body.message)")
case .transport(let detail):
print("network problem: \(detail)")
default:
print(error.localizedDescription)
}
}
isUnauthorized is a convenience for case .api(status: 401, _). In
normal use you should rarely see a 401 reach you, because the SDK
re-enrolls transparently; a 401 that survives that means enrollment
itself was rejected (for example the app secret was revoked).
Rate limiting: honour 429 and back off¶
The edge WAF may throttle bursts of requests with an HTTP 429. The
shipped client does not implement a retry loop for 429: it
surfaces it as SDKError.api(status: 429, ...). When you see a 429,
back off before retrying (respect any Retry-After guidance and
avoid tight retry loops). Do not hammer checkForUpdate() on a timer.
Troubleshooting¶
The prompt below assumes the client constructed cleanly. When it didn't, work through these before reading further:
| Symptom | Cause | Fix |
|---|---|---|
SDKError.notConfigured |
The configuration is missing an app key or app secret | Check the values against the channel's SDK app. See Configuration |
Every call fails with a 401 that survives a retry |
The app secret was revoked, or the SDK app was deleted | Create a new SDK app on the channel and ship the new key and secret. See Configuration |
checkForUpdate() reports no update when a newer build exists |
The build hasn't been published into the channel this SDK app belongs to — uploading a build is not releasing it | Publish the build into that channel as a release |
beginInstall(for:) returns false |
The release has no iOS over-the-air manifest | Handle it: fall back to a direct download |
| The install starts and the icon greys out | Ad-hoc signing: the device isn't in the provisioning profile | Enroll the device, re-sign, re-upload |
SDKError.transport on a real device but not the simulator |
Network policy, a VPN, or App Transport Security | Confirm the device can reach api.appgantry.com over HTTPS |
A 429 reaches your code |
The SDK deliberately doesn't retry throttled calls | Back off — see Rate limiting and Rate limits |
| The token seems to reset on every launch | The default Keychain store isn't usable on that platform, so it falls back to memory | Pass your own TokenStore. See The token store |
Next¶
- Configuration: the app secret that enrollment exchanges.
- Privacy: what the SDK sends and where the token lives.
- SDK HTTP API: the same lifecycle at the wire level, for a client that isn't the Swift package.
- API reference: the
/sdk/v1endpoints behind these calls.