From 4467d8ec64c95d525f56352a3be575aad9052a21 Mon Sep 17 00:00:00 2001 From: James Maes Date: Mon, 9 Nov 2026 14:01:07 -0600 Subject: [PATCH] fix(core): allow 60 s for the metadata load and name the URL on timeout GET /qqq/v1/metaData returns about 1.7 MB and takes the server 4 to 8 s to build; the default 10 s read timeout surfaced as 'Error loading QInstance: timeout' on handhelds. Only the metadata call gets the long limit. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 5 ++ .../core/controllers/NetworkQQQRepository.kt | 47 +++++++++++++--- .../controllers/NetworkQQQRepositoryTest.kt | 56 +++++++++++++++++++ 3 files changed, 99 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f9f49f..bca077e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 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 /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 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. diff --git a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt index 48e5087..8420f49 100644 --- a/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt +++ b/qqq/android/core/src/main/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepository.kt @@ -44,18 +44,30 @@ import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONObject import retrofit2.Retrofit +import java.io.IOException +import java.io.InterruptedIOException import java.util.TimeZone 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 = { 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 = { emptyMap() }) : QQQRepository { private val sessionUUIDs = ConcurrentHashMap() private val retroJson = Json { ignoreUnknownKeys = true } private val compatibilityByServer = ConcurrentHashMap() private val qqqApiServiceMap: MutableMap = mutableMapOf() + private val metaDataApiServiceMap: MutableMap = mutableMapOf() /******************************************************************************* *******************************************************************************/ @@ -63,9 +75,13 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs baseUrl = environment.baseUrl if (qqqApiServiceMap[baseUrl] == null) { - val thisRetrofit = buildRetrofit(baseUrl) - val thisQQQApiService = thisRetrofit.create(QQQApiService::class.java) - qqqApiServiceMap[baseUrl] = thisQQQApiService + val client = buildClient(baseUrl) + qqqApiServiceMap[baseUrl] = buildRetrofit(baseUrl, client).create(QQQApiService::class.java) + val metaDataClient = client.newBuilder() + .callTimeout(metaDataTimeoutMillis, TimeUnit.MILLISECONDS) + .readTimeout(metaDataTimeoutMillis, TimeUnit.MILLISECONDS) + .build() + metaDataApiServiceMap[baseUrl] = buildRetrofit(baseUrl, metaDataClient).create(QQQApiService::class.java) } // 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() .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() .followRedirects(false) .followSslRedirects(false) @@ -110,8 +135,6 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs .addInterceptor(SameOriginInterceptor()) .build() ) - .baseUrl(baseUrl) - .build() } /******************************************************************************* @@ -309,7 +332,13 @@ class NetworkQQQRepository(private var baseUrl: String, @Volatile var requestObs override suspend fun getMetaData( frontendName: String, frontendVersion: String, applicationName: String, applicationVersion: String ): 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) + } } /******************************************************************************* diff --git a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt index 5a61098..b9a4a85 100644 --- a/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt +++ b/qqq/android/core/src/test/java/com/kingsrook/qqq/frontend/android/core/controllers/NetworkQQQRepositoryTest.kt @@ -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) + } + } } -- 2.54.0