Fix multiline list parsing and expose actionable HTTP failures
This commit is contained in:
1 parent
8b0d847776
commit
66ab7db3d8
11 files changed
+131
-12
No files matched your search
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user