diff --git a/README.md b/README.md index 0b6f2e9..7933138 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ All settings live in `appsettings.json`. | `App:AllowedOrigins` | `[]` | Browser origins allowed to call the server. Requests carrying any other `Origin` header are rejected. | | `App:AllowUnauthenticated` | `false` | Allow listening beyond localhost without `AuthToken`, for when a proxy in front authenticates. | | `App:MaxRows` | `1000` | Maximum rows returned per query, and tables per `list_tables`. Response includes `"truncated": true` when the limit is hit. | -| `App:QueryTimeoutSeconds` | `30` | Per-query timeout in seconds. Also how long a query waits for a free slot when `MaxConcurrentQueries` are already running. | +| `App:QueryTimeoutSeconds` | `30` | Per-query timeout in seconds; a query that runs longer fails with "Query timed out after N s". Also how long a query waits for a free slot when `MaxConcurrentQueries` are already running. | | `App:MaxValueLength` | `10000` | Longest string returned for a single value, in characters. Longer text and binary values are cut off and marked `… [truncated: N characters in total]`. | | `App:MaxResultBytes` | `4000000` | Approximate size limit for one query's result. Rows stop being added once it's reached, and the response says `"truncated": true`. | | `App:MaxConcurrentQueries` | `8` | Queries that may run at once against each database. Further queries wait up to `QueryTimeoutSeconds`, then fail with "Database 'x' is busy". | diff --git a/Services/QueryService.cs b/Services/QueryService.cs index 575546a..ffd7d25 100644 --- a/Services/QueryService.cs +++ b/Services/QueryService.cs @@ -300,6 +300,10 @@ private async Task> RunWithRetryAsync(DbConfig db, Func> RunWithRetryAsync(DbConfig db, Func ex switch + { + OperationCanceledException or TimeoutException => true, + Npgsql.NpgsqlException { InnerException: TimeoutException } => true, + Microsoft.Data.SqlClient.SqlException { Number: -2 } => true, + MySqlException { ErrorCode: MySqlErrorCode.CommandTimeoutExpired } => true, + OracleException { Number: 1013 } => true, + _ => false, + }; + private async Task> RunOnceAsync(DbConfig db, Func, Task> work, CancellationToken ct) { await using var conn = db.GetFactory().CreateConnection()!; diff --git a/tests/SqlMcp.Tests/QueryServiceIntegrationTests.cs b/tests/SqlMcp.Tests/QueryServiceIntegrationTests.cs index 6256963..3126136 100644 --- a/tests/SqlMcp.Tests/QueryServiceIntegrationTests.cs +++ b/tests/SqlMcp.Tests/QueryServiceIntegrationTests.cs @@ -765,6 +765,21 @@ public async Task Returns_awkward_values_as_json(DbProvider provider, string sql Assert.Contains(expected, json); } + [SkippableTheory] + [InlineData(DbProvider.Postgres, "SELECT pg_sleep(10) AS s")] + [InlineData(DbProvider.MySql, "SELECT COUNT(*) AS n FROM information_schema.columns a, information_schema.columns b, information_schema.columns c")] + [InlineData(DbProvider.SqlServer, "WITH n AS (SELECT CAST(1 AS bigint) AS i UNION ALL SELECT i + 1 FROM n WHERE i < 100000000) SELECT MAX(i) AS m FROM n OPTION (MAXRECURSION 0)")] + [InlineData(DbProvider.Oracle, "SELECT COUNT(*) AS n FROM all_objects a, all_objects b, all_objects c")] + public async Task Says_when_a_query_ran_past_the_timeout(DbProvider provider, string sql) + { + var db = RequireDb(provider); + var svc = Service(db, settings: new() { ["App:QueryTimeoutSeconds"] = "1" }); + + var result = await svc.ExecuteAsync(new("test", sql)); + + Assert.Equal("Query timed out after 1 s (App:QueryTimeoutSeconds). Narrow it with a WHERE clause or an index, or ask for fewer rows.", result.Error); + } + [SkippableFact] public async Task Rejects_queries_beyond_the_concurrency_limit() {