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
@@ -43,6 +43,8 @@ Primary sources used: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/{Parser,T
|
|||||||
|
|
||||||
## 3. Literals and Core Expressions
|
## 3. Literals and Core Expressions
|
||||||
- Scalars: `null`, `true`, `false`, `void`.
|
- Scalars: `null`, `true`, `false`, `void`.
|
||||||
|
- List literals allow multiline layout, comments between entries and trailing commas.
|
||||||
|
Newlines before the closing `]` are whitespace, including in nested and empty lists.
|
||||||
- List literal: `[a, b, c]`, spreads with `...`.
|
- List literal: `[a, b, c]`, spreads with `...`.
|
||||||
- Spread positions: beginning, middle, end are all valid: `[...a]`, `[0, ...a, 4]`, `[head, ...mid, tail]`.
|
- Spread positions: beginning, middle, end are all valid: `[...a]`, `[0, ...a, 4]`, `[head, ...mid, tail]`.
|
||||||
- Spread source must be a `List` at runtime (non-list spread raises an error).
|
- Spread source must be a `List` at runtime (non-list spread raises an error).
|
||||||
|
|||||||
@@ -194,3 +194,17 @@ val allowLocalOnly = object : HttpAccessPolicy {
|
|||||||
- **Windows native:** supported via the Ktor WinHttp client backend
|
- **Windows native:** supported via the Ktor WinHttp client backend
|
||||||
- **Apple native:** supported via the Ktor Darwin client backend
|
- **Apple native:** supported via the Ktor Darwin client backend
|
||||||
- **Other targets:** may report unsupported; use `Http.isSupported()` before relying on it
|
- **Other targets:** may report unsupported; use `Http.isSupported()` before relying on it
|
||||||
|
|
||||||
|
## Request failure diagnostics
|
||||||
|
|
||||||
|
Transport exceptions report the request method and target, a category, the
|
||||||
|
underlying exception/cause chain, and recovery advice. Query strings and userinfo
|
||||||
|
are redacted from the diagnostic target. Categories are `HTTP_TLS_FAILED`,
|
||||||
|
`HTTP_TIMEOUT`, `HTTP_DNS_FAILED`, `HTTP_CONNECTION_FAILED` and
|
||||||
|
`HTTP_REQUEST_FAILED`. Permission denials are `HTTP_ACCESS_DENIED`; redirect
|
||||||
|
limit errors retain `HTTP_REDIRECT_LIMIT_EXCEEDED`.
|
||||||
|
|
||||||
|
`HTTP_TLS_FAILED` means the handshake failed before an HTTP response. Do not
|
||||||
|
retry the same URL unchanged; use another source. A remote TLS fatal alert is
|
||||||
|
not a Lyng syntax error. Non-2xx responses remain ordinary response objects, and
|
||||||
|
request cancellation propagates rather than becoming a generic HTTP error.
|
||||||
@@ -29,7 +29,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group = "net.sergeych"
|
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> {
|
private fun Project.sqliteLinuxLinkerOpts(vararg defaultDirs: String): List<String> {
|
||||||
val overrideDir = providers.gradleProperty("sqlite3.lib.dir").orNull
|
val overrideDir = providers.gradleProperty("sqlite3.lib.dir").orNull
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
|
|
||||||
package net.sergeych.lyng.io.http
|
package net.sergeych.lyng.io.http
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
|
||||||
import net.sergeych.lyng.ModuleScope
|
import net.sergeych.lyng.ModuleScope
|
||||||
import net.sergeych.lyng.Scope
|
import net.sergeych.lyng.Scope
|
||||||
import net.sergeych.lyng.ScopeFacade
|
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 {
|
private suspend inline fun ScopeFacade.httpGuard(crossinline block: suspend () -> Obj): Obj {
|
||||||
return try {
|
return try {
|
||||||
block()
|
block()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: HttpAccessDeniedException) {
|
} catch (e: HttpAccessDeniedException) {
|
||||||
raiseIllegalOperation(e.reasonDetail ?: "http access denied")
|
raiseIllegalOperation("HTTP_ACCESS_DENIED: " + (e.reasonDetail ?: "http access denied"))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
raiseIllegalOperation(e.message ?: "http error")
|
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.HttpClientEngineConfig
|
||||||
import io.ktor.client.engine.HttpClientEngineFactory
|
import io.ktor.client.engine.HttpClientEngineFactory
|
||||||
import io.ktor.client.plugins.timeout
|
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.HttpRequestBuilder
|
||||||
import io.ktor.client.request.request
|
import io.ktor.client.request.request
|
||||||
import io.ktor.client.request.setBody
|
import io.ktor.client.request.setBody
|
||||||
@@ -25,6 +27,7 @@ private class KtorLyngHttpEngine(
|
|||||||
HttpClient(engineFactory) {
|
HttpClient(engineFactory) {
|
||||||
expectSuccess = false
|
expectSuccess = false
|
||||||
followRedirects = false
|
followRedirects = false
|
||||||
|
install(HttpTimeout)
|
||||||
install(CappedHttpRedirect) {
|
install(CappedHttpRedirect) {
|
||||||
maxRedirects = config.maxRedirects
|
maxRedirects = config.maxRedirects
|
||||||
}
|
}
|
||||||
@@ -39,15 +42,22 @@ private class KtorLyngHttpEngine(
|
|||||||
throw UnsupportedOperationException(it.message ?: "HTTP client is not supported")
|
throw UnsupportedOperationException(it.message ?: "HTTP client is not supported")
|
||||||
}
|
}
|
||||||
|
|
||||||
val response = httpClient.request {
|
try {
|
||||||
applyRequest(request)
|
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) {
|
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
|
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 {
|
extern object Http {
|
||||||
/* True when HTTP client support is available on this runtime. */
|
/* True when HTTP client support is available on this runtime. */
|
||||||
fun isSupported(): Bool
|
fun isSupported(): Bool
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
|||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
group = "net.sergeych"
|
group = "net.sergeych"
|
||||||
version = "1.5.10"
|
version = providers.gradleProperty("lyng.version").getOrElse("1.5.10")
|
||||||
|
|
||||||
// Removed legacy buildscript classpath declarations; plugins are applied via the plugins DSL below
|
// Removed legacy buildscript classpath declarations; plugins are applied via the plugins DSL below
|
||||||
|
|
||||||
|
|||||||
@@ -4021,6 +4021,7 @@ class Compiler(
|
|||||||
while (true) {
|
while (true) {
|
||||||
val t = cc.next()
|
val t = cc.next()
|
||||||
when (t.type) {
|
when (t.type) {
|
||||||
|
Token.Type.NEWLINE, Token.Type.SINGLE_LINE_COMMENT, Token.Type.MULTILINE_COMMENT -> continue
|
||||||
Token.Type.COMMA -> {
|
Token.Type.COMMA -> {
|
||||||
// todo: check commas sequences like [,] [,,] before, after or instead of expressions
|
// todo: check commas sequences like [,] [,,] before, after or instead of expressions
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import net.sergeych.lyng.Script
|
||||||
|
import kotlin.test.*
|
||||||
|
|
||||||
|
class MultilineListLiteralTest {
|
||||||
|
@Test fun finalNewlineCommentsAndTrailingCommaAreWhitespace() = runTest {
|
||||||
|
val scope = Script.newScope()
|
||||||
|
for (source in listOf(
|
||||||
|
"[\n \"first\",\n \"second\"\n]",
|
||||||
|
"[\n \"first\",\n \"second\",\n]",
|
||||||
|
"[\n // before\n \"first\", /* between */\n \"second\" // after\n]",
|
||||||
|
)) assertEquals("[first,second]", scope.eval(source).toString(scope).value)
|
||||||
|
}
|
||||||
|
@Test fun nestedAndEmptyMultilineListsAndExpressionsRemainValid() = runTest {
|
||||||
|
val scope = Script.newScope()
|
||||||
|
assertEquals("[]", scope.eval("[\n]").toString(scope).value)
|
||||||
|
assertEquals("[[1,2],[3,4]]", scope.eval("[\n [1,2],\n [3,4]\n]").toString(scope).value)
|
||||||
|
assertEquals("[3,7]", scope.eval("[\n 1+2,\n 3+4\n]").toString(scope).value)
|
||||||
|
assertFails { scope.eval("[\n @\n]") }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user