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) + } +}