Merge pull request 'fix(core): allow 60 s for the metadata load and name the URL on timeout' (#26) from fix/metadata-load-timeout into main
This commit was merged in pull request #26.
This commit is contained in:
3 files changed
+99
-9
No files matched your search
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.0.4 (versionCode 6)
|
||||||
|
|
||||||
|
- Loading the server's metadata no longer fails with "Error loading QInstance: timeout" on a handheld. The metadata response is about 1.7 MB and takes the server 4 to 8 seconds to build, and the default 10 second read timeout expired. That one call now allows 60 seconds; every other call keeps its short timeout. A timeout now reads "Timed out after 60.0s loading the server's metadata from <server>/qqq/v1/metaData".
|
||||||
|
- Includes 1.0.3 (versionCode 5): string widget icons and summary-only help content from current QQQ core are accepted. (PR 24)
|
||||||
|
|
||||||
## 1.0.2 (versionCode 4), candidate
|
## 1.0.2 (versionCode 4), candidate
|
||||||
|
|
||||||
Not yet built, signed, tagged or published (prepared 2026-11-06). Supersedes the unpublished signed 1.0.1 (versionCode 3) candidate and updates it in place with the same permanent signer.
|
Not yet built, signed, tagged or published (prepared 2026-11-06). Supersedes the unpublished signed 1.0.1 (versionCode 3) candidate and updates it in place with the same permanent signer.
|
||||||
|
|||||||
+38
-9
@@ -44,18 +44,30 @@ import okhttp3.RequestBody
|
|||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InterruptedIOException
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** Ceiling for loading the server's metadata (GET /qqq/v1/metaData). That response is large (about 1.7 MB for a
|
||||||
|
** platform administrator on the staging server) and the server needs 4 to 8 seconds to build it, so the default
|
||||||
|
** 10 second OkHttp read timeout expired on handhelds. Only this one call gets the long limit; every other call
|
||||||
|
** keeps the short defaults.
|
||||||
|
*******************************************************************************/
|
||||||
|
const val METADATA_LOAD_TIMEOUT_SECONDS = 60L
|
||||||
|
|
||||||
|
|
||||||
/*******************************************************************************
|
/*******************************************************************************
|
||||||
*******************************************************************************/
|
*******************************************************************************/
|
||||||
class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObserver: (RequestOutcome) -> Unit = {}, private val compatibility: UiApiCompatibility? = null, private val additionalHeaders: (String) -> Map<String, String> = { emptyMap() }) : QQQRepository {
|
class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObserver: (RequestOutcome) -> Unit = {}, private val compatibility: UiApiCompatibility? = null, private val metaDataTimeoutMillis: Long = METADATA_LOAD_TIMEOUT_SECONDS * 1000, private val additionalHeaders: (String) -> Map<String, String> = { emptyMap() }) : QQQRepository {
|
||||||
private val sessionUUIDs = ConcurrentHashMap<String, String>()
|
private val sessionUUIDs = ConcurrentHashMap<String, String>()
|
||||||
private val retroJson = Json { ignoreUnknownKeys = true }
|
private val retroJson = Json { ignoreUnknownKeys = true }
|
||||||
private val compatibilityByServer = ConcurrentHashMap<String, UiApiCompatibility>()
|
private val compatibilityByServer = ConcurrentHashMap<String, UiApiCompatibility>()
|
||||||
|
|
||||||
private val qqqApiServiceMap: MutableMap<String, QQQApiService> = mutableMapOf()
|
private val qqqApiServiceMap: MutableMap<String, QQQApiService> = mutableMapOf()
|
||||||
|
private val metaDataApiServiceMap: MutableMap<String, QQQApiService> = mutableMapOf()
|
||||||
|
|
||||||
/*******************************************************************************
|
/*******************************************************************************
|
||||||
*******************************************************************************/
|
*******************************************************************************/
|
||||||
@@ -63,9 +75,13 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
|
|||||||
baseUrl = environment.baseUrl
|
baseUrl = environment.baseUrl
|
||||||
|
|
||||||
if (qqqApiServiceMap[baseUrl] == null) {
|
if (qqqApiServiceMap[baseUrl] == null) {
|
||||||
val thisRetrofit = buildRetrofit(baseUrl)
|
val client = buildClient(baseUrl)
|
||||||
val thisQQQApiService = thisRetrofit.create(QQQApiService::class.java)
|
qqqApiServiceMap[baseUrl] = buildRetrofit(baseUrl, client).create(QQQApiService::class.java)
|
||||||
qqqApiServiceMap[baseUrl] = thisQQQApiService
|
val metaDataClient = client.newBuilder()
|
||||||
|
.callTimeout(metaDataTimeoutMillis, TimeUnit.MILLISECONDS)
|
||||||
|
.readTimeout(metaDataTimeoutMillis, TimeUnit.MILLISECONDS)
|
||||||
|
.build()
|
||||||
|
metaDataApiServiceMap[baseUrl] = buildRetrofit(baseUrl, metaDataClient).create(QQQApiService::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
// retrofit = buildRetrofit()
|
// retrofit = buildRetrofit()
|
||||||
@@ -74,10 +90,19 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
|
|||||||
|
|
||||||
/*******************************************************************************
|
/*******************************************************************************
|
||||||
*******************************************************************************/
|
*******************************************************************************/
|
||||||
private fun buildRetrofit(baseUrl: String): Retrofit {
|
private fun buildRetrofit(baseUrl: String, client: OkHttpClient): Retrofit {
|
||||||
return Retrofit.Builder()
|
return Retrofit.Builder()
|
||||||
.addConverterFactory(retroJson.asConverterFactory("application/json".toMediaType()))
|
.addConverterFactory(retroJson.asConverterFactory("application/json".toMediaType()))
|
||||||
.client(
|
.client(client)
|
||||||
|
.baseUrl(baseUrl)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** Build the shared client (same-origin, session, compatibility and error interceptors) with default timeouts.
|
||||||
|
*******************************************************************************/
|
||||||
|
private fun buildClient(baseUrl: String): OkHttpClient {
|
||||||
|
return (
|
||||||
OkHttpClient.Builder()
|
OkHttpClient.Builder()
|
||||||
.followRedirects(false)
|
.followRedirects(false)
|
||||||
.followSslRedirects(false)
|
.followSslRedirects(false)
|
||||||
@@ -110,8 +135,6 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
|
|||||||
.addInterceptor(SameOriginInterceptor())
|
.addInterceptor(SameOriginInterceptor())
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.baseUrl(baseUrl)
|
|
||||||
.build()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*******************************************************************************
|
/*******************************************************************************
|
||||||
@@ -309,7 +332,13 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs
|
|||||||
override suspend fun getMetaData(
|
override suspend fun getMetaData(
|
||||||
frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String
|
frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String
|
||||||
): QInstance {
|
): QInstance {
|
||||||
return qqqApiServiceMap[baseUrl]!!.getMetaData(frontendName, frontendVersion, applicationName, applicationVersion)
|
try {
|
||||||
|
return metaDataApiServiceMap[baseUrl]!!.getMetaData(frontendName, frontendVersion, applicationName, applicationVersion)
|
||||||
|
} catch (e: InterruptedIOException) {
|
||||||
|
// OkHttp reports read and call timeouts as InterruptedIOException (SocketTimeoutException); the bare "timeout" is not diagnosable.
|
||||||
|
// Coroutine cancellation is a CancellationException, not an IOException, so it is unaffected.
|
||||||
|
throw IOException("Timed out after ${metaDataTimeoutMillis / 1000.0}s loading the server's metadata from ${baseUrl.trimEnd('/')}/qqq/v1/metaData", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*******************************************************************************
|
/*******************************************************************************
|
||||||
|
|||||||
+56
@@ -303,4 +303,60 @@ class NetworkQQQRepositoryTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** The metadata response is large and slow to build; a response that starts after the old 10 second read timeout must still load.
|
||||||
|
*******************************************************************************/
|
||||||
|
@Test
|
||||||
|
fun metaDataLoadSurvivesAServerThatAnswersAfterTheOldDefaultTimeout() = runBlocking() {
|
||||||
|
MockWebServer().use { server ->
|
||||||
|
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
|
||||||
|
server.enqueue(MockResponse().setHeadersDelay(11, java.util.concurrent.TimeUnit.SECONDS).setBody("""{"tables":{},"processes":{},"widgets":{},"apps":{}}"""))
|
||||||
|
val origin = server.url("/").toString()
|
||||||
|
val repository = NetworkQQQRepository(origin)
|
||||||
|
repository.setEnvironment(Environment("test", origin))
|
||||||
|
val instance = repository.getMetaData("qqq-android", "1.0", "app", "1.0")
|
||||||
|
assertTrue(instance.tables!!.isEmpty())
|
||||||
|
server.takeRequest()
|
||||||
|
assertEquals("/qqq/v1/metaData", server.takeRequest().requestUrl!!.encodedPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** A metadata timeout names what timed out and the URL, without any query values, tokens or cookies.
|
||||||
|
*******************************************************************************/
|
||||||
|
@Test
|
||||||
|
fun metaDataTimeoutNamesTheMetadataLoadAndUrlWithoutSecrets() = runBlocking() {
|
||||||
|
MockWebServer().use { server ->
|
||||||
|
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
|
||||||
|
server.enqueue(MockResponse().setHeadersDelay(3, java.util.concurrent.TimeUnit.SECONDS).setBody("{}"))
|
||||||
|
val origin = server.url("/").toString()
|
||||||
|
val repository = NetworkQQQRepository(origin, metaDataTimeoutMillis = 500)
|
||||||
|
repository.setEnvironment(Environment("test", origin))
|
||||||
|
repository.setSessionUUID("fixture-session-value")
|
||||||
|
val failure = runCatching { repository.getMetaData("qqq-android", "1.0", "app", "1.0") }.exceptionOrNull()
|
||||||
|
assertTrue(failure is java.io.IOException)
|
||||||
|
val message = failure!!.message!!
|
||||||
|
assertTrue(message, message.startsWith("Timed out after 0.5s loading the server's metadata from "))
|
||||||
|
assertTrue(message, message.endsWith(origin.trimEnd('/') + "/qqq/v1/metaData"))
|
||||||
|
assertFalse(message.contains("fixture-session-value"))
|
||||||
|
assertFalse(message.contains("?"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** Only metadata gets the long limit: other calls keep the short default and still fail fast.
|
||||||
|
*******************************************************************************/
|
||||||
|
@Test
|
||||||
|
fun otherCallsStillUseTheShortDefaultTimeout() = runBlocking() {
|
||||||
|
MockWebServer().use { server ->
|
||||||
|
server.enqueue(MockResponse().setBody(VERSION_FIXTURE))
|
||||||
|
server.enqueue(MockResponse().setHeadersDelay(12, java.util.concurrent.TimeUnit.SECONDS).setBody("""{"records":[]}"""))
|
||||||
|
val origin = server.url("/").toString()
|
||||||
|
val repository = NetworkQQQRepository(origin)
|
||||||
|
repository.setEnvironment(Environment("test", origin))
|
||||||
|
val failure = runCatching { repository.queryRecords("items", "id", 0, 10) }.exceptionOrNull()
|
||||||
|
assertTrue(failure is java.io.InterruptedIOException)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user