mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-11-04 09:51:11 +00:00
feat(network): fetch config from jsDelivr when raw.githubusercontent.com fails
Every config file the app reads -- chains, assets, DApps, XCM, banners, token icons -- comes from raw.githubusercontent.com with no other source, so when that host cannot be reached a fresh install has no chains at all. ConfigMirrorInterceptor retries the same file on cdn.jsdelivr.net and then fastly.jsdelivr.net when the primary fails to connect or answers 403, 429, 451 or 5xx. A 404 is an answer and is returned as is. A host that failed is skipped for ten minutes so later requests do not each wait for its timeout. Mirror requests revalidate, so a mirror's long max-age cannot pin a copy in the HTTP cache after the primary is back. The shared OkHttp client and Coil's image client both carry it.
This commit is contained in:
1 parent
2d5d8bec49
commit
0b1319c866
4 files changed
+277
-2
No files matched your search
+134
@@ -0,0 +1,134 @@
|
||||
package io.novafoundation.nova.common.data.network.mirror
|
||||
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Every config file the app reads (chains, assets, DApps, XCM, banners, icons) lives on
|
||||
* raw.githubusercontent.com. When that host cannot be reached -- blocked, down, rate limited -- a fresh
|
||||
* install has nothing to show. This interceptor retries the same file on mirrors that serve the same
|
||||
* repository content, in order, and remembers for [hostDownMillis] that the primary failed so the next
|
||||
* requests do not each wait for its timeout first.
|
||||
*
|
||||
* A 404 is an answer, not an outage: it is returned as is and no mirror is asked.
|
||||
*/
|
||||
class ConfigMirrorInterceptor(
|
||||
private val mirrors: List<GithubMirror> = DEFAULT_MIRRORS,
|
||||
private val hostDownMillis: Long = TimeUnit.MINUTES.toMillis(10),
|
||||
private val clock: () -> Long = System::currentTimeMillis,
|
||||
) : Interceptor {
|
||||
|
||||
companion object {
|
||||
const val GITHUB_RAW_HOST = "raw.githubusercontent.com"
|
||||
|
||||
val DEFAULT_MIRRORS = listOf(
|
||||
GithubMirror.JsDelivr("cdn.jsdelivr.net"),
|
||||
GithubMirror.JsDelivr("fastly.jsdelivr.net"),
|
||||
)
|
||||
|
||||
private val OUTAGE_CODES = setOf(403, 429, 451)
|
||||
}
|
||||
|
||||
private val downUntil = ConcurrentHashMap<String, Long>()
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val file = GithubFile.parse(request.url)
|
||||
|
||||
if (file == null || request.method != "GET") return chain.proceed(request)
|
||||
|
||||
val candidates = listOf(request) + mirrors.map { mirror ->
|
||||
request.newBuilder()
|
||||
.url(mirror.urlOf(file).newBuilder().encodedQuery(request.url.encodedQuery).build())
|
||||
// A mirror's max-age would pin its copy in the HTTP cache long after the primary is back.
|
||||
.cacheControl(CacheControl.Builder().noCache().build())
|
||||
.build()
|
||||
}
|
||||
|
||||
val available = candidates.filterNot { isDown(it.url.host) }.ifEmpty { candidates }
|
||||
|
||||
var lastFailure: IOException? = null
|
||||
var lastOutage: Response? = null
|
||||
|
||||
for ((index, candidate) in available.withIndex()) {
|
||||
val isLast = index == available.lastIndex
|
||||
|
||||
val response = try {
|
||||
chain.proceed(candidate)
|
||||
} catch (e: IOException) {
|
||||
markDown(candidate.url.host)
|
||||
lastFailure = e
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isOutage(response) || isLast) {
|
||||
if (!isOutage(response)) downUntil.remove(candidate.url.host)
|
||||
lastOutage?.close()
|
||||
return response
|
||||
}
|
||||
|
||||
markDown(candidate.url.host)
|
||||
lastOutage?.close()
|
||||
lastOutage = response
|
||||
}
|
||||
|
||||
lastOutage?.let { return it }
|
||||
throw lastFailure ?: IOException("No source answered for ${request.url}")
|
||||
}
|
||||
|
||||
private fun isOutage(response: Response) = response.code in OUTAGE_CODES || response.code >= 500
|
||||
|
||||
private fun isDown(host: String): Boolean {
|
||||
val until = downUntil[host] ?: return false
|
||||
if (clock() < until) return true
|
||||
downUntil.remove(host, until)
|
||||
return false
|
||||
}
|
||||
|
||||
private fun markDown(host: String) {
|
||||
downUntil[host] = clock() + hostDownMillis
|
||||
}
|
||||
}
|
||||
|
||||
/** A file in a GitHub repository at a ref, as raw.githubusercontent.com addresses it. */
|
||||
data class GithubFile(val owner: String, val repo: String, val ref: String, val path: String) {
|
||||
|
||||
companion object {
|
||||
|
||||
fun parse(url: HttpUrl): GithubFile? {
|
||||
if (url.host != ConfigMirrorInterceptor.GITHUB_RAW_HOST) return null
|
||||
|
||||
val segments = url.pathSegments
|
||||
if (segments.size < 4) return null
|
||||
|
||||
val (owner, repo) = segments
|
||||
// raw accepts both `master` and `refs/heads/master`; mirrors only know the short form.
|
||||
val refLength = if (segments[2] == "refs" && segments.size >= 6) 3 else 1
|
||||
val ref = segments.subList(2, 2 + refLength).last()
|
||||
val path = segments.drop(2 + refLength).joinToString("/")
|
||||
|
||||
if (path.isEmpty()) return null
|
||||
|
||||
return GithubFile(owner, repo, ref, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface GithubMirror {
|
||||
|
||||
fun urlOf(file: GithubFile): HttpUrl
|
||||
|
||||
/** jsDelivr serves any public GitHub repository at `/gh/{owner}/{repo}@{ref}/{path}`. */
|
||||
class JsDelivr(private val host: String) : GithubMirror {
|
||||
|
||||
override fun urlOf(file: GithubFile): HttpUrl {
|
||||
return "https://$host/gh/${file.owner}/${file.repo}@${file.ref}/${file.path}".toHttpUrl()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import android.content.SharedPreferences
|
||||
import android.graphics.Color
|
||||
import coil.ImageLoader
|
||||
import coil.decode.SvgDecoder
|
||||
import coil.util.CoilUtils
|
||||
import com.google.android.play.core.integrity.IntegrityManagerFactory
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
@@ -24,6 +25,7 @@ import io.novafoundation.nova.common.data.memory.ComputationalCache
|
||||
import io.novafoundation.nova.common.data.memory.RealComputationalCache
|
||||
import io.novafoundation.nova.common.data.network.NetworkApiCreator
|
||||
import io.novafoundation.nova.common.data.network.coingecko.CoinGeckoLinkParser
|
||||
import io.novafoundation.nova.common.data.network.mirror.ConfigMirrorInterceptor
|
||||
import io.novafoundation.nova.common.data.providers.deviceid.AndroidDeviceIdProvider
|
||||
import io.novafoundation.nova.common.data.providers.deviceid.DeviceIdProvider
|
||||
import io.novafoundation.nova.common.data.repository.AssetsIconModeRepository
|
||||
@@ -116,6 +118,7 @@ import io.novafoundation.nova.common.view.input.selector.ListSelectorMixin
|
||||
import io.novafoundation.nova.common.view.input.selector.RealListSelectorMixinFactory
|
||||
import io.novasama.substrate_sdk_android.encrypt.Signer
|
||||
import io.novasama.substrate_sdk_android.icon.IconGenerator
|
||||
import okhttp3.OkHttpClient
|
||||
import java.security.SecureRandom
|
||||
import java.util.Random
|
||||
import javax.inject.Qualifier
|
||||
@@ -143,7 +146,14 @@ class CommonModule {
|
||||
|
||||
@Provides
|
||||
@ApplicationScope
|
||||
fun imageLoader(context: Context) = ImageLoader.Builder(context)
|
||||
fun imageLoader(context: Context, configMirrorInterceptor: ConfigMirrorInterceptor) = ImageLoader.Builder(context)
|
||||
// Token icons live on raw.githubusercontent.com too; Coil keeps its own disk cache.
|
||||
.okHttpClient {
|
||||
OkHttpClient.Builder()
|
||||
.cache(CoilUtils.createDefaultCache(context))
|
||||
.addInterceptor(configMirrorInterceptor)
|
||||
.build()
|
||||
}
|
||||
.componentRegistry {
|
||||
add(SvgDecoder(context))
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import io.novafoundation.nova.common.data.network.AppLinksProvider
|
||||
import io.novafoundation.nova.common.data.network.HttpExceptionHandler
|
||||
import io.novafoundation.nova.common.data.network.NetworkApiCreator
|
||||
import io.novafoundation.nova.common.data.network.TimeHeaderInterceptor
|
||||
import io.novafoundation.nova.common.data.network.mirror.ConfigMirrorInterceptor
|
||||
import io.novafoundation.nova.common.data.network.rpc.SocketSingleRequestExecutor
|
||||
import io.novafoundation.nova.common.di.scope.ApplicationScope
|
||||
import io.novafoundation.nova.common.mixin.api.NetworkStateMixin
|
||||
@@ -74,10 +75,15 @@ class NetworkModule {
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@ApplicationScope
|
||||
fun provideConfigMirrorInterceptor() = ConfigMirrorInterceptor()
|
||||
|
||||
@Provides
|
||||
@ApplicationScope
|
||||
fun provideOkHttpClient(
|
||||
context: Context
|
||||
context: Context,
|
||||
configMirrorInterceptor: ConfigMirrorInterceptor
|
||||
): OkHttpClient {
|
||||
val builder = OkHttpClient.Builder()
|
||||
.connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
@@ -86,6 +92,7 @@ class NetworkModule {
|
||||
.cache(Cache(File(context.cacheDir, HTTP_CACHE), CACHE_SIZE))
|
||||
.retryOnConnectionFailure(true)
|
||||
.addInterceptor(TimeHeaderInterceptor())
|
||||
.addInterceptor(configMirrorInterceptor)
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
builder.addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY))
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package io.novafoundation.nova.common.data.network.mirror
|
||||
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
|
||||
private const val RAW = "https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/android/chains.json"
|
||||
private const val CDN = "https://cdn.jsdelivr.net/gh/pezkuwichain/pezkuwi-wallet-utils@master/chains/v22/android/chains.json"
|
||||
private const val FASTLY = "https://fastly.jsdelivr.net/gh/pezkuwichain/pezkuwi-wallet-utils@master/chains/v22/android/chains.json"
|
||||
|
||||
class ConfigMirrorInterceptorTest {
|
||||
|
||||
private var now = 0L
|
||||
private val hits = mutableListOf<String>()
|
||||
|
||||
/** What each host does, standing in for the network: a status code, or null for a connection failure. */
|
||||
private val hosts = mutableMapOf<String, Int?>()
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.addInterceptor(ConfigMirrorInterceptor(hostDownMillis = 1_000, clock = { now }))
|
||||
.addInterceptor(Interceptor { chain -> fakeNetwork(chain.request()) })
|
||||
.build()
|
||||
|
||||
@Test
|
||||
fun `a blocked primary is served from the first mirror`() {
|
||||
hosts["raw.githubusercontent.com"] = null
|
||||
hosts["cdn.jsdelivr.net"] = 200
|
||||
|
||||
assertEquals("cdn.jsdelivr.net", fetch(RAW))
|
||||
assertEquals(listOf(RAW, CDN), hits)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mirrors are tried in order until one answers`() {
|
||||
hosts["raw.githubusercontent.com"] = 451
|
||||
hosts["cdn.jsdelivr.net"] = null
|
||||
hosts["fastly.jsdelivr.net"] = 200
|
||||
|
||||
assertEquals("fastly.jsdelivr.net", fetch(RAW))
|
||||
assertEquals(listOf(RAW, CDN, FASTLY), hits)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server error on the primary goes to a mirror`() {
|
||||
hosts["raw.githubusercontent.com"] = 503
|
||||
hosts["cdn.jsdelivr.net"] = 200
|
||||
|
||||
assertEquals("cdn.jsdelivr.net", fetch(RAW))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing file is an answer and no mirror is asked`() {
|
||||
hosts["raw.githubusercontent.com"] = 404
|
||||
|
||||
assertNull(fetch(RAW))
|
||||
assertEquals(listOf(RAW), hits)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed primary is skipped until its window passes`() {
|
||||
hosts["raw.githubusercontent.com"] = null
|
||||
hosts["cdn.jsdelivr.net"] = 200
|
||||
fetch(RAW)
|
||||
hits.clear()
|
||||
|
||||
fetch(RAW)
|
||||
assertEquals("the primary is not waited on again inside the window", listOf(CDN), hits)
|
||||
|
||||
now += 1_001
|
||||
hits.clear()
|
||||
hosts["raw.githubusercontent.com"] = 200
|
||||
assertEquals("raw.githubusercontent.com", fetch(RAW))
|
||||
assertEquals(listOf(RAW), hits)
|
||||
}
|
||||
|
||||
@Test(expected = IOException::class)
|
||||
fun `when every source fails the failure reaches the caller`() {
|
||||
fetch(RAW)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `other hosts are left alone`() {
|
||||
hosts["example.org"] = null
|
||||
|
||||
runCatching { fetch("https://example.org/a.json") }
|
||||
assertEquals(listOf("https://example.org/a.json"), hits)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the long ref form maps to the mirror's short form`() {
|
||||
val file = GithubFile.parse("https://raw.githubusercontent.com/o/r/refs/heads/master/a/b.json".toHttpUrl())
|
||||
|
||||
assertEquals(GithubFile("o", "r", "master", "a/b.json"), file)
|
||||
}
|
||||
|
||||
private fun fetch(url: String): String? {
|
||||
client.newCall(Request.Builder().url(url).build()).execute().use { response ->
|
||||
return if (response.isSuccessful) response.body!!.string() else null
|
||||
}
|
||||
}
|
||||
|
||||
private fun fakeNetwork(request: Request): Response {
|
||||
hits += request.url.toString()
|
||||
|
||||
val code = hosts[request.url.host] ?: throw IOException("unreachable: ${request.url.host}")
|
||||
|
||||
return Response.Builder()
|
||||
.request(request)
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(code)
|
||||
.message("")
|
||||
.body(request.url.host.toResponseBody("text/plain".toMediaType()))
|
||||
.build()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user