From 2b43e1f956d8ff7c2c2d51d64058ae844324a556 Mon Sep 17 00:00:00 2001 From: Nathan Martinez Date: Sun, 4 Oct 2026 02:39:03 -0600 Subject: [PATCH 1/2] feat: define provider failure semantics --- .../music/data/providers/ProviderFailure.kt | 51 +++++++ .../data/providers/ProviderFailureTest.kt | 125 ++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 app/src/main/java/com/craftworks/music/data/providers/ProviderFailure.kt create mode 100644 app/src/test/java/com/craftworks/music/data/providers/ProviderFailureTest.kt diff --git a/app/src/main/java/com/craftworks/music/data/providers/ProviderFailure.kt b/app/src/main/java/com/craftworks/music/data/providers/ProviderFailure.kt new file mode 100644 index 00000000..c4b4564f --- /dev/null +++ b/app/src/main/java/com/craftworks/music/data/providers/ProviderFailure.kt @@ -0,0 +1,51 @@ +package com.craftworks.music.data.providers + +import kotlin.time.Duration + +/** Expected external failures, without backend details, presentation, or recovery policy. */ +sealed interface ProviderFailure { + /** Authentication was explicitly rejected; this does not identify which credential is wrong. */ + data object AuthenticationRejected : ProviderFailure + + /** Authentication recovery failed, but its precise cause is unavailable. */ + data object AuthenticationRecoveryFailed : ProviderFailure + + /** HTTP 429, with an optional normalized finite, non-negative retry delay. */ + data class RateLimited(val retryAfter: Duration? = null) : ProviderFailure { + init { + require(retryAfter == null || (retryAfter.isFinite() && retryAfter >= Duration.ZERO)) { + "Retry delay must be finite and non-negative." + } + } + } + + /** A known connectivity failure, without asserting permanent provider-wide unavailability. */ + data object Unavailable : ProviderFailure + + /** A terminal non-success HTTP response. Adapters classify HTTP 429 as [RateLimited]. */ + data class HttpError(val status: Int) : ProviderFailure { + init { + require(status in 300..599) { + "HTTP error status must be between 300 and 599." + } + } + } + + /** An explicitly reported logical failure; code meanings remain the adapter's responsibility. */ + data class ProtocolError(val code: Int? = null) : ProviderFailure + + /** A malformed response or a success response missing mandatory payload. */ + data object InvalidResponse : ProviderFailure + + /** The backend explicitly reports missing content; a null success payload is insufficient. */ + data object MissingContent : ProviderFailure +} + +/** + * Carries only safe semantic information. Backend causes may contain secrets, so are not retained. + * Cancellation and programming defects must not be translated into this exception. + */ +class ProviderException(val failure: ProviderFailure) : IllegalStateException( + "Provider operation failed.", + null +) diff --git a/app/src/test/java/com/craftworks/music/data/providers/ProviderFailureTest.kt b/app/src/test/java/com/craftworks/music/data/providers/ProviderFailureTest.kt new file mode 100644 index 00000000..7a64c712 --- /dev/null +++ b/app/src/test/java/com/craftworks/music/data/providers/ProviderFailureTest.kt @@ -0,0 +1,125 @@ +package com.craftworks.music.data.providers + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Test +import java.lang.reflect.Modifier +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class ProviderFailureTest { + private val failures = listOf( + ProviderFailure.AuthenticationRejected, + ProviderFailure.AuthenticationRecoveryFailed, + ProviderFailure.RateLimited(3.seconds), + ProviderFailure.Unavailable, + ProviderFailure.HttpError(503), + ProviderFailure.ProtocolError(40), + ProviderFailure.InvalidResponse, + ProviderFailure.MissingContent + ) + + @Test + fun categoriesExposeOnlyTheirIntendedMetadata() { + val expectedFields = listOf( + emptyList(), emptyList(), listOf("retryAfter"), emptyList(), + listOf("status"), listOf("code"), emptyList(), emptyList() + ) + + failures.zip(expectedFields).forEach { (failure, expected) -> + val fields = failure.javaClass.declaredFields.filter { + !Modifier.isStatic(it.modifiers) && !it.isSynthetic + }.map { it.name } + assertEquals(expected, fields) + } + } + + @Test + fun exceptionExposesFailureWithFixedSafeMessageAndRepresentation() { + failures.forEach { failure -> + val exception = ProviderException(failure) + assertSame(failure, exception.failure) + assertEquals("Provider operation failed.", exception.message) + assertEquals(exception.message, exception.localizedMessage) + assertEquals( + "${ProviderException::class.java.name}: Provider operation failed.", + exception.toString() + ) + } + } + + @Test + fun exceptionPreservesIllegalStateExceptionCompatibility() { + val exception: IllegalStateException = ProviderException(ProviderFailure.AuthenticationRejected) + assertEquals("Provider operation failed.", exception.message) + } + + @Test + fun exceptionHasNoCauseAndCannotAcquireOneThroughInitCause() { + val exception = ProviderException(ProviderFailure.InvalidResponse) + assertNull(exception.cause) + assertThrows(IllegalStateException::class.java) { + exception.initCause(IllegalArgumentException("Sensitive backend detail")) + } + assertNull(exception.cause) + } + + @Test + fun metadataFreeCategoriesHaveDistinctValueIdentity() { + assertEquals(ProviderFailure.AuthenticationRejected, ProviderFailure.AuthenticationRejected) + assertNotEquals(ProviderFailure.AuthenticationRejected, ProviderFailure.AuthenticationRecoveryFailed) + assertNotEquals(ProviderFailure.InvalidResponse, ProviderFailure.MissingContent) + } + + @Test + fun httpStatusIsPreservedWithValueEquality() { + assertEquals(503, ProviderFailure.HttpError(503).status) + assertEquals(ProviderFailure.HttpError(503), ProviderFailure.HttpError(503)) + assertEquals(ProviderFailure.HttpError(503).hashCode(), ProviderFailure.HttpError(503).hashCode()) + assertNotEquals(ProviderFailure.HttpError(503), ProviderFailure.HttpError(502)) + assertEquals("HttpError(status=503)", ProviderFailure.HttpError(503).toString()) + } + + @Test + fun httpStatusRejectsInformationalSuccessfulAndOutOfRangeValues() { + listOf(-1, 0, 100, 200, 299, 600).forEach { status -> + assertThrows(IllegalArgumentException::class.java) { ProviderFailure.HttpError(status) } + } + assertEquals(300, ProviderFailure.HttpError(300).status) + assertEquals(599, ProviderFailure.HttpError(599).status) + } + + @Test + fun protocolCodeIsOptionalNumericMetadataWithoutBackendAssumptions() { + assertNull(ProviderFailure.ProtocolError().code) + assertEquals(40, ProviderFailure.ProtocolError(40).code) + assertEquals(-1, ProviderFailure.ProtocolError(-1).code) + assertEquals(ProviderFailure.ProtocolError(40), ProviderFailure.ProtocolError(40)) + assertNotEquals(ProviderFailure.ProtocolError(), ProviderFailure.ProtocolError(40)) + assertEquals("ProtocolError(code=40)", ProviderFailure.ProtocolError(40).toString()) + } + + @Test + fun retryDelayPreservesPrecisionAndNormalizedValueEquality() { + assertNull(ProviderFailure.RateLimited().retryAfter) + assertEquals(Duration.ZERO, ProviderFailure.RateLimited(Duration.ZERO).retryAfter) + assertEquals(1500.milliseconds, ProviderFailure.RateLimited(1500.milliseconds).retryAfter) + assertEquals(ProviderFailure.RateLimited(1.seconds), ProviderFailure.RateLimited(1000.milliseconds)) + assertNotEquals(ProviderFailure.RateLimited(), ProviderFailure.RateLimited(Duration.ZERO)) + assertEquals("RateLimited(retryAfter=3s)", ProviderFailure.RateLimited(3.seconds).toString()) + } + + @Test + fun retryDelayRejectsNegativeAndInfiniteValuesIncludingCopies() { + listOf((-1).milliseconds, Duration.INFINITE, -Duration.INFINITE).forEach { delay -> + assertThrows(IllegalArgumentException::class.java) { ProviderFailure.RateLimited(delay) } + } + assertThrows(IllegalArgumentException::class.java) { + ProviderFailure.RateLimited(1.seconds).copy(retryAfter = (-1).seconds) + } + } +} From dfcf6307a6af52c7ac66392cf7e93ad0504b3509 Mon Sep 17 00:00:00 2001 From: Nathan Martinez Date: Sun, 4 Oct 2026 05:58:37 -0600 Subject: [PATCH 2/2] feat: translate provider transport failures --- .../data/providers/ProviderFailureAdapters.kt | 68 ++++++ .../providers/ProviderFailureAdaptersTest.kt | 209 ++++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 app/src/main/java/com/craftworks/music/data/providers/ProviderFailureAdapters.kt create mode 100644 app/src/test/java/com/craftworks/music/data/providers/ProviderFailureAdaptersTest.kt diff --git a/app/src/main/java/com/craftworks/music/data/providers/ProviderFailureAdapters.kt b/app/src/main/java/com/craftworks/music/data/providers/ProviderFailureAdapters.kt new file mode 100644 index 00000000..2c6aa188 --- /dev/null +++ b/app/src/main/java/com/craftworks/music/data/providers/ProviderFailureAdapters.kt @@ -0,0 +1,68 @@ +package com.craftworks.music.data.providers + +import io.ktor.client.plugins.HttpRequestTimeoutException +import io.ktor.serialization.JsonConvertException +import kotlinx.serialization.SerializationException +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.time.Instant +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException +import kotlin.time.Duration +import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.Duration.Companion.seconds + +/** Wrap only network operations, not local file access or application logic. */ +suspend fun providerTransportBoundary(request: suspend () -> T): T = try { + request() +} catch (_: HttpRequestTimeoutException) { + throw ProviderException(ProviderFailure.Unavailable) +} catch (_: SocketTimeoutException) { + throw ProviderException(ProviderFailure.Unavailable) +} catch (_: ConnectException) { + throw ProviderException(ProviderFailure.Unavailable) +} catch (_: UnknownHostException) { + throw ProviderException(ProviderFailure.Unavailable) +} + +/** Wrap only inbound decoding. HTTP status must be handled before calling this helper. */ +suspend fun providerDecodeBoundary(decode: suspend () -> T): T = try { + decode() +} catch (_: SerializationException) { + throw ProviderException(ProviderFailure.InvalidResponse) +} catch (exception: JsonConvertException) { + // Ktor can wrap converter defects too; only a known serialization failure is translated. + if (exception.cause !is SerializationException) throw exception + throw ProviderException(ProviderFailure.InvalidResponse) +} + +/** Call only after redirects/authentication recovery are complete; never on the initial auth 401. */ +fun requireProviderHttpSuccess(status: Int, retryAfter: String? = null, now: Instant = Instant.now()) { + require(status in 100..599) { "HTTP status must be between 100 and 599." } + if (status in 200..299) return + // Informational responses are not terminal responses, and indicate incorrect helper usage. + require(status >= 300) { "Expected a terminal HTTP response." } + throw ProviderException( + if (status == 429) ProviderFailure.RateLimited(parseProviderRetryAfter(retryAfter, now)) + else ProviderFailure.HttpError(status) + ) +} + +/** Raw Retry-After text is never retained. [now] makes HTTP-date normalization deterministic. */ +fun parseProviderRetryAfter(value: String?, now: Instant = Instant.now()): Duration? { + val text = value?.trim()?.takeIf { it.isNotEmpty() } ?: return null + if (text.all { it in '0'..'9' }) { + val seconds = text.toLongOrNull() ?: return null + return seconds.seconds.takeIf { it.isFinite() } + } + val date = try { + ZonedDateTime.parse(text, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant() + } catch (_: DateTimeParseException) { + return null + } + if (date <= now) return Duration.ZERO + val delta = java.time.Duration.between(now, date) + return (delta.seconds.seconds + delta.nano.nanoseconds).takeIf { it.isFinite() } +} diff --git a/app/src/test/java/com/craftworks/music/data/providers/ProviderFailureAdaptersTest.kt b/app/src/test/java/com/craftworks/music/data/providers/ProviderFailureAdaptersTest.kt new file mode 100644 index 00000000..d4b8bb6c --- /dev/null +++ b/app/src/test/java/com/craftworks/music/data/providers/ProviderFailureAdaptersTest.kt @@ -0,0 +1,209 @@ +package com.craftworks.music.data.providers + +import io.ktor.client.network.sockets.ConnectTimeoutException +import io.ktor.client.plugins.HttpRequestTimeoutException +import io.ktor.serialization.JsonConvertException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Test +import java.io.IOException +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.time.Instant +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class ProviderFailureAdaptersTest { + private val now = Instant.parse("2015-10-21T07:27:00Z") + + @Test + fun rateLimitWithDeltaSeconds() { + assertEquals(ProviderFailure.RateLimited(12.seconds), httpFailure(429, "12").failure) + } + + @Test + fun rateLimitWithHttpDate() { + assertEquals( + ProviderFailure.RateLimited(60.seconds), + httpFailure(429, "Wed, 21 Oct 2015 07:28:00 GMT").failure + ) + } + + @Test + fun missingOrMalformedRetryAfterIsAbsent() { + listOf(null, "", " ", "-1", "+1", "1.5", "secret header", "not a date").forEach { + assertNull(parseProviderRetryAfter(it, now)) + assertEquals(ProviderFailure.RateLimited(), httpFailure(429, it).failure) + } + } + + @Test + fun overflowingRetryAfterIsAbsent() { + listOf("9223372036854775808", Long.MAX_VALUE.toString(), "9".repeat(100)).forEach { + assertNull(parseProviderRetryAfter(it, now)) + } + } + + @Test + fun pastAndCurrentDatesBecomeZero() { + listOf("Wed, 21 Oct 2015 07:26:00 GMT", "Wed, 21 Oct 2015 07:27:00 GMT").forEach { + assertEquals(Duration.ZERO, parseProviderRetryAfter(it, now)) + } + } + + @Test + fun retryAfterPreservesPrecisionAndWhitespace() { + assertEquals(Duration.ZERO, parseProviderRetryAfter("0", now)) + assertEquals(12.seconds, parseProviderRetryAfter(" 0012 ", now)) + assertEquals( + 500.milliseconds, + parseProviderRetryAfter("Wed, 21 Oct 2015 07:28:00 GMT", now.plusSeconds(59).plusMillis(500)) + ) + } + + @Test + fun terminalHttpErrorsPreserveOnlyStatus() { + listOf(301, 400, 401, 403, 404, 500, 503, 599).forEach { + assertEquals(ProviderFailure.HttpError(it), httpFailure(it, "secret header").failure) + } + } + + @Test + fun successfulHttpStatusesReturnNormally() { + listOf(200, 204, 206, 299).forEach { requireProviderHttpSuccess(it, "secret header", now) } + } + + @Test + fun invalidOrNonterminalStatusRemainsProgrammingError() { + listOf(-1, 100, 199, 600).forEach { + assertThrows(IllegalArgumentException::class.java) { requireProviderHttpSuccess(it, now = now) } + } + } + + @Test + fun knownTimeoutsBecomeUnavailable() { + listOf( + SocketTimeoutException("secret transport"), + ConnectTimeoutException("secret transport"), + HttpRequestTimeoutException("https://secret.invalid/token", 10) + ).forEach { assertSafe(transportFailure(it), ProviderFailure.Unavailable) } + } + + @Test + fun connectionRefusedBecomesUnavailable() { + assertSafe(transportFailure(ConnectException("secret transport")), ProviderFailure.Unavailable) + } + + @Test + fun dnsFailureBecomesUnavailable() { + assertSafe(transportFailure(UnknownHostException("secret host")), ProviderFailure.Unavailable) + } + + @Test + fun malformedInboundJsonBecomesInvalidResponse() { + val exception = assertThrows(ProviderException::class.java) { + runBlocking { providerDecodeBoundary { Json.decodeFromString("{secret body") } } + } + assertSafe(exception, ProviderFailure.InvalidResponse) + } + + @Test + fun knownKtorJsonConversionFailureBecomesInvalidResponse() { + val failure = JsonConvertException("secret body", SerializationException("secret decoder")) + assertSafe(decodeFailure(failure), ProviderFailure.InvalidResponse) + } + + @Test + fun cancellationPropagatesUnchangedAtBothBoundaries() { + assertUnchanged(CancellationException("cancelled")) + } + + @Test + fun existingProviderExceptionPropagatesUnchangedAtBothBoundaries() { + assertUnchanged(ProviderException(ProviderFailure.AuthenticationRejected)) + } + + @Test + fun programmingDefectsAndUnclassifiedIoRemainUnchanged() { + listOf( + NullPointerException(), ClassCastException(), IllegalStateException(), + IllegalArgumentException(), NotImplementedError(), IOException("unknown IO") + ).forEach { assertUnchanged(it) } + } + + @Test + fun ktorWrappedProgrammingDefectIsNotClassifiedAsInvalidResponse() { + assertUnchanged(JsonConvertException("converter failed", NullPointerException())) + } + + @Test + fun transportBoundaryDoesNotClassifyDecoderFailure() { + val failure = SerializationException("secret decoder") + val caught = assertThrows(SerializationException::class.java) { + runBlocking { providerTransportBoundary { throw failure } } + } + assertSame(failure, caught) + } + + @Test + fun decodeBoundaryDoesNotClassifyTransportFailure() { + val failure = ConnectException("secret transport") + val caught = assertThrows(ConnectException::class.java) { + runBlocking { providerDecodeBoundary { throw failure } } + } + assertSame(failure, caught) + } + + @Test + fun successfulBoundaryValuesArePreserved() = runBlocking { + val value = Any() + assertSame(value, providerTransportBoundary { value }) + assertSame(value, providerDecodeBoundary { value }) + } + + @Test + fun rateLimitExceptionDoesNotRetainRawHeader() { + assertSafe(httpFailure(429, "secret header"), ProviderFailure.RateLimited()) + } + + private fun httpFailure(status: Int, header: String? = null): ProviderException = + assertThrows(ProviderException::class.java) { requireProviderHttpSuccess(status, header, now) } + + private fun transportFailure(failure: Throwable): ProviderException = + assertThrows(ProviderException::class.java) { + runBlocking { providerTransportBoundary { throw failure } } + } + + private fun decodeFailure(failure: Throwable): ProviderException = + assertThrows(ProviderException::class.java) { + runBlocking { providerDecodeBoundary { throw failure } } + } + + private fun assertUnchanged(failure: Throwable) { + val transport = assertThrows(failure.javaClass) { + runBlocking { providerTransportBoundary { throw failure } } + } + val decode = assertThrows(failure.javaClass) { + runBlocking { providerDecodeBoundary { throw failure } } + } + assertSame(failure, transport) + assertSame(failure, decode) + } + + private fun assertSafe(exception: ProviderException, failure: ProviderFailure) { + assertEquals(failure, exception.failure) + assertEquals("Provider operation failed.", exception.message) + assertEquals("${ProviderException::class.java.name}: Provider operation failed.", exception.toString()) + assertNull(exception.cause) + assertEquals(0, exception.suppressed.size) + } +}