Errors
ReadyEvery failure carries a typed reason, not just a message. A gRPC status gives a coarse category, accompanied by an ErrorDetail with a stable ErrorReason, a trace_id, and — where they apply — the offending field, subject, and an auto-expiry. Branch on the reason; quote the trace‑id to the operator. The coarse code is advisory, and the message is masked — the reason is the contract.
Who this is for: the engineer handling failures in code, and anyone debugging a call. This page is the anatomy of an error, the full catalog of reasons and what to do about each, and the rules for retrying versus stopping.
Anatomy of an error#
A failed call returns a gRPC status carrying an ErrorDetail. The server sets these fields:
| Field | What it is |
|---|---|
| reason enum | The stable, typed ErrorReason. Always set. This is what you branch on. |
| trace_id string | A 32-char W3C trace ID. Always set. The one string to quote to the deployment’s operator to find the server-side context. |
| field string | The dotted path of the input that failed validation — set for INVALID_ARGUMENT. |
| subject string | The opaque ID of the entity the call referred to — e.g. the user on USER_NOT_FOUND. |
| auto_expires_at epoch ms | Set on RATE_LIMIT_EXCEEDED from a per-user limit: when the limit admits the call again, so do not retry before it. For an emptied bucket it is the later of its 30 minute lock and its next token. Zero means no time is given. |
Read the reason, not the code#
The coarse gRPC code (NotFound, FailedPrecondition, ResourceExhausted…) is a sensible bucket, but it is not unique to a reason — the same reason can ride different codes depending on where it is raised. PERMISSION_DENIED, for instance, appears under four codes. So branch on ErrorDetail.reason, never on the code or the message text. The SDKs extract it for you into one typed surface:
// The relying-party (Go) path: the SDK unwraps the detail into one typed error. _, err := client.IssueAuthChallenge(ctx, req) var se *ydentifi.StatusError if errors.As(err, &se) { switch se.Reason { case identity.ErrorReason_LICENSE_USER_CAP_REACHED: // at your plan's cap — surface an upgrade prompt case identity.ErrorReason_USER_LOCKED: // you locked this user; UpdateUserStatus(ACTIVE) unlocks case identity.ErrorReason_RATE_LIMIT_EXCEEDED: // not before se.AutoExpiresAtUnixMs case identity.ErrorReason_INVALID_ARGUMENT: // se.Field names the offending input } log.Printf("ydentifi error trace=%s", se.TraceID) } // Or match a sentinel directly: errors.Is(err, ydentifi.ErrNotFound)
// The device (Swift) path: catch the one typed error enum. do { let ack = try await device.submitChallengeResponse( /* ... */ ) } catch let e as YdentifiDeviceError { switch e { case .server(let reason, _, let traceID, _, let autoExpiresAt): switch reason { case .userLocked: break // locked by the service; no countdown case .rateLimitExceeded: break // not before autoExpiresAt case .permissionDenied: break // stop — this device isn't allowed default: break } // include traceID in any support ticket case .cryptoFailure: // Face ID or Touch ID did not sign. Cancelled: let the user retry. // LAError.biometryLockout: unlock with the passcode first. // TKError.corruptedData: the key is gone; enrol again. break default: break } }
Human-readable messages are deliberately opaque on the wire — internal detail is masked to Internal and logged server-side, not leaked to clients. Do not parse the message string; it is for humans, and it can change. The reason is the stable contract, and the trace_id is how the operator finds the rest.
The catalog#
Every reason the server can return, grouped by domain. Adding a reason is non-breaking; renaming or removing one is not — so it is safe to default: an unknown reason to “transient, retry once.”
| Reason | Means | What to do |
|---|---|---|
| USER_NOT_FOUND | No user with that opaque ID — wrong ID, or it was deleted. | Re-check the opaque ID against your own mapping. |
| USER_ALREADY_EXISTS | A create collided with an existing identity. | Treat as idempotent; keep one opaque ID per person. |
| MAX_USERS_REACHED | This relying party already has as many users as its seat allocation (max_users) allows. | Delete a user you no longer need (the seat is free at once), or give this relying party more seats. |
| USER_LOCKED | You locked this user with UpdateUserStatus. Every call for them is refused until you set them ACTIVE again; there is no expiry. Not the per-user rate limits — those return RATE_LIMIT_EXCEEDED. | Set the user ACTIVE when the lock is no longer needed. |
| DEVICE_NOT_FOUND | No such device for this user. | Refresh with ListDevicesForUser. |
| DEVICE_BANNED | A banned phone’s own call or open stream was refused. Enrolling banned hardware is refused with PERMISSION_DENIED instead, so that refusal does not reveal the ban. | Intentional — unban the hardware first if this is wrong. |
| MAX_DEVICES_REACHED | The user already has 3 active devices, the most a user may have. IssueEnrollmentInvite refuses with FAILED_PRECONDITION; an enrollment that reaches the limit after its invite was issued is refused with RESOURCE_EXHAUSTED. | Show the user their devices (ListDevicesForUser), let them, once authenticated, choose one to remove, RevokeDevice it, then invite again. See Enroll: the device limit. Retrying alone does not help. |
| Reason | Means | What to do |
|---|---|---|
| CHALLENGE_NOT_FOUND | No challenge with that ID. | Check the ID; it may have been reaped after expiry. |
| CHALLENGE_ALREADY_TERMINAL | Already approved, denied, or dismissed — cannot be re-driven. | Read the terminal outcome; issue a fresh challenge if needed. |
| CHALLENGE_ALREADY_EXISTS | Your challenge_id was already issued — the idempotent-retry signal. | Not a fault: treat a network-loss retry as success and dedupe. |
| CHALLENGE_TOO_SOON | This user’s last challenge is still open and was issued less than 15 seconds ago (RESOURCE_EXHAUSTED). Nothing of the user’s limit was spent. A retry with the newest open challenge’s own challenge_id is answered CHALLENGE_ALREADY_EXISTS instead, and spends a try as before. | Wait until auto_expires_at, or until the user has answered the open challenge; show the open one rather than sending another. |
| NO_ACTIVE_DEVICE_FOR_USER | The user has no enrolled device to receive the challenge. | Onboard a device first — see Enroll & link devices. |
| Reason | Means | What to do |
|---|---|---|
| MIN_STRENGTH_REQUIRED | You issued a challenge without a tier. | Set min_strength explicitly — there is no implicit default. |
| DEVICE_INCAPABLE | The device cannot meet the requested tier (e.g. a signed biometric on a phone with none enrolled). | Lower the requirement, or prompt the user to enroll a stronger device. |
| AUTH_STRENGTH_REQUIRED | The device response did not stamp a strength. | Should not occur on current clients; update the SDK. |
| AUTH_STRENGTH_INSUFFICIENT | An approval came back below the required level — the cryptographic backstop. A decline is not held to that level. | Expected when a factor was unavailable at sign time; re-challenge. |
| Reason | Means | What to do |
|---|---|---|
| TOTP_NOT_ENROLLED | The account has no TOTP secret. | Enroll one first. |
| TOTP_SECRET_KEY_UNAVAILABLE | The secret was encrypted with an AEAD key the operator has since removed. | Re-mint with IssueUserTotpEnrollment(reuse_existing=false). |
| TOTP_LOCKED | The user's codes are locked after 100 wrong codes in a row. Every code is refused, a correct one too. The user stays ACTIVE and push challenges still work. GetUser shows totp_locked. | Confirm the user another way, then issue a new secret with IssueUserTotpEnrollment(reuse_existing=false). Nothing else unlocks the codes. |
| Reason | Means | What to do |
|---|---|---|
| ENROLLMENT_INVITE_EXPIRED | The single-use invite timed out. | Mint a fresh invite and re-scan. |
| LICENSE_USER_CAP_REACHED | The deployment holds more seats or users than its license allows, and refuses every call until its operator resolves it. | Add seats; see Get a license. |
| LICENSE_EXPIRED | The license expired more than 30 days ago, and its grace period has ended. | Run ydfi pull, then ydfi license apply. The admin plane still works. |
| LICENSE_VIOLATED | The server has no valid license loaded. | Check the installed file with ydfi deployment verify-license --license-file <path>. |
| RATE_LIMIT_EXCEEDED | A rate limit was hit. On your calls it is a per-user limit (subject names the user): once a user empties one, that action is refused for 30 minutes, or until its next token if that is later. On a phone’s enrolment it means the server’s enrolment budget is spent (message enrolment is busy on this server; retry in a few seconds), which passes in seconds. | Do not retry before auto_expires_at. To clear a user’s limits at once, once the cause is resolved, call UnlockUserRequestLimits. |
| Reason | Means | What to do |
|---|---|---|
| MTLS_CERT_NOT_PINNED | Your client certificate is not recognized by the deployment. | (Re)provision it — see Certificates & PKI. |
| MTLS_CERT_EXPIRED | Your client certificate has expired. | Rotate with RotateCertificate before expiry. |
| PERMISSION_DENIED | The call is not allowed for this identity — often a cross-tenant reach. | Stop; do not retry. Confirm the identity owns the entity. |
| INVALID_ARGUMENT | A field failed validation. | Read ErrorDetail.field — it names the exact input. |
| NOT_IMPLEMENTED | The RPC is not available on this deployment version. | Check the deployment’s version, or the public .proto files. |
| SERVER_INTERNAL | A masked internal fault. The message is deliberately opaque. | Retry once; if it persists, quote the trace-id to the operator. |
| SERVER_UNAVAILABLE | A transient server-side condition. | Retry with exponential backoff. |
Retry, back off, or stop#
Five buckets cover the whole catalog:
When a failure reaches a human, the useful artefact is the trace_id. A relying party quotes it to their Ydentifi operator, who looks it up in the deployment’s tracing — no personal data changes hands, and the exact call is found in one hop. See Operate & maintain.