Skip to content

Commit ff653fd

Browse files
committed
feat(model-server)!: use a consistent status code for query failures
When a modelql query fails due to a mismatch between what's expected by the query and the underlying data, always returns the same status code. Before, most cases resulted in a 500 internal server, which in turn triggered the default retry mechanism of the modelql client. This leads to unnecessary amounts of queries on the server and slows down error reports on the client side. I have chosen 422 here. 5xx status codes are reserved for actual application failures instead of business logic errors. 404 would be too specific. 422 looks like it covers all kinds of errors related to being unable to "process" an otherwise valid query successfully. BREAKING CHANGE: resolving a non-existing node via ModelQL now returns status code 422 instead of 404.
1 parent d04c1d7 commit ff653fd

File tree

5 files changed

+63
-23
lines changed

5 files changed

+63
-23
lines changed

model-server-openapi/specifications/model-server-v2.yaml

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -322,8 +322,8 @@ paths:
322322
responses:
323323
"200":
324324
$ref: '#/components/responses/200json'
325-
"404":
326-
$ref: '#/components/responses/modelql-404'
325+
"422":
326+
$ref: '#/components/responses/ModelQlQueryExecutionFailed'
327327
default:
328328
$ref: '#/components/responses/GeneralError'
329329
/repositories/{repository}/init:
@@ -429,8 +429,8 @@ paths:
429429
responses:
430430
"200":
431431
$ref: '#/components/responses/200json'
432-
"404":
433-
$ref: '#/components/responses/modelql-404'
432+
"422":
433+
$ref: '#/components/responses/ModelQlQueryExecutionFailed'
434434
default:
435435
$ref: '#/components/responses/GeneralError'
436436

@@ -503,8 +503,16 @@ components:
503503
application/problem+json:
504504
schema:
505505
$ref: 'problem.yaml#/Problem'
506-
"modelql-404":
507-
description: "A IProducingStep<INodeReference>.resolve() call failed to find the node."
506+
"ModelQlQueryExecutionFailed":
507+
description: "A syntactically valid query failed to provide a result due to a mismatch of the query and the underlying data."
508+
content:
509+
text/plain:
510+
schema:
511+
type: string
512+
description: "Contains a technical error description why query execution failed."
513+
example: |-
514+
server version: 0.0.0
515+
Exception in thread "main" java.lang.IllegalArgumentException: At least one element was expected. it<1004>.typed().flatMap { ...
508516
GeneralError:
509517
description: Unexpected error
510518
content:

modelql-client/src/jvmTest/kotlin/org/modelix/modelql/client/ModelQLClientTest.kt

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import io.ktor.server.testing.testApplication
77
import kotlinx.coroutines.withTimeout
88
import org.junit.jupiter.api.assertThrows
99
import org.modelix.model.api.ConceptReference
10+
import org.modelix.model.api.IChildLinkReference
1011
import org.modelix.model.api.IConceptReference
1112
import org.modelix.model.api.INode
1213
import org.modelix.model.api.NodeReference
@@ -17,6 +18,7 @@ import org.modelix.model.lazy.CLTree
1718
import org.modelix.model.lazy.ObjectStoreCache
1819
import org.modelix.model.persistent.MapBaseStore
1920
import org.modelix.modelql.core.IFluxUnboundQuery
21+
import org.modelix.modelql.core.assertNotEmpty
2022
import org.modelix.modelql.core.buildFluxQuery
2123
import org.modelix.modelql.core.contains
2224
import org.modelix.modelql.core.count
@@ -262,8 +264,8 @@ class ModelQLClientTest {
262264
it.memoize { n ->
263265
n.count()
264266
.sum(
265-
n.allChildren().fold(0) { acc, it ->
266-
acc.sum(it.mapRecursive())
267+
n.allChildren().fold(0) { acc, child ->
268+
acc.sum(child.mapRecursive())
267269
},
268270
)
269271
}
@@ -272,13 +274,35 @@ class ModelQLClientTest {
272274
}
273275

274276
@Test
275-
fun `resolving a non-existing node reference returns 404`() = runTest { httpClient ->
277+
fun `resolving a non-existing node reference returns 422`() = runTest { httpClient ->
276278
val client = ModelQLClient("http://localhost/query", httpClient)
277279
val ex = assertThrows<ModelQueryRequestException> {
278280
client.query<INode> {
279281
NodeReference("doesnotexist").asMono().resolve()
280282
}
281283
}
282-
assertEquals(HttpStatusCode.NotFound, ex.httpResponse.status)
284+
assertEquals(HttpStatusCode.UnprocessableEntity, ex.httpResponse.status)
285+
}
286+
287+
@Test
288+
fun `failed assertions in queries return 422`() = runTest { httpClient ->
289+
val client = ModelQLClient("http://localhost/query", httpClient)
290+
val ex = assertThrows<ModelQueryRequestException> {
291+
client.query<INode> {
292+
it.children(IChildLinkReference.fromName("test")).first().assertNotEmpty()
293+
}
294+
}
295+
assertEquals(HttpStatusCode.UnprocessableEntity, ex.httpResponse.status)
296+
}
297+
298+
@Test
299+
fun `empty query results return 422`() = runTest { httpClient ->
300+
val client = ModelQLClient("http://localhost/query", httpClient)
301+
val ex = assertThrows<ModelQueryRequestException> {
302+
client.query<INode> {
303+
it.children(IChildLinkReference.fromName("test")).first()
304+
}
305+
}
306+
assertEquals(HttpStatusCode.UnprocessableEntity, ex.httpResponse.status)
283307
}
284308
}

modelql-core/src/commonMain/kotlin/org/modelix/modelql/core/Query.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,15 @@ private abstract class BoundQuery<In, out AggregationOut, out ElementOut>(val ex
6262
}
6363
}
6464

65+
class EmptyQueryResultException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
66+
6567
private class MonoBoundQuery<In, Out>(executor: IQueryExecutor<In>, override val query: MonoUnboundQuery<In, Out>) : BoundQuery<In, Out, Out>(executor), IMonoQuery<Out> {
6668

6769
override suspend fun execute(): IStepOutput<Out> {
6870
try {
6971
return executor.createStream(query).exactlyOne().getSuspending()
7072
} catch (ex: NoSuchElementException) {
71-
throw RuntimeException("Empty query result: " + this, ex)
73+
throw EmptyQueryResultException("Empty query result: $this", ex)
7274
}
7375
}
7476

@@ -170,7 +172,7 @@ class MonoUnboundQuery<In, ElementOut>(
170172
try {
171173
return asStream(evaluationContext, input.asObservable()).exactlyOne().getSynchronous()
172174
} catch (ex: NoSuchElementException) {
173-
throw RuntimeException("Empty query result: " + this, ex)
175+
throw EmptyQueryResultException("Empty query result: $this", ex)
174176
}
175177
}
176178

modelql-server/src/main/kotlin/org/modelix/modelql/server/ModelQLServer.kt

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import kotlinx.serialization.KSerializer
1313
import org.modelix.model.api.INode
1414
import org.modelix.model.api.UnresolvableNodeReferenceException
1515
import org.modelix.model.area.IArea
16+
import org.modelix.modelql.core.EmptyQueryResultException
1617
import org.modelix.modelql.core.IMonoUnboundQuery
1718
import org.modelix.modelql.core.IStepOutput
1819
import org.modelix.modelql.core.MODELIX_VERSION
@@ -22,6 +23,7 @@ import org.modelix.modelql.core.VersionAndData
2223
import org.modelix.modelql.core.upcast
2324
import org.modelix.modelql.untyped.UntypedModelQL
2425
import org.modelix.modelql.untyped.createQueryExecutor
26+
import org.modelix.streams.StreamAssertionError
2527
import kotlin.system.measureTimeMillis
2628

2729
class ModelQLServer private constructor(val rootNodeProvider: () -> INode?, val area: IArea? = null) {
@@ -101,17 +103,19 @@ class ModelQLServer private constructor(val rootNodeProvider: () -> INode?, val
101103
val serializedResult = json.encodeToString(VersionAndData.serializer(serializer), versionAndResult)
102104
afterQueryExecution()
103105
call.respondText(text = serializedResult, contentType = ContentType.Application.Json)
104-
} catch (ex: UnresolvableNodeReferenceException) {
105-
afterQueryExecution()
106-
call.respondText(
107-
text = "server version: $MODELIX_VERSION\n" + ex.stackTraceToString(),
108-
status = HttpStatusCode.NotFound,
109-
)
110-
} catch (ex: Throwable) {
106+
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
111107
afterQueryExecution()
108+
val statusCode = when (ex) {
109+
is UnresolvableNodeReferenceException,
110+
is StreamAssertionError,
111+
is EmptyQueryResultException,
112+
-> HttpStatusCode.UnprocessableEntity
113+
114+
else -> HttpStatusCode.InternalServerError
115+
}
112116
call.respondText(
113117
text = "server version: $MODELIX_VERSION\n" + ex.stackTraceToString(),
114-
status = HttpStatusCode.InternalServerError,
118+
status = statusCode,
115119
)
116120
}
117121
}

streams/src/commonMain/kotlin/org/modelix/streams/StreamExtensions.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ import com.badoo.reaktive.single.map
3434
import com.badoo.reaktive.single.subscribe
3535
import kotlinx.coroutines.flow.single
3636

37+
class StreamAssertionError(message: String) : IllegalArgumentException(message)
38+
3739
fun Observable<*>.count(): Single<Int> = collect({ arrayOf(0) }) { acc, it -> acc[0]++ }.map { it[0] }
3840
fun <T, R> Observable<T>.fold(initial: R, operation: (R, T) -> R): Single<R> {
3941
return collect({ mutableListOf(initial) }) { acc, it -> acc[0] = operation(acc[0], it) }.map { it[0] }
@@ -51,13 +53,13 @@ fun <T> Observable<T>.withIndex(): Observable<IndexedValue<T>> {
5153
return map { IndexedValue(index++, it) }
5254
}
5355
fun <T> Observable<T>.assertNotEmpty(message: () -> String): Observable<T> {
54-
return this.switchIfEmpty { throw IllegalArgumentException("At least one element was expected. " + message()) }
56+
return this.switchIfEmpty { throw StreamAssertionError("At least one element was expected. xxx " + message()) }
5557
}
5658
fun <T> Maybe<T>.assertEmpty(message: (T) -> String): Completable {
57-
return map { throw IllegalArgumentException(message(it)) }.asCompletable()
59+
return map { throw StreamAssertionError(message(it)) }.asCompletable()
5860
}
5961
fun <T> Observable<T>.assertEmpty(message: (T) -> String): Completable {
60-
return map { throw IllegalArgumentException(message(it)) }.asCompletable()
62+
return map { throw StreamAssertionError(message(it)) }.asCompletable()
6163
}
6264
fun <T> Single<T>.cached(): Single<T> {
6365
return this.asObservable().replay(1).autoConnect()

0 commit comments

Comments
 (0)