Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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 <T> 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 <T> 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() }
}
Original file line number Diff line number Diff line change
@@ -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<JsonObject>("{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)
}
}
Loading
Loading