Errors.kt

package com.shengzhiai.yugu

import com.shengzhiai.yugu.errors.ErrorTable
import com.shengzhiai.yugu.internal.JObj
import java.io.IOException
import java.io.InterruptedIOException
import java.net.SocketTimeoutException
import java.security.cert.CertificateException
import javax.net.ssl.SSLPeerUnverifiedException

/** 错误类别,与 `spec/errors.json` 的 `categories` 一致。 */
public enum class ErrorCategory {
    NETWORK, TIMEOUT, AUTH, PERMISSION, INVALID_PARAM, NOT_FOUND, CONFLICT, RATE_LIMIT, QUOTA,
    SERVER, UPSTREAM, AUDIO, STATE, CANCELLED, PROTOCOL, UNKNOWN;

    public companion object {
        /** 按名称取类别,名称不认识时返回 [UNKNOWN]。 */
        @JvmStatic
        public fun of(name: String?): ErrorCategory = values().firstOrNull { it.name == name } ?: UNKNOWN
    }
}

/**
 * SDK 全部错误的基类。
 *
 * `code` 为平台错误码,警告码或 SDK 本地错误码,没有错误码时为 0。`httpStatus` 没有 HTTP 响应时为 0。
 * `retryable` 由 [YuguErrors.isRetryable] 的同一套规则算出,SDK 内部重试与重连也按它判断。
 * `attempts` 为该逻辑调用实际发出的请求次数,`rawBody` 为原始响应体,最多保留 4 KB。
 */
public open class YuguException @JvmOverloads constructor(
    public val category: ErrorCategory,
    public val code: Int,
    public val httpStatus: Int,
    message: String,
    public val retryable: Boolean,
    rawBody: String? = null,
    cause: Throwable? = null,
    public val retryAfterMs: Long? = null,
) : RuntimeException(message, cause) {

    /** 原始响应体,按 UTF-8 截断到 4096 字节。 */
    public val rawBody: String? = rawBody?.let { truncateUtf8(it, RAW_BODY_LIMIT) }

    /** 该调用使用的幂等键。 */
    public var idempotencyKey: String? = null
        internal set

    /** 已知时为评测记录号。 */
    public var recordId: String? = null
        internal set

    /** 实际发出的请求次数。 */
    public var attempts: Int = 0
        internal set

    override fun toString(): String {
        val sb = StringBuilder(javaClass.simpleName).append(": ").append(message)
        sb.append(" [category=").append(category).append(" code=").append(code)
        if (httpStatus > 0) sb.append(" http=").append(httpStatus)
        sb.append(" retryable=").append(retryable)
        if (attempts > 0) sb.append(" attempts=").append(attempts)
        idempotencyKey?.let { sb.append(" idempotencyKey=").append(it) }
        recordId?.let { sb.append(" recordId=").append(it) }
        return sb.append(']').toString()
    }

    internal fun withContext(key: String?, attempts: Int, recordId: String? = null): YuguException {
        if (key != null) this.idempotencyKey = key
        if (attempts > 0) this.attempts = attempts
        if (recordId != null) this.recordId = recordId
        return this
    }

    public companion object {
        /** `rawBody` 的上限字节数。 */
        public const val RAW_BODY_LIMIT: Int = 4096

        internal fun truncateUtf8(s: String, limit: Int): String {
            var bytes = 0
            var i = 0
            while (i < s.length) {
                val c = s[i]
                val n = when {
                    c.code < 0x80 -> 1
                    c.code < 0x800 -> 2
                    Character.isHighSurrogate(c) && i + 1 < s.length && Character.isLowSurrogate(s[i + 1]) -> 4
                    else -> 3
                }
                if (bytes + n > limit) return s.substring(0, i)
                bytes += n
                i += if (n == 4) 2 else 1
            }
            return s
        }
    }
}

/** 网络错误:连接失败,连接被重置,证书校验失败,实时连接重连用尽。 */
public class NetworkException @JvmOverloads constructor(
    code: Int, httpStatus: Int, message: String, retryable: Boolean,
    rawBody: String? = null, cause: Throwable? = null, retryAfterMs: Long? = null,
) : YuguException(ErrorCategory.NETWORK, code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)

/** 超时:连接超时,读取超时,等待终评结果超时,HTTP 408。 */
public class RequestTimeoutException @JvmOverloads constructor(
    code: Int, httpStatus: Int, message: String, retryable: Boolean,
    rawBody: String? = null, cause: Throwable? = null, retryAfterMs: Long? = null,
) : YuguException(ErrorCategory.TIMEOUT, code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)

/** 鉴权失败:凭据缺失,签名不符,token 失效,API Key 不存在或已禁用。 */
public class AuthException @JvmOverloads constructor(
    code: Int, httpStatus: Int, message: String, retryable: Boolean,
    rawBody: String? = null, cause: Throwable? = null, retryAfterMs: Long? = null,
) : YuguException(ErrorCategory.AUTH, code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)

/** 无权限:coreType 未授权,账户或用户被禁用,没有麦克风权限。 */
public class PermissionException @JvmOverloads constructor(
    code: Int, httpStatus: Int, message: String, retryable: Boolean,
    rawBody: String? = null, cause: Throwable? = null, retryAfterMs: Long? = null,
) : YuguException(ErrorCategory.PERMISSION, code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)

/** 参数错误:平台校验失败或 SDK 在发请求前发现的参数问题。 */
public class InvalidParameterException @JvmOverloads constructor(
    code: Int, httpStatus: Int, message: String, retryable: Boolean,
    rawBody: String? = null, cause: Throwable? = null, retryAfterMs: Long? = null,
) : YuguException(ErrorCategory.INVALID_PARAM, code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)

/** 资源不存在。 */
public class NotFoundException @JvmOverloads constructor(
    code: Int, httpStatus: Int, message: String, retryable: Boolean,
    rawBody: String? = null, cause: Throwable? = null, retryAfterMs: Long? = null,
) : YuguException(ErrorCategory.NOT_FOUND, code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)

/** 冲突:幂等键仍在处理中,幂等键用于了不同请求,账目并发冲突。 */
public class ConflictException @JvmOverloads constructor(
    code: Int, httpStatus: Int, message: String, retryable: Boolean,
    rawBody: String? = null, cause: Throwable? = null, retryAfterMs: Long? = null,
) : YuguException(ErrorCategory.CONFLICT, code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)

/** 限流:请求过多,排队超时,并发超限。`retryAfterMs` 为服务端建议的等待时长。 */
public class RateLimitException @JvmOverloads constructor(
    code: Int, httpStatus: Int, message: String, retryable: Boolean,
    rawBody: String? = null, cause: Throwable? = null, retryAfterMs: Long? = null,
) : YuguException(ErrorCategory.RATE_LIMIT, code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)

/** 额度不足:套餐额度,账户余额,试用与沙箱的每日上限。 */
public class QuotaExceededException @JvmOverloads constructor(
    code: Int, httpStatus: Int, message: String, retryable: Boolean,
    rawBody: String? = null, cause: Throwable? = null, retryAfterMs: Long? = null,
) : YuguException(ErrorCategory.QUOTA, code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)

/** 服务端错误,类别为 [ErrorCategory.SERVER] 或 [ErrorCategory.UPSTREAM]。 */
public class ServerException @JvmOverloads constructor(
    category: ErrorCategory, code: Int, httpStatus: Int, message: String, retryable: Boolean,
    rawBody: String? = null, cause: Throwable? = null, retryAfterMs: Long? = null,
) : YuguException(category, code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)

/** 音频质量问题:本地预检未通过,或开启 `strictAudio` 后结果带 1001 警告。 */
public class AudioQualityException @JvmOverloads constructor(
    code: Int, httpStatus: Int, message: String, retryable: Boolean,
    rawBody: String? = null, cause: Throwable? = null, retryAfterMs: Long? = null,
) : YuguException(ErrorCategory.AUDIO, code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)

/** 状态错误:客户端已关闭,会话状态不允许该操作,录音器不可用,重放缓冲溢出。 */
public class IllegalSessionStateException @JvmOverloads constructor(
    code: Int, httpStatus: Int, message: String, retryable: Boolean,
    rawBody: String? = null, cause: Throwable? = null, retryAfterMs: Long? = null,
) : YuguException(ErrorCategory.STATE, code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)

/** 调用方取消了请求。 */
public class RequestCancelledException @JvmOverloads constructor(
    code: Int, httpStatus: Int, message: String, retryable: Boolean,
    rawBody: String? = null, cause: Throwable? = null, retryAfterMs: Long? = null,
) : YuguException(ErrorCategory.CANCELLED, code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)

/** 协议错误:响应或帧无法解析。 */
public class ProtocolViolationException @JvmOverloads constructor(
    code: Int, httpStatus: Int, message: String, retryable: Boolean,
    rawBody: String? = null, cause: Throwable? = null, retryAfterMs: Long? = null,
) : YuguException(ErrorCategory.PROTOCOL, code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)

/** 音频质量警告码,与 `spec/errors.json` 的 `warnings` 一致。 */
public enum class WarningCode(public val code: Int, public val defaultMessage: String) {
    NO_VALID_AUDIO(1001, "No valid audio detected!"),
    VOLUME_TOO_LOW(1002, "Audio volume too low!"),
    VOLUME_TOO_HIGH(1003, "Audio volume too high!"),
    AUDIO_NOISY(1004, "Audio noisy!"),
    AUDIO_INCOMPLETE(1005, "Audio not complete!"),
    SCORER_DEGRADED(1009, "scorer degraded");

    public companion object {
        /** 按警告码取枚举,不认识的码返回 null。 */
        @JvmStatic
        public fun fromCode(code: Int): WarningCode? = values().firstOrNull { it.code == code }
    }
}

/** 评测结果里的一条警告,形态为 `{code, message}`。 */
public data class Warning(public val code: Int, public val message: String) {
    /** 对应的枚举,不认识的码为 null。 */
    public val warningCode: WarningCode? get() = WarningCode.fromCode(code)
}

/** 本地音频预检给出的一条警告,码为 90101 到 90105。 */
public data class LocalWarning(public val code: Int, public val message: String)

/**
 * 错误码映射与可重试判定,全部基于生成的 [ErrorTable]。
 *
 * 错误码按场景查表,不只看数字:错误响应体与错误帧里的 `code` 查错误码表,结果里的警告查警告码表,
 * 90000 起的本地码查本地码表。1004 与 1005 在错误码表里是账户状态,在警告码表里是音频问题。
 * 不在兜底表里的 HTTP 状态按类别归类:其余 4xx 为 `INVALID_PARAM`,其余 5xx 为 `SERVER`,其他为 `UNKNOWN`。
 *
 * 可重试判定规则,按顺序第一条命中为准:
 * 1. 本地码 90001,90002,90007 可重试,其余本地码不可重试。
 * 2. 有业务错误码时取错误码表里的 `retryable`。
 * 3. 业务错误码缺失或不认识时,HTTP 408,425,429,500,502,503,504 可重试,其余不可重试。
 */
public object YuguErrors {

    private val BRACKET_CODE = Regex("^\\s*\\[(\\d{1,6})]")

    /**
     * 按错误码构造对应类型的异常:本地码查本地码表,其余查错误码表,1004 与 1005 因此是账户状态错误。
     * 只出现在警告码表里的码,例如 1001,构造 [AudioQualityException]。
     */
    @JvmStatic
    @JvmOverloads
    public fun fromCode(code: Int, message: String? = null, httpStatus: Int = 0): YuguException {
        if (code in LOCAL_RANGE) {
            val e = local(code, message)
            return if (httpStatus == 0) e else create(e.category, code, httpStatus, e.message ?: "", e.retryable)
        }
        ErrorTable.ERRORS[code]?.let { entry ->
            return create(ErrorCategory.of(entry.category), code, httpStatus, message ?: entry.message, entry.retryable)
        }
        if (ErrorTable.WARNINGS.containsKey(code)) return fromWarning(code, message)
        return create(httpCategory(httpStatus), code, httpStatus, message ?: "error $code", httpStatus in ErrorTable.RETRYABLE_HTTP)
    }

    /** 按警告码表构造 [AudioQualityException],供需要把警告当异常处理的调用方使用。 */
    @JvmStatic
    @JvmOverloads
    public fun fromWarning(code: Int, message: String? = null): AudioQualityException {
        val entry = ErrorTable.WARNINGS[code]
        return AudioQualityException(code, 0, message ?: entry?.message ?: "warning $code", entry?.retryable ?: false)
    }

    /** 按本地码表构造异常。 */
    @JvmStatic
    @JvmOverloads
    public fun local(code: Int, message: String? = null, cause: Throwable? = null): YuguException {
        val entry = ErrorTable.LOCAL[code]
        val category = entry?.let { ErrorCategory.of(it.category) } ?: ErrorCategory.UNKNOWN
        return create(category, code, 0, message ?: entry?.message ?: "local error $code", entry?.retryable ?: false, cause = cause)
    }

    /**
     * 把 HTTP 错误响应映射为异常。顺序:响应体非零 `code` 查错误码表,FastAPI 形态 `detail` 里的
     * `[2001]` 前缀,最后按 HTTP 状态兜底。错误码不在错误码表里时保留原码,类别与可重试按 HTTP 状态判定。
     */
    @JvmStatic
    @JvmOverloads
    public fun fromHttp(httpStatus: Int, body: String?, retryAfterMs: Long? = null): YuguException {
        val parsed = parseBody(body)
        val code = parsed.code ?: 0
        val entry = if (code != 0) ErrorTable.ERRORS[code] else null
        val category = entry?.let { ErrorCategory.of(it.category) } ?: httpCategory(httpStatus)
        val message = parsed.message?.takeIf { it.isNotBlank() } ?: entry?.message ?: "HTTP $httpStatus"
        val e = create(category, code, httpStatus, message, isRetryable(code, httpStatus), body, null, retryAfterMs)
        parsed.recordId?.let { e.recordId = it }
        return e
    }

    /** 把实时评测的错误帧 `{"event":"error","code":N,"message":"..."}` 映射为异常,`code` 查错误码表。 */
    @JvmStatic
    public fun fromErrorFrame(frameJson: String): YuguException {
        val parsed = parseBody(frameJson)
        val code = parsed.code ?: 0
        val entry = if (code != 0) ErrorTable.ERRORS[code] else null
        val category = entry?.let { ErrorCategory.of(it.category) } ?: ErrorCategory.UNKNOWN
        val message = parsed.message?.takeIf { it.isNotBlank() } ?: entry?.message ?: "error frame"
        return create(category, code, 0, message, isRetryable(code, 0), frameJson)
    }

    /** 公开的可重试判定,SDK 的重试循环与实时重连共用。 */
    @JvmStatic
    public fun isRetryable(error: Throwable?): Boolean = when (error) {
        null -> false
        is YuguException -> error.retryable
        is IOException -> fromIOException(error, cancelled = false).retryable
        else -> false
    }

    /** 按错误码与 HTTP 状态计算可重试判定,`code` 为 0 表示没有错误码,错误码查错误码表。 */
    @JvmStatic
    public fun isRetryable(code: Int, httpStatus: Int): Boolean {
        if (code in LOCAL_RANGE) return ErrorTable.LOCAL[code]?.retryable ?: false
        if (code != 0) ErrorTable.ERRORS[code]?.let { return it.retryable }
        return httpStatus in ErrorTable.RETRYABLE_HTTP
    }

    /** 错误码所属类别,查表顺序与 [fromCode] 相同,不认识的码返回 null。 */
    @JvmStatic
    public fun categoryOf(code: Int): ErrorCategory? {
        val entry = if (code in LOCAL_RANGE) ErrorTable.LOCAL[code] else ErrorTable.ERRORS[code] ?: ErrorTable.WARNINGS[code]
        return entry?.let { ErrorCategory.of(it.category) }
    }

    private val LOCAL_RANGE = 90000..90999

    /** HTTP status category: the fallback table, then by class (4xx, 5xx), else UNKNOWN. */
    internal fun httpCategory(httpStatus: Int): ErrorCategory {
        ErrorTable.HTTP_FALLBACK[httpStatus]?.let { return ErrorCategory.of(it) }
        return when (httpStatus) {
            in 400..499 -> ErrorCategory.INVALID_PARAM
            in 500..599 -> ErrorCategory.SERVER
            else -> ErrorCategory.UNKNOWN
        }
    }

    internal fun fromIOException(e: IOException, cancelled: Boolean): YuguException = when {
        cancelled -> local(90003, cause = e)
        e is SocketTimeoutException -> local(90002, "连接或读取超时:${e.message ?: "timeout"}", e)
        e is InterruptedIOException && e.message == "timeout" -> local(90002, "请求超过时限", e)
        isTlsFailure(e) -> local(90011, "证书校验失败:${e.message}", e)
        else -> local(90001, "网络错误:${e.javaClass.simpleName} ${e.message ?: ""}".trim(), e)
    }

    private fun isTlsFailure(e: Throwable): Boolean {
        var t: Throwable? = e
        var depth = 0
        while (t != null && depth < 8) {
            if (t is SSLPeerUnverifiedException || t is CertificateException) return true
            t = t.cause
            depth++
        }
        return false
    }

    internal class ParsedBody(val code: Int?, val message: String?, val recordId: String?)

    internal fun parseBody(body: String?): ParsedBody {
        val obj = JObj.parseOrNull(body) ?: return ParsedBody(null, body?.trim()?.take(200)?.takeIf { looksLikeText(it) }, null)
        var code = obj.int("code")?.takeIf { it != 0 }
        var message = obj.str("message") ?: obj.str("msg") ?: obj.str("error")
        when (val detail = obj.raw("detail")) {
            is String -> {
                if (message == null) message = detail
                if (code == null) BRACKET_CODE.find(detail)?.let { code = it.groupValues[1].toInt() }
            }
            is List<*> -> if (message == null) {
                message = detail.joinToString(";") { item ->
                    @Suppress("UNCHECKED_CAST")
                    ((item as? Map<String, Any?>)?.get("msg") ?: item).toString()
                }
            }
        }
        return ParsedBody(code, message, obj.str("recordId"))
    }

    private fun looksLikeText(s: String): Boolean = s.isNotEmpty() && !s.startsWith("<")

    internal fun create(
        category: ErrorCategory,
        code: Int,
        httpStatus: Int,
        message: String,
        retryable: Boolean,
        rawBody: String? = null,
        cause: Throwable? = null,
        retryAfterMs: Long? = null,
    ): YuguException = when (category) {
        ErrorCategory.NETWORK -> NetworkException(code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)
        ErrorCategory.TIMEOUT -> RequestTimeoutException(code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)
        ErrorCategory.AUTH -> AuthException(code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)
        ErrorCategory.PERMISSION -> PermissionException(code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)
        ErrorCategory.INVALID_PARAM -> InvalidParameterException(code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)
        ErrorCategory.NOT_FOUND -> NotFoundException(code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)
        ErrorCategory.CONFLICT -> ConflictException(code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)
        ErrorCategory.RATE_LIMIT -> RateLimitException(code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)
        ErrorCategory.QUOTA -> QuotaExceededException(code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)
        ErrorCategory.SERVER, ErrorCategory.UPSTREAM ->
            ServerException(category, code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)
        ErrorCategory.AUDIO -> AudioQualityException(code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)
        ErrorCategory.STATE -> IllegalSessionStateException(code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)
        ErrorCategory.CANCELLED -> RequestCancelledException(code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)
        ErrorCategory.PROTOCOL -> ProtocolViolationException(code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)
        ErrorCategory.UNKNOWN -> YuguException(ErrorCategory.UNKNOWN, code, httpStatus, message, retryable, rawBody, cause, retryAfterMs)
    }
}