Harden current-information search failures
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Preserve required-search semantics for live hints and expose stable gateway error codes without leaking provider details.
This commit is contained in:
Rocky
2026-08-22 22:43:16 +08:00
parent 4f5b6eafbd
commit 03eac71905
16 changed files with 277 additions and 45 deletions
+1
View File
@@ -69,6 +69,7 @@ export interface AIHintCard {
locale: "zh" | "en";
conditions: string[];
metadata?: Record<string, unknown>;
taskKind?: "ai_question" | "current_information_question";
}
export interface AdminHintPack {
+11 -2
View File
@@ -480,8 +480,10 @@ paths:
and output-budget policy from `capability` plus optional `taskKind`. It
never infers task type from `input` or `context`, and clients cannot
supply provider parameters. Ordinary AI questions allow model-selected
server-side web search; current-information questions require it. Other
tools remain disabled. Search has no separate credit fee; settlement
server-side web search and retain thinking if search is unavailable.
Current-information questions require search and never degrade to an
unverified offline answer. Other tools remain disabled. Search has no
separate credit fee; settlement
uses the provider-reported LLM input and output Token counts, including
any search context charged by the provider.
For account grants, `oobe` is accepted only for dictation polish and the
@@ -1785,6 +1787,13 @@ components:
type: array
maxItems: 20
items: { type: string, minLength: 1, maxLength: 64 }
taskKind:
type: string
enum: [ai_question, current_information_question]
default: ai_question
description: |
AI execution intent for this card. Current-information cards require
server-side web search; ordinary cards match hold-to-talk AI policy.
metadata:
type: object
additionalProperties: true
@@ -2,6 +2,7 @@ package com.osglab.account.features.content.feed.sources
import com.osglab.account.features.content.feed.HintFeedSettings
import com.osglab.account.features.content.models.AIHintCardDto
import com.osglab.account.features.content.models.AIHintTaskKind
import kotlinx.serialization.json.buildJsonObject
class BaselineHintSource : HintFeedSource {
@@ -31,6 +32,7 @@ private val CHINESE = listOf(
category = "economy",
priority = 42,
locale = "zh",
taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION,
),
card(
id = "cap-zh-clipboard-reply",
@@ -68,6 +70,7 @@ private val ENGLISH = listOf(
category = "economy",
priority = 42,
locale = "en",
taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION,
),
card(
id = "cap-en-clipboard-reply",
@@ -97,6 +100,7 @@ private fun card(
priority: Int,
locale: String,
conditions: List<String> = emptyList(),
taskKind: AIHintTaskKind = AIHintTaskKind.AI_QUESTION,
) = AIHintCardDto(
id = id,
text = text,
@@ -107,4 +111,5 @@ private fun card(
locale = locale,
conditions = conditions,
metadata = buildJsonObject {},
taskKind = taskKind,
)
@@ -5,6 +5,7 @@ import com.osglab.account.features.content.feed.HintFeedSettings
import com.osglab.account.features.content.feed.csvValues
import com.osglab.account.features.content.feed.stableHintId
import com.osglab.account.features.content.models.AIHintCardDto
import com.osglab.account.features.content.models.AIHintTaskKind
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.timeout
@@ -44,6 +45,7 @@ class GoogleFeedHintSource(
priority = 66,
source = "google-trends-rss",
locale = "en",
taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION,
metadata = buildJsonObject {
put("geo", geo)
put("query", title)
@@ -65,6 +67,7 @@ class GoogleFeedHintSource(
priority = 58,
source = "google-news-rss",
locale = "en",
taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION,
metadata = buildJsonObject { put("title", title) },
)
}.take(4)
@@ -4,6 +4,7 @@ import com.osglab.account.features.content.feed.HintCardPolicy
import com.osglab.account.features.content.feed.HintFeedSettings
import com.osglab.account.features.content.feed.stableHintId
import com.osglab.account.features.content.models.AIHintCardDto
import com.osglab.account.features.content.models.AIHintTaskKind
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.timeout
@@ -73,6 +74,7 @@ class TopHubHintSource(
priority = 78,
source = "tophub-daily",
locale = "zh",
taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION,
metadata = buildJsonObject {
data.string("day")?.let { put("day", it) }
put("date", day)
@@ -106,6 +108,7 @@ class TopHubHintSource(
priority = 74,
source = "tophub-daily",
locale = "zh",
taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION,
metadata = item.metadata("title" to title, "url" to item.string("url")),
)
}
@@ -202,6 +205,7 @@ class TopHubHintSource(
priority = priority,
source = source,
locale = "zh",
taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION,
metadata = metadata,
)
@@ -5,6 +5,7 @@ import com.osglab.account.features.content.feed.HintFeedSettings
import com.osglab.account.features.content.feed.HintWeatherCity
import com.osglab.account.features.content.feed.parseWeatherCities
import com.osglab.account.features.content.models.AIHintCardDto
import com.osglab.account.features.content.models.AIHintTaskKind
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.timeout
@@ -77,6 +78,7 @@ class WeatherHintSource(
source = id,
locale = locale,
conditions = listOf("geo_optional"),
taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION,
metadata = buildJsonObject {
put("city", city.name)
put("lat", city.latitude)
@@ -87,6 +87,15 @@ data class SkillCatalogRecord(
val skills: List<OfficialSkillRecord>,
)
@Serializable
enum class AIHintTaskKind {
@SerialName("ai_question")
AI_QUESTION,
@SerialName("current_information_question")
CURRENT_INFORMATION_QUESTION,
}
@Serializable
data class AIHintCardDto(
val id: String,
@@ -99,6 +108,7 @@ data class AIHintCardDto(
val locale: String,
val conditions: List<String> = emptyList(),
val metadata: JsonObject? = null,
val taskKind: AIHintTaskKind = AIHintTaskKind.AI_QUESTION,
)
@Serializable
@@ -36,6 +36,21 @@ class ProviderCatalog(
class UnsupportedGatewayCapabilityException(capability: String) :
IllegalArgumentException("No gateway provider is configured for capability '$capability'")
/**
* Provider configuration or credentials are unavailable. The route exposes
* only a stable service error and never leaks configuration details.
*/
open class ProviderUnavailableException(message: String) : RuntimeException(message)
/**
* The provider rejected or failed an upstream request. HTTP status is retained
* only for safe error classification; provider response bodies remain private.
*/
open class ProviderUpstreamException(
message: String,
val upstreamStatus: Int? = null,
) : RuntimeException(message)
/**
* Upstream returned an invalid metering/result envelope. Gateway orchestration
* treats this as provider failure and releases the reservation.
@@ -20,6 +20,8 @@ import com.osglab.account.features.gateway.models.TextProviderRequest
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.providers.GatewayProvider
import com.osglab.account.features.gateway.providers.ProviderCompletionException
import com.osglab.account.features.gateway.providers.ProviderUnavailableException
import com.osglab.account.features.gateway.providers.ProviderUpstreamException
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.bearerAuth
@@ -244,7 +246,10 @@ class KtorDeepSeekClient(
if (!response.status.isSuccess()) {
// Consume but never log or persist a provider body.
runCatching { response.body<ByteReadChannel>().readBounded() }
throw DeepSeekProviderException("DeepSeek returned HTTP ${response.status.value}")
throw DeepSeekProviderException(
"DeepSeek returned HTTP ${response.status.value}",
response.status.value,
)
}
val expectedContentType = if (request.stream) {
ContentType.Text.EventStream
@@ -652,15 +657,18 @@ private data class ResponseFormat(
val type: String,
)
class DeepSeekConfigurationException(message: String) : IllegalStateException(message)
class DeepSeekConfigurationException(message: String) : ProviderUnavailableException(message)
class DeepSeekProviderException(message: String) : RuntimeException(message)
class DeepSeekProviderException(
message: String,
upstreamStatus: Int? = null,
) : ProviderUpstreamException(message, upstreamStatus)
class DeepSeekUsageException(message: String) : ProviderCompletionException(message)
class DeepSeekEmptyResultException(
val finishReason: String = "missing",
val reasoningContentPresent: Boolean = false,
val usagePresent: Boolean = false,
) : RuntimeException("DeepSeek returned an empty result")
) : ProviderCompletionException("DeepSeek returned an empty result")
private data class DeepSeekUsage(
val total: Long,
@@ -79,6 +79,9 @@ internal class DeepSeekSearchFallbackClient(
request.executionPolicy.webSearch.name,
failure::class.simpleName ?: "Exception",
)
if (request.executionPolicy.webSearch == GatewayWebSearchMode.REQUIRED) {
throw failure
}
return fallback.complete(
request.copy(
executionPolicy = request.executionPolicy.copy(
@@ -146,6 +149,7 @@ internal class KtorDeepSeekResponsesClient(
runCatching { response.body<ByteReadChannel>().readBounded() }
throw DeepSeekProviderException(
"DeepSeek Responses returned HTTP ${response.status.value}",
response.status.value,
)
}
val responseContentType = response.headers[HttpHeaders.ContentType]
@@ -14,6 +14,8 @@ import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.providers.GatewayProvider
import com.osglab.account.features.gateway.providers.ProviderCompletionException
import com.osglab.account.features.gateway.providers.ProviderUnavailableException
import com.osglab.account.features.gateway.providers.ProviderUpstreamException
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.http.Url
@@ -353,9 +355,9 @@ private data class RecognitionOptions(
val showUtterances: Boolean = true,
)
class VolcengineConfigurationException(message: String) : IllegalStateException(message)
class VolcengineConfigurationException(message: String) : ProviderUnavailableException(message)
class VolcengineProviderException(message: String) : RuntimeException(message)
class VolcengineProviderException(message: String) : ProviderUpstreamException(message)
class VolcengineUsageException(message: String) : ProviderCompletionException(message)
internal fun extractFinalDuration(
@@ -24,6 +24,9 @@ import com.osglab.account.features.gateway.ports.GatewayIdentityPort
import com.osglab.account.features.gateway.ports.GatewayPrincipalResolver
import com.osglab.account.features.gateway.ports.GatewayRequestAlreadyClaimedException
import com.osglab.account.features.gateway.providers.UnsupportedGatewayCapabilityException
import com.osglab.account.features.gateway.providers.ProviderCompletionException
import com.osglab.account.features.gateway.providers.ProviderUnavailableException
import com.osglab.account.features.gateway.providers.ProviderUpstreamException
import com.osglab.account.features.gateway.services.GatewayAccessDeniedException
import com.osglab.account.features.gateway.services.ComplimentaryRequestUnavailableException
import com.osglab.account.features.gateway.services.GatewayGrantService
@@ -31,6 +34,8 @@ import com.osglab.account.features.gateway.services.GatewayRefreshTokenInvalidEx
import com.osglab.account.features.gateway.services.GatewayRefreshTokenReuseException
import com.osglab.account.features.gateway.services.GatewayService
import com.osglab.account.features.gateway.services.GatewayTaskPolicyResolver
import com.osglab.account.features.gateway.services.GatewayUsagePolicyException
import com.osglab.account.features.credits.domain.InsufficientCredits
import com.osglab.account.features.oobe.OobeFeatureAlreadyUsedException
import com.osglab.account.features.oobe.OobeRequestAlreadyClaimedException
import io.ktor.http.ContentType
@@ -63,6 +68,7 @@ import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withTimeout
import kotlinx.io.readByteArray
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
fun Route.configureGatewayRoutes(
@@ -311,10 +317,21 @@ fun Route.configureGatewayRoutes(
flush()
})
} catch (failure: Throwable) {
if (failure is CancellationException) throw failure
if (failure is CancellationException &&
failure !is TimeoutCancellationException
) {
throw failure
}
// The response may already be committed. Emit metadata only.
val errorEvent =
"event: gateway_error\ndata: {\"code\":\"provider_error\",\"requestId\":\"$requestId\"}\n\n"
val descriptor = gatewayFailureDescriptor(failure)
val payload = ROUTE_JSON.encodeToString(
GatewayErrorResponse(
descriptor.code,
descriptor.message,
requestId,
),
)
val errorEvent = "event: gateway_error\ndata: $payload\n\n"
writeFully(errorEvent.encodeToByteArray())
flush()
}
@@ -440,101 +457,151 @@ private suspend fun ApplicationCall.respondGatewayFailure(
failure: Throwable,
requestId: String,
) {
val descriptor = gatewayFailureDescriptor(failure)
respondGatewayError(
descriptor.status,
descriptor.code,
descriptor.message,
requestId,
)
}
internal data class GatewayFailureDescriptor(
val status: HttpStatusCode,
val code: String,
val message: String,
)
internal fun gatewayFailureDescriptor(failure: Throwable): GatewayFailureDescriptor =
when (failure) {
is GatewayRequestAlreadyClaimedException -> respondGatewayError(
is GatewayRequestAlreadyClaimedException -> GatewayFailureDescriptor(
HttpStatusCode.Conflict,
"request_already_claimed",
"This account request ID is already ${failure.state.name.lowercase()}",
requestId,
)
is ComplimentaryRequestUnavailableException -> respondGatewayError(
is ComplimentaryRequestUnavailableException -> GatewayFailureDescriptor(
HttpStatusCode.Conflict,
"oobe_already_used",
"The complimentary OOBE request has already been used",
requestId,
)
is OobeFeatureAlreadyUsedException -> respondGatewayError(
is OobeFeatureAlreadyUsedException -> GatewayFailureDescriptor(
HttpStatusCode.Conflict,
"oobe_feature_already_used",
"This OOBE feature has already been used successfully in this session",
requestId,
)
is OobeRequestAlreadyClaimedException -> respondGatewayError(
is OobeRequestAlreadyClaimedException -> GatewayFailureDescriptor(
HttpStatusCode.Conflict,
"oobe_request_replayed",
"This OOBE request ID has already been used",
requestId,
)
is GatewayBodyTooLargeException -> respondGatewayError(
is GatewayBodyTooLargeException -> GatewayFailureDescriptor(
HttpStatusCode.PayloadTooLarge,
"request_too_large",
"Request body exceeds the gateway limit",
requestId,
)
is GatewayRequestTimeoutException -> respondGatewayError(
is GatewayRequestTimeoutException -> GatewayFailureDescriptor(
HttpStatusCode.RequestTimeout,
"request_timeout",
"Request body was not received within the time limit",
requestId,
)
is GatewayAccessDeniedException -> respondGatewayError(
is GatewayAccessDeniedException -> GatewayFailureDescriptor(
HttpStatusCode.Forbidden,
"gateway_grant_denied",
"Gateway access is not granted",
requestId,
)
is GatewayRefreshTokenInvalidException,
is GatewayRefreshTokenReuseException -> respondGatewayError(
is GatewayRefreshTokenReuseException -> GatewayFailureDescriptor(
HttpStatusCode.Unauthorized,
"invalid_gateway_refresh",
"Gateway refresh token is invalid",
requestId,
)
is AsrConcurrencyLimitException -> respondGatewayError(
is InsufficientCredits -> GatewayFailureDescriptor(
HttpStatusCode.PaymentRequired,
"insufficient_credits",
"The account does not have enough credits",
)
is AsrConcurrencyLimitException -> GatewayFailureDescriptor(
HttpStatusCode.TooManyRequests,
"asr_concurrency_limit",
"Too many concurrent ASR sessions",
requestId,
)
is AsrSessionNotFoundException,
is AsrSessionAlreadyUsedException -> respondGatewayError(
is AsrSessionAlreadyUsedException -> GatewayFailureDescriptor(
HttpStatusCode.NotFound,
"asr_session_unavailable",
"ASR session is unavailable",
requestId,
)
is UnsupportedGatewayCapabilityException -> respondGatewayError(
is TimeoutCancellationException -> GatewayFailureDescriptor(
HttpStatusCode.GatewayTimeout,
"provider_timeout",
"The managed provider timed out",
)
is UnsupportedGatewayCapabilityException,
is ProviderUnavailableException -> GatewayFailureDescriptor(
HttpStatusCode.ServiceUnavailable,
"provider_unavailable",
"No provider is configured for this capability",
requestId,
"The managed provider is unavailable",
)
is IllegalArgumentException -> respondGatewayError(
is ProviderUpstreamException ->
when (failure.upstreamStatus) {
408, 504 -> GatewayFailureDescriptor(
HttpStatusCode.GatewayTimeout,
"provider_timeout",
"The managed provider timed out",
)
429 -> GatewayFailureDescriptor(
HttpStatusCode.ServiceUnavailable,
"provider_rate_limited",
"The managed provider is temporarily busy",
)
401, 403, 503 -> GatewayFailureDescriptor(
HttpStatusCode.ServiceUnavailable,
"provider_unavailable",
"The managed provider is unavailable",
)
else -> GatewayFailureDescriptor(
HttpStatusCode.BadGateway,
"provider_failure",
"The managed provider request failed",
)
}
is ProviderCompletionException,
is GatewayUsagePolicyException,
is GatewayOutputLimitException -> GatewayFailureDescriptor(
HttpStatusCode.BadGateway,
"provider_invalid_response",
"The managed provider returned an invalid response",
)
is IllegalArgumentException -> GatewayFailureDescriptor(
HttpStatusCode.BadRequest,
"invalid_request",
failure.message ?: "Request is invalid",
requestId,
)
else -> respondGatewayError(
HttpStatusCode.BadGateway,
"gateway_failure",
"The managed provider request failed",
requestId,
else -> GatewayFailureDescriptor(
HttpStatusCode.InternalServerError,
"internal_failure",
"The managed gateway could not complete the request",
)
}
}
private suspend fun ApplicationCall.respondGatewayError(
status: HttpStatusCode,
@@ -3,6 +3,7 @@ package com.osglab.account.features.content.feed
import com.osglab.account.features.content.feed.sources.BaselineHintSource
import com.osglab.account.features.content.feed.sources.HintFeedGenerationContext
import com.osglab.account.features.content.models.AIHintCardDto
import com.osglab.account.features.content.models.AIHintTaskKind
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.shouldBe
@@ -54,6 +55,23 @@ class HintFeedPolicyTest : FunSpec({
"cap-en-clipboard-translate",
)
}
test("baseline marks only current-information cards as requiring online search") {
val source = BaselineHintSource()
val cards = source.fetch(
"zh",
HintFeedGenerationContext(
generatedAt = Instant.parse("2026-08-21T00:00:00Z"),
localDate = LocalDate.parse("2026-08-21"),
),
settings(),
).associateBy(AIHintCardDto::id)
cards.getValue("cap-zh-stocks").taskKind shouldBe
AIHintTaskKind.CURRENT_INFORMATION_QUESTION
cards.getValue("cap-zh-encyclopedia").taskKind shouldBe
AIHintTaskKind.AI_QUESTION
}
})
private fun hint(id: String, text: String, priority: Int) =
@@ -12,6 +12,7 @@ import com.osglab.account.features.content.feed.HintFeedGenerationStatusResponse
import com.osglab.account.features.content.feed.HintFeedPackGenerationResult
import com.osglab.account.features.content.feed.HintFeedService
import com.osglab.account.features.content.models.AIHintCardDto
import com.osglab.account.features.content.models.AIHintTaskKind
import com.osglab.account.features.content.models.CreateOfficialSkillRequest
import com.osglab.account.features.content.models.SkillLocalizationDto
import com.osglab.account.features.content.models.SkillLocalizationsDto
@@ -85,6 +86,7 @@ class ContentRoutesTest {
source = "official",
locale = "en",
conditions = listOf("idle"),
taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION,
),
),
),
@@ -110,6 +112,7 @@ class ContentRoutesTest {
pack.status shouldBe HttpStatusCode.OK
pack.bodyAsText() shouldContain """"version":1"""
pack.bodyAsText() shouldContain """"text":"Daily brief""""
pack.bodyAsText() shouldContain """"taskKind":"current_information_question""""
legacyPack.bodyAsText() shouldBe pack.bodyAsText()
legacyPack.headers[HttpHeaders.ETag] shouldBe etag
legacyPack.headers[HttpHeaders.CacheControl] shouldBe pack.headers[HttpHeaders.CacheControl]
@@ -243,7 +243,7 @@ class DeepSeekClientTest : StringSpec({
}
}
"falls back to Chat Completions when Responses search fails" {
"falls back to thinking Chat Completions when optional search fails" {
val paths = mutableListOf<String>()
val requestBodies = mutableListOf<String>()
val client = HttpClient(
@@ -274,8 +274,8 @@ class DeepSeekClientTest : StringSpec({
try {
val usage = DeepSeekProvider(client, CONFIG).execute(
request(
taskKind = GatewayTaskKind.CURRENT_INFORMATION_QUESTION,
webSearch = GatewayWebSearchMode.REQUIRED,
taskKind = GatewayTaskKind.AI_QUESTION,
webSearch = GatewayWebSearchMode.ALLOWED,
),
DISCARD_OUTPUT,
)
@@ -291,6 +291,39 @@ class DeepSeekClientTest : StringSpec({
}
}
"does not return an offline answer when current information requires search" {
val paths = mutableListOf<String>()
val client = HttpClient(
MockEngine { request ->
paths += request.url.encodedPath
respond(
content = """{"error":{"message":"search unavailable"}}""",
status = HttpStatusCode.ServiceUnavailable,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
)
},
) {
install(ContentNegotiation) {
json(Json { explicitNulls = false })
}
}
try {
shouldThrow<DeepSeekProviderException> {
DeepSeekProvider(client, CONFIG).execute(
request(
taskKind = GatewayTaskKind.CURRENT_INFORMATION_QUESTION,
webSearch = GatewayWebSearchMode.REQUIRED,
),
DISCARD_OUTPUT,
)
}
paths shouldBe listOf("/v1/responses")
} finally {
client.close()
}
}
"retries one buffered empty result and returns the successful retry" {
var attempts = 0
val provider = DeepSeekProvider(
@@ -0,0 +1,48 @@
package com.osglab.account.features.gateway.routes
import com.osglab.account.features.credits.domain.InsufficientCredits
import com.osglab.account.features.gateway.providers.ProviderCompletionException
import com.osglab.account.features.gateway.providers.ProviderUnavailableException
import com.osglab.account.features.gateway.providers.ProviderUpstreamException
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.ktor.http.HttpStatusCode
class GatewayFailureMappingTest : StringSpec({
"reports insufficient credits instead of a gateway failure" {
val result = gatewayFailureDescriptor(InsufficientCredits(available = 1, required = 2))
result.status shouldBe HttpStatusCode.PaymentRequired
result.code shouldBe "insufficient_credits"
}
"distinguishes unavailable busy timeout and invalid provider responses" {
gatewayFailureDescriptor(ProviderUnavailableException("missing")).let {
it.status shouldBe HttpStatusCode.ServiceUnavailable
it.code shouldBe "provider_unavailable"
}
gatewayFailureDescriptor(ProviderUpstreamException("busy", upstreamStatus = 429)).let {
it.status shouldBe HttpStatusCode.ServiceUnavailable
it.code shouldBe "provider_rate_limited"
}
gatewayFailureDescriptor(ProviderUpstreamException("unavailable", upstreamStatus = 503)).let {
it.status shouldBe HttpStatusCode.ServiceUnavailable
it.code shouldBe "provider_unavailable"
}
gatewayFailureDescriptor(ProviderUpstreamException("timeout", upstreamStatus = 504)).let {
it.status shouldBe HttpStatusCode.GatewayTimeout
it.code shouldBe "provider_timeout"
}
gatewayFailureDescriptor(ProviderCompletionException("invalid")).let {
it.status shouldBe HttpStatusCode.BadGateway
it.code shouldBe "provider_invalid_response"
}
}
"keeps unexpected server failures distinct from upstream failures" {
val result = gatewayFailureDescriptor(IllegalStateException("database unavailable"))
result.status shouldBe HttpStatusCode.InternalServerError
result.code shouldBe "internal_failure"
}
})