Fix multiline list parsing and expose actionable HTTP failures

This commit is contained in:
sergeych committed 2026-10-13 02:19:56 +04:00
1 parent 8b0d847776
commit 66ab7db3d8
11 files changed
+131 -12

No files matched your search

+1 -1
View File
@@ -29,7 +29,7 @@ plugins {
}
group = "net.sergeych"
version = "0.0.1-SNAPSHOT"
version = providers.gradleProperty("lyngio.version").getOrElse("0.0.1-SNAPSHOT")
private fun Project.sqliteLinuxLinkerOpts(vararg defaultDirs: String): List<String> {
val overrideDir = providers.gradleProperty("sqlite3.lib.dir").orNull
@@ -17,6 +17,8 @@
package net.sergeych.lyng.io.http
import kotlinx.coroutines.CancellationException
import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Scope
import net.sergeych.lyng.ScopeFacade
@@ -164,8 +166,10 @@ private suspend fun buildHttpModule(module: ModuleScope, policy: HttpAccessPolic
private suspend inline fun ScopeFacade.httpGuard(crossinline block: suspend () -> Obj): Obj {
return try {
block()
} catch (e: CancellationException) {
throw e
} catch (e: HttpAccessDeniedException) {
raiseIllegalOperation(e.reasonDetail ?: "http access denied")
raiseIllegalOperation("HTTP_ACCESS_DENIED: " + (e.reasonDetail ?: "http access denied"))
} catch (e: Exception) {
raiseIllegalOperation(e.message ?: "http error")
}
@@ -0,0 +1,34 @@
package net.sergeych.lyngio.http
/** Transport diagnostics, preserved by the Lyng bridge instead of a generic "http error". */
class LyngHttpRequestException(request: LyngHttpRequest, cause: Throwable) :
IllegalStateException(describeHttpFailure(request, cause), cause)
internal fun describeHttpFailure(request: LyngHttpRequest, cause: Throwable): String {
val causes = mutableListOf<Throwable>()
var current: Throwable? = cause
repeat(8) {
val item = current ?: return@repeat
if (causes.any { it === item }) return@repeat
causes += item
current = item.cause
}
val details = causes.joinToString(" <- ") { "${it::class.simpleName ?: "Exception"}: ${it.message ?: "no detail"}" }
val text = details.lowercase()
val (code, advice) = when {
"http_redirect_limit_exceeded" in text -> "HTTP_REDIRECT_LIMIT_EXCEEDED" to
"Do not retry this URL unchanged; inspect the redirect chain or use another source."
"tls" in text || "ssl" in text || "handshake" in text || "certificate" in text -> "HTTP_TLS_FAILED" to
"The HTTPS handshake failed before an HTTP response. Do not retry this URL unchanged; changing its path/query will not repair TLS. Use another HTTPS host or source."
"timeout" in text || "timed out" in text -> "HTTP_TIMEOUT" to
"The request exceeded a time limit. At most a bounded retry may help; avoid repeating it indefinitely."
"unknownhost" in text || "unresolvedaddress" in text || "name or service not known" in text -> "HTTP_DNS_FAILED" to
"The hostname could not be resolved. Verify the hostname or use another source."
"connectexception" in text || "connection refused" in text || "connection reset" in text -> "HTTP_CONNECTION_FAILED" to
"The connection failed before a usable response. Verify the endpoint; avoid repeated unchanged requests."
else -> "HTTP_REQUEST_FAILED" to "Use the cause details to choose another source or a different approach."
}
// Query strings and userinfo can contain credentials; the script already knows the full URL.
val target = request.url.substringBefore('?').substringBefore('#').replace(Regex("//[^/@]+@"), "//[redacted]@")
return "$code: ${request.method.uppercase()} $target\nCause: $details\n$advice"
}
@@ -5,6 +5,8 @@ import io.ktor.client.call.body
import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.engine.HttpClientEngineFactory
import io.ktor.client.plugins.timeout
import io.ktor.client.plugins.HttpTimeout
import kotlinx.coroutines.CancellationException
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.request
import io.ktor.client.request.setBody
@@ -25,6 +27,7 @@ private class KtorLyngHttpEngine(
HttpClient(engineFactory) {
expectSuccess = false
followRedirects = false
install(HttpTimeout)
install(CappedHttpRedirect) {
maxRedirects = config.maxRedirects
}
@@ -39,15 +42,22 @@ private class KtorLyngHttpEngine(
throw UnsupportedOperationException(it.message ?: "HTTP client is not supported")
}
val response = httpClient.request {
applyRequest(request)
try {
val response = httpClient.request { applyRequest(request) }
return LyngHttpResponse(
status = response.status.value,
statusText = response.status.description,
headers = response.headers.entries().associate { it.key to it.value.toList() },
bodyBytes = response.body<ByteArray>(),
)
} catch (e: CancellationException) {
throw e
} catch (e: HttpRedirectLimitExceededException) {
// Keep the existing typed redirect-limit contract for host callers.
throw e
} catch (e: Exception) {
throw LyngHttpRequestException(request, e)
}
return LyngHttpResponse(
status = response.status.value,
statusText = response.status.description,
headers = response.headers.entries().associate { it.key to it.value.toList() },
bodyBytes = response.body<ByteArray>(),
)
}
private fun HttpRequestBuilder.applyRequest(request: LyngHttpRequest) {
@@ -0,0 +1,25 @@
package net.sergeych.lyngio.http
import kotlin.test.*
class HttpRequestFailureTest {
private val request = LyngHttpRequest("GET", "https://user:secret@example.com/page?key=private#fragment")
@Test fun savedFatalTlsAlertGetsSpecificCauseAndNoUnchangedRetryAdvice() {
val saved = IllegalStateException("Received alert during handshake. Level: FATAL, code: InternalError")
val message = LyngHttpRequestException(request, saved).message!!
assertTrue(message.startsWith("HTTP_TLS_FAILED: GET https://[redacted]@example.com/page"))
assertTrue(message.contains("FATAL, code: InternalError"))
assertTrue(message.contains("Do not retry this URL unchanged"))
assertTrue(message.contains("changing its path/query will not repair TLS"))
assertFalse(message.contains("secret")); assertFalse(message.contains("private"))
}
@Test fun nestedCausesAndTimeoutDnsAndConnectionFailuresRemainDistinct() {
val nested = IllegalStateException("request failed", IllegalArgumentException("TLS certificate rejected"))
assertTrue(describeHttpFailure(request, nested).contains("HTTP_TLS_FAILED"))
for ((detail, code) in listOf("request timed out" to "HTTP_TIMEOUT",
"UnknownHostException: example.com" to "HTTP_DNS_FAILED",
"Connection refused" to "HTTP_CONNECTION_FAILED")) {
assertTrue(describeHttpFailure(request, IllegalStateException(detail)).startsWith(code))
}
}
}
+9 -1
View File
@@ -32,7 +32,15 @@ extern class HttpResponse {
fun bytes(): Buffer
}
/* HTTP/HTTPS client singleton. */
/*
HTTP/HTTPS client singleton.
Transport failures include a diagnostic code, request method/URL, underlying cause,
and recovery advice: HTTP_TLS_FAILED, HTTP_TIMEOUT, HTTP_DNS_FAILED,
HTTP_CONNECTION_FAILED, HTTP_REQUEST_FAILED. HTTP_ACCESS_DENIED means no grant.
HTTP_TLS_FAILED occurs before any response: do not retry the URL unchanged;
use another source. Query strings/userinfo are redacted from the diagnostic target.
Non-2xx HTTP statuses are returned normally. Cancellation propagates to the host.
*/
extern object Http {
/* True when HTTP client support is available on this runtime. */
fun isSupported(): Bool