From db7d05bd98ff174dca61525282ec58ce12ee6b90 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sat, 29 Aug 2026 11:39:37 -0400 Subject: [PATCH] Say "not available in your country" instead of "relay returned 451" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../kotlin/io/flamenet/client/MessengerApi.kt | 2 ++ .../io/flamenet/client/RelayAccounts.kt | 2 ++ .../kotlin/io/flamenet/client/RelayClient.kt | 2 ++ .../io/flamenet/client/RelayException.kt | 15 +++++++++ .../io/flamenet/client/GeoUnavailableTest.kt | 33 +++++++++++++++++++ 5 files changed, 54 insertions(+) create mode 100644 client/src/test/kotlin/io/flamenet/client/GeoUnavailableTest.kt diff --git a/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt b/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt index e8b7e3a..99ae73a 100644 --- a/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt +++ b/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt @@ -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) } } diff --git a/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt b/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt index eca890f..f446f49 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayAccounts.kt @@ -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) } } diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt index 6bb9fad..577cfc3 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -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) } } diff --git a/client/src/main/kotlin/io/flamenet/client/RelayException.kt b/client/src/main/kotlin/io/flamenet/client/RelayException.kt index 85d4629..3e8a325 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayException.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayException.kt @@ -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") diff --git a/client/src/test/kotlin/io/flamenet/client/GeoUnavailableTest.kt b/client/src/test/kotlin/io/flamenet/client/GeoUnavailableTest.kt new file mode 100644 index 0000000..fd4fb19 --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/GeoUnavailableTest.kt @@ -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) + } +}