Say "not available in your country" instead of "relay returned 451"

The edge geo-gate serves US, PE and BO for the relay and accounts hosts, and
answers 451 for anywhere else — before the request reaches the relay. Nothing in
either client knew what 451 meant, so it fell through to the generic branch.

⚠️ **The download site is deliberately not gated**, which makes this worse than
a bad error string. Someone outside those countries can read the page, install
the app, choose a password, tap sign up, and be told "relay returned 451".
Nothing in that mentions their country, and it is not a failure they can retry
past, so the only sensible reading is that the app is broken.

451 is the right status — this is a legal/policy refusal, not a 403 — so the
only thing missing was somebody translating it.

client 96.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-29 11:39:37 -04:00
co-authored by Claude Opus 4.8
parent f9f063a211
commit db7d05bd98
5 changed files with 54 additions and 0 deletions
@@ -294,6 +294,8 @@ class MessengerApi(
401, 403 -> RelayException.Unauthorized(message)
404 -> RelayException.NotFound(message.ifBlank { request.url.encodedPath })
429 -> RelayException.RateLimited(it.header("Retry-After")?.toIntOrNull())
// The geo-gate, not the relay. Needs a sentence, not a number.
451 -> RelayException.Unavailable(message)
else -> RelayException.Http(it.code, message)
}
}
@@ -133,6 +133,8 @@ class RelayAccounts(
404 -> RelayException.NotFound(path)
409 -> RelayException.Http(409, message)
429 -> RelayException.RateLimited(it.header("Retry-After")?.toIntOrNull())
// The geo-gate, not the relay. Needs a sentence, not a number.
451 -> RelayException.Unavailable(message)
else -> RelayException.Http(it.code, message)
}
}
@@ -513,6 +513,8 @@ class RelayClient(
401 -> RelayException.Unauthorized(readable)
404 -> RelayException.NotFound(path)
429 -> RelayException.RateLimited(response.header("Retry-After")?.toIntOrNull())
// The geo-gate, not the relay. Needs a sentence, not a number.
451 -> RelayException.Unavailable(readable)
else -> RelayException.Http(code, readable)
}
}
@@ -26,6 +26,21 @@ sealed class RelayException(message: String, cause: Throwable? = null) : Excepti
class RateLimited(val retryAfterSeconds: Int?) :
RelayException("rate limited" + (retryAfterSeconds?.let { ", retry in ${it}s" } ?: ""))
/**
* 451 — this service is not offered in the caller's country.
*
* 🔴 **Its own type because the generic one reads as a broken app.** The
* edge geo-gate answers 451 before the request reaches the relay, and the
* download site is deliberately NOT gated — so somebody can read the page,
* install the app, choose a password, tap sign up, and be shown "relay
* returned 451". Nothing in that mentions their country, and it is not a
* failure they can retry past, so the only sensible reading is that the app
* does not work.
*/
class Unavailable(val body: String) : RelayException(
"Flamenet isn't available in your country yet. " +
"This isn't a problem with your account or your connection.")
/** Any other non-2xx. The body is kept because relays put the reason in it. */
class Http(val status: Int, val body: String) : RelayException("relay returned $status: $body")
@@ -0,0 +1,33 @@
package io.flamenet.client
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* 451 — the country gate (§ edge policy).
*
* 🔴 **The download site is deliberately NOT gated.** Someone outside the
* served countries can read the page, install the app, choose a password, tap
* sign up — and only then does the edge answer 451, before the request ever
* reaches the relay. Reported as a status code, that reads as a broken app:
* nothing mentions their country, and it is not a failure they can retry past.
*/
class GeoUnavailableTest {
@Test
fun `the message names the country, not the status code`() {
val message = RelayException.Unavailable("").message.orEmpty()
assertTrue("country" in message, "must say why: $message")
assertTrue("451" !in message, "a status code explains nothing: $message")
}
/**
* ⚠️ It must also say what it is NOT. Someone who has just typed a password
* will otherwise assume they got it wrong and try again — and again.
*/
@Test
fun `the message rules out the account and the connection`() {
val message = RelayException.Unavailable("").message.orEmpty()
assertTrue("account" in message && "connection" in message, message)
}
}