A 403 is not an expired session

Signing in on a fresh install said "Your session expired. Sign out and back
in to keep going." — advice that cannot work, because signing in again hits
the same rule and says the same thing. There is no way out of that loop from
inside the app.

RelayAccounts mapped 401 and 403 to the same Unauthorized. They are not the
same: 401 means the token was refused and signing in again IS the fix; 403
means a rule refused this — too many devices, a retired device, an
unverified address — and the relay writes a sentence naming which one and
what to do about it. RelayClient already made this distinction; RelayAccounts
did not, so which client a path happened to use decided whether its refusals
were legible.

The 403 sentence now reaches the screen instead of "That isn't allowed on
this server", which replaced an answer somebody could act on with one they
could not. Only when it is really a sentence: a JSON fragment or a bare code
keeps the generic line rather than putting a payload on screen.

Predates tonight's merges — checked against fa92581 — and is why a sign-in
failure could not be diagnosed from the device.
This commit is contained in:
Paul Hitt
2026-09-03 19:22:27 -04:00
parent 3f0e36fffc
commit eff8cdb493
2 changed files with 26 additions and 2 deletions
@@ -2829,7 +2829,20 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
409 -> "That handle is already taken. Try a different one."
400, 422 -> "That server couldn't accept those details. " +
"Check the handle and try again."
403 -> "That isn't allowed on this server."
// 🔴 **Say what the relay said.** A 403 is a rule refusing this
// specific thing, and the relay writes a sentence that names it
// and what to do — "this account already has 3 registered devices,
// remove one from the Devices screen". "That isn't allowed on this
// server" replaces an answer somebody can act on with one they
// cannot, and leaves them guessing at a rule nobody stated.
//
// ⚠️ Only when it really is a sentence. `body` falls back to the
// raw payload when there is no `message`, so anything that looks
// like JSON or a bare code keeps the generic line rather than
// putting a fragment on screen.
403 -> failure.body
.takeIf { it.isNotBlank() && !it.trimStart().startsWith("{") && " " in it }
?: "That isn't allowed on this server."
in 500..599 -> "That server is having trouble right now. " +
"Try again in a moment."
else -> "That server couldn't complete this. Try again in a moment."
@@ -174,7 +174,18 @@ class RelayAccounts(
// Play Store to fix a typo.
code == "challenge_required" -> RelayException.ChallengeRequired(message)
code == "challenge_failed" -> RelayException.ChallengeFailed(message)
it.code == 401 || it.code == 403 -> RelayException.Unauthorized(message)
// 🔴 **A 403 is not an expired session.** 401 means the token
// was refused and signing in again is the fix. 403 means a
// RULE refused this — too many devices, a retired device, an
// unverified address — and the relay writes a sentence saying
// which. Reporting those as "your session expired, sign out and
// back in" sends somebody round a loop that cannot end: they
// sign in again, hit the same rule, and are told the same
// wrong thing. Observed tonight on a fresh install.
it.code == 401 -> RelayException.Unauthorized(message)
it.code == 403 && code == "email_unverified" ->
RelayException.EmailUnverified(message)
it.code == 403 -> RelayException.Http(403, message)
it.code == 404 -> RelayException.NotFound(path)
it.code == 409 -> RelayException.Http(409, message)
it.code == 429 ->