YuguErrors.java

package com.shengzhiai.yugu.errors;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.SocketTimeoutException;
import java.net.http.HttpConnectTimeoutException;
import java.net.http.HttpTimeoutException;
import java.net.http.WebSocketHandshakeException;
import java.util.Map;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.net.ssl.SSLException;

/**
 * Error mapping and the public retry decision.
 *
 * <p>Mapping order for platform responses: local error, then the body {@code code} (non-zero)
 * through the table generated from spec/errors.json, then the engine compat detail pattern
 * {@code [2001] ...}, then the HTTP status fallback.
 *
 * <p>Retry rules of {@link #isRetryable(Throwable)}, first match wins:
 * <ol>
 *   <li>local codes: 90001 network, 90002 timeout and 90007 result timeout are retryable,
 *       every other local code is not;</li>
 *   <li>a business code from the table: its {@code retryable} flag;</li>
 *   <li>unknown or absent code: HTTP 408, 425, 429, 500, 502, 503 and 504 are retryable.</li>
 * </ol>
 */
public final class YuguErrors {
    private static final Pattern DETAIL_CODE = Pattern.compile("^\\s*\\[(\\d{1,6})\\]\\s*(.*)$", Pattern.DOTALL);
    private static final ObjectMapper JSON = new ObjectMapper();

    private YuguErrors() {
    }

    // ------------------------------------------------------------------ retry decision

    /**
     * Whether repeating the failed call with the same idempotency key may succeed. The SDK retry
     * loop and the WebSocket reconnect logic use exactly this function.
     *
     * @param error any throwable; non SDK exceptions are classified first
     * @return true for retryable errors
     */
    public static boolean isRetryable(Throwable error) {
        if (error == null) {
            return false;
        }
        Throwable c = unwrap(error);
        if (c instanceof YuguException) {
            return ((YuguException) c).isRetryable();
        }
        return fromThrowable(c).isRetryable();
    }

    static boolean isRetryable(CodeKind kind, int code, int httpStatus) {
        ErrorTable.Entry e = entry(kind, code);
        if (e != null) {
            return e.retryable;
        }
        return isRetryableHttpStatus(httpStatus);
    }

    /**
     * @param httpStatus HTTP status
     * @return true when the status is retryable in the absence of a business code
     */
    public static boolean isRetryableHttpStatus(int httpStatus) {
        for (int s : ErrorTable.RETRYABLE_HTTP) {
            if (s == httpStatus) {
                return true;
            }
        }
        return false;
    }

    // ------------------------------------------------------------------ table lookups

    /**
     * Resolves the table of a code. Platform error codes win over warning codes for the two codes
     * present in both tables (1004, 1005); use {@link #fromWarning(int)} for warnings.
     *
     * @param code code
     * @return table kind
     */
    public static CodeKind kindOf(int code) {
        if (code == 0) {
            return CodeKind.NONE;
        }
        if (ErrorTable.LOCAL.containsKey(code)) {
            return CodeKind.LOCAL;
        }
        if (ErrorTable.ERRORS.containsKey(code)) {
            return CodeKind.ERROR;
        }
        if (ErrorTable.WARNINGS.containsKey(code)) {
            return CodeKind.WARNING;
        }
        return CodeKind.UNLISTED;
    }

    static ErrorTable.Entry entry(CodeKind kind, int code) {
        if (kind == null) {
            return null;
        }
        switch (kind) {
            case LOCAL:
                return ErrorTable.LOCAL.get(code);
            case ERROR:
                return ErrorTable.ERRORS.get(code);
            case WARNING:
                return ErrorTable.WARNINGS.get(code);
            default:
                return null;
        }
    }

    /**
     * @param code platform, warning or local code
     * @return category from the table, {@link ErrorCategory#UNKNOWN} for unlisted codes
     */
    public static ErrorCategory categoryOf(int code) {
        ErrorTable.Entry e = entry(kindOf(code), code);
        return e == null ? ErrorCategory.UNKNOWN : ErrorCategory.parse(e.category);
    }

    /**
     * @param httpStatus HTTP status
     * @return category from the HTTP fallback table, {@link ErrorCategory#UNKNOWN} when unlisted
     */
    public static ErrorCategory categoryForHttpStatus(int httpStatus) {
        String c = ErrorTable.HTTP_FALLBACK.get(httpStatus);
        return c == null ? ErrorCategory.UNKNOWN : ErrorCategory.parse(c);
    }

    /**
     * @param category category
     * @return the exception class used for that category
     */
    public static Class<? extends YuguException> exceptionClassFor(ErrorCategory category) {
        switch (category == null ? ErrorCategory.UNKNOWN : category) {
            case NETWORK:
                return NetworkException.class;
            case TIMEOUT:
                return RequestTimeoutException.class;
            case AUTH:
                return AuthException.class;
            case PERMISSION:
                return PermissionException.class;
            case INVALID_PARAM:
                return InvalidParameterException.class;
            case NOT_FOUND:
                return NotFoundException.class;
            case CONFLICT:
                return ConflictException.class;
            case RATE_LIMIT:
                return RateLimitException.class;
            case QUOTA:
                return QuotaExceededException.class;
            case SERVER:
            case UPSTREAM:
                return ServerException.class;
            case AUDIO:
                return AudioQualityException.class;
            case STATE:
                return IllegalSessionStateException.class;
            case CANCELLED:
                return RequestCancelledException.class;
            case PROTOCOL:
                return ProtocolViolationException.class;
            default:
                return YuguException.class;
        }
    }

    /**
     * Creates the exception subclass matching the category of the details.
     *
     * @param d details
     * @return exception, never null
     */
    public static YuguException create(ErrorDetails d) {
        switch (d.category) {
            case NETWORK:
                return new NetworkException(d);
            case TIMEOUT:
                return new RequestTimeoutException(d);
            case AUTH:
                return new AuthException(d);
            case PERMISSION:
                return new PermissionException(d);
            case INVALID_PARAM:
                return new InvalidParameterException(d);
            case NOT_FOUND:
                return new NotFoundException(d);
            case CONFLICT:
                return new ConflictException(d);
            case RATE_LIMIT:
                return new RateLimitException(d);
            case QUOTA:
                return new QuotaExceededException(d);
            case SERVER:
            case UPSTREAM:
                return new ServerException(d);
            case AUDIO:
                return new AudioQualityException(d);
            case STATE:
                return new IllegalSessionStateException(d);
            case CANCELLED:
                return new RequestCancelledException(d);
            case PROTOCOL:
                return new ProtocolViolationException(d);
            default:
                return new YuguException(d);
        }
    }

    // ------------------------------------------------------------------ factories by code

    /**
     * Builds the typed exception for a platform or local code using the table message. For the
     * codes 1001 to 1009 that are only warnings, an {@link AudioQualityException} is built.
     *
     * @param code code
     * @return exception
     */
    public static YuguException fromCode(int code) {
        return fromCode(code, null);
    }

    /**
     * @param code    code
     * @param message message, the table message when null
     * @return exception
     */
    public static YuguException fromCode(int code, String message) {
        CodeKind kind = kindOf(code);
        ErrorTable.Entry e = entry(kind, code);
        return create(ErrorDetails.builder()
                .category(e == null ? ErrorCategory.UNKNOWN : ErrorCategory.parse(e.category))
                .code(code)
                .codeKind(kind)
                .httpStatus(e == null ? 0 : e.http)
                .message(message != null ? message : e != null ? e.message : "unknown code " + code)
                .build());
    }

    /**
     * Builds an {@link AudioQualityException} for an audio warning code (1001 to 1005, 1009), for
     * callers who want to treat a warning as an error.
     *
     * @param code warning code
     * @return exception
     */
    public static AudioQualityException fromWarning(int code) {
        return fromWarning(code, null, null);
    }

    /**
     * @param code    warning code
     * @param message message, the table message when null
     * @param result  result that carried the warning, may be null
     * @return exception
     */
    public static AudioQualityException fromWarning(int code, String message, com.shengzhiai.yugu.model.EvalResult result) {
        ErrorTable.Entry e = ErrorTable.WARNINGS.get(code);
        ErrorDetails d = ErrorDetails.builder()
                .category(ErrorCategory.AUDIO)
                .code(code)
                .codeKind(e == null ? CodeKind.UNLISTED : CodeKind.WARNING)
                .message(message != null ? message : e != null ? e.message : "audio warning " + code)
                .recordId(result == null ? null : result.getRecordId())
                .idempotencyKey(result == null ? null : result.getIdempotencyKey())
                .build();
        return new AudioQualityException(d, result);
    }

    /**
     * Builds an exception for an SDK local code (90001 to 90203).
     *
     * @param code    local code
     * @param message message, the table message when null
     * @param cause   cause, may be null
     * @return exception
     */
    public static YuguException local(int code, String message, Throwable cause) {
        ErrorTable.Entry e = ErrorTable.LOCAL.get(code);
        if (e == null) {
            throw new IllegalArgumentException("not a local code: " + code);
        }
        return create(ErrorDetails.builder()
                .category(ErrorCategory.parse(e.category))
                .code(code)
                .codeKind(CodeKind.LOCAL)
                .message(message != null ? message : e.message)
                .cause(cause)
                .build());
    }

    /** Shortcut for {@link #local(int, String, Throwable)} without cause. */
    static YuguException local(int code, String message) {
        return local(code, message, null);
    }

    // ------------------------------------------------------------------ factories by response

    /**
     * Maps a failed HTTP response. Body shapes understood: platform {@code {code, message, timestamp}},
     * FastAPI {@code {detail: "..."}} or {@code {detail: [...]}}, engine detail {@code "[2001] ..."}.
     *
     * @param httpStatus status
     * @param body       response body, may be null
     * @return exception
     */
    public static YuguException fromHttpResponse(int httpStatus, String body) {
        return fromHttpResponse(httpStatus, body, -1, null, 1);
    }

    /**
     * @param httpStatus     status
     * @param body           response body, may be null
     * @param retryAfterMs   Retry-After in milliseconds, -1 when absent
     * @param idempotencyKey key of the call, may be null
     * @param attempts       attempts made
     * @return exception
     */
    public static YuguException fromHttpResponse(int httpStatus, String body, long retryAfterMs,
                                                 String idempotencyKey, int attempts) {
        ParsedBody p = ParsedBody.parse(body);
        int code = p.code;
        CodeKind kind = kindOf(code);
        ErrorTable.Entry e = entry(kind, code);
        String message = p.message;
        if (e == null && p.detailCode != 0) {
            // Engine compat detail "[2001] ...": 2001 to 2003 are AUTH in the table.
            CodeKind dk = kindOf(p.detailCode);
            ErrorTable.Entry de = entry(dk, p.detailCode);
            if (de != null && dk == CodeKind.ERROR) {
                code = p.detailCode;
                kind = dk;
                e = de;
            }
        }
        ErrorCategory category;
        if (e != null) {
            category = ErrorCategory.parse(e.category);
        } else {
            category = categoryForHttpStatus(httpStatus);
        }
        if (message == null || message.isEmpty()) {
            message = e != null ? e.message : "HTTP " + httpStatus;
        }
        return create(ErrorDetails.builder()
                .category(category)
                .code(code)
                .codeKind(kind)
                .httpStatus(httpStatus)
                .message(message)
                .rawBody(body)
                .recordId(p.recordId)
                .retryAfterMs(retryAfterMs)
                .idempotencyKey(idempotencyKey)
                .attempts(attempts)
                .build());
    }

    /**
     * Maps a WebSocket error frame {@code {"event":"error","code":N,"message":"..."}}. Frames without a
     * listed code are classified as {@link ErrorCategory#SERVER}, not retryable.
     *
     * @param code           code from the frame, 0 when absent
     * @param message        message from the frame
     * @param rawFrame       raw frame text
     * @param idempotencyKey key of the session
     * @param attempts       connection attempts so far
     * @return exception
     */
    public static YuguException fromWsError(int code, String message, String rawFrame, String idempotencyKey, int attempts) {
        CodeKind kind = kindOf(code);
        ErrorTable.Entry e = entry(kind, code);
        if (e == null && message != null) {
            Matcher m = DETAIL_CODE.matcher(message);
            if (m.matches()) {
                int dc = parseIntSafe(m.group(1));
                ErrorTable.Entry de = ErrorTable.ERRORS.get(dc);
                if (de != null) {
                    code = dc;
                    kind = CodeKind.ERROR;
                    e = de;
                }
            }
        }
        ErrorCategory category = e != null ? ErrorCategory.parse(e.category) : ErrorCategory.SERVER;
        return create(ErrorDetails.builder()
                .category(category)
                .code(code)
                .codeKind(kind)
                .message(message == null || message.isEmpty() ? (e != null ? e.message : "server error frame") : message)
                .rawBody(rawFrame)
                .idempotencyKey(idempotencyKey)
                .attempts(attempts)
                .build());
    }

    /**
     * Classifies a low level failure: timeouts are {@link ErrorCategory#TIMEOUT} 90002, TLS
     * failures 90011, other I/O failures {@link ErrorCategory#NETWORK} 90001, cancellation 90003, a
     * rejected WebSocket handshake by its HTTP status.
     *
     * @param error throwable
     * @return exception, the same instance when it already is a {@link YuguException}
     */
    public static YuguException fromThrowable(Throwable error) {
        return fromThrowable(error, null, 1);
    }

    /**
     * @param error          throwable
     * @param idempotencyKey key of the call, may be null
     * @param attempts       attempts made
     * @return exception
     */
    public static YuguException fromThrowable(Throwable error, String idempotencyKey, int attempts) {
        Throwable c = unwrap(error);
        if (c instanceof YuguException) {
            return (YuguException) c;
        }
        int code;
        String msg;
        if (c instanceof HttpConnectTimeoutException) {
            code = ErrorTable.TIMEOUT;
            msg = "connect timeout";
        } else if (c instanceof HttpTimeoutException || c instanceof SocketTimeoutException || c instanceof TimeoutException) {
            code = ErrorTable.TIMEOUT;
            msg = "read timeout";
        } else if (c instanceof WebSocketHandshakeException) {
            java.net.http.HttpResponse<?> r = ((WebSocketHandshakeException) c).getResponse();
            int status = r == null ? 0 : r.statusCode();
            Object b = r == null ? null : r.body();
            String body = b instanceof String ? (String) b : null;
            YuguException mapped = fromHttpResponse(status, body, -1, idempotencyKey, attempts);
            return create(mapped.toDetails().toBuilder()
                    .message("WebSocket handshake rejected: " + mapped.getRawMessage())
                    .cause(c)
                    .build());
        } else if (c instanceof SSLException) {
            code = ErrorTable.TLS_ERROR;
            msg = "TLS failure: " + c.getMessage();
        } else if (c instanceof CancellationException || c instanceof InterruptedException) {
            code = ErrorTable.CANCELLED;
            msg = "cancelled";
        } else if (c instanceof IOException) {
            code = ErrorTable.NETWORK_ERROR;
            msg = "network error: " + (c.getMessage() == null ? c.getClass().getSimpleName() : c.getMessage());
        } else if (c instanceof IllegalArgumentException) {
            code = ErrorTable.INVALID_ARGUMENT;
            msg = c.getMessage();
        } else {
            return create(ErrorDetails.builder()
                    .category(ErrorCategory.UNKNOWN)
                    .message(c == null ? "unknown error" : String.valueOf(c))
                    .cause(c)
                    .idempotencyKey(idempotencyKey)
                    .attempts(attempts)
                    .build());
        }
        ErrorTable.Entry e = ErrorTable.LOCAL.get(code);
        return create(ErrorDetails.builder()
                .category(ErrorCategory.parse(e.category))
                .code(code)
                .codeKind(CodeKind.LOCAL)
                .message(msg)
                .cause(c)
                .idempotencyKey(idempotencyKey)
                .attempts(attempts)
                .build());
    }

    static Throwable unwrap(Throwable t) {
        Throwable c = t;
        while ((c instanceof CompletionException || c instanceof ExecutionException || c instanceof UncheckedIOException)
                && c.getCause() != null) {
            c = c.getCause();
        }
        return c;
    }

    private static int parseIntSafe(String s) {
        try {
            return Integer.parseInt(s.trim());
        } catch (RuntimeException e) {
            return 0;
        }
    }

    /** Fields extracted from an error body. */
    static final class ParsedBody {
        int code;
        int detailCode;
        String message;
        String recordId;

        static ParsedBody parse(String body) {
            ParsedBody p = new ParsedBody();
            if (body == null || body.isEmpty()) {
                return p;
            }
            JsonNode n;
            try {
                n = JSON.readTree(body);
            } catch (IOException | RuntimeException e) {
                p.message = null;
                return p;
            }
            if (n == null || !n.isObject()) {
                return p;
            }
            p.code = intField(n, "code");
            if (p.code == 0) {
                p.code = intField(n, "errId");
            }
            p.message = textField(n, "message");
            if (p.message == null) {
                p.message = textField(n, "msg");
            }
            if (p.message == null) {
                p.message = textField(n, "error");
            }
            JsonNode detail = n.get("detail");
            if (detail != null && !detail.isNull()) {
                String d;
                if (detail.isTextual()) {
                    d = detail.asText();
                } else if (detail.isArray()) {
                    StringBuilder sb = new StringBuilder();
                    for (JsonNode item : detail) {
                        String m = item.isObject() ? textField(item, "msg") : item.asText();
                        if (m == null) {
                            m = item.toString();
                        }
                        if (sb.length() > 0) {
                            sb.append("; ");
                        }
                        sb.append(m);
                    }
                    d = sb.toString();
                } else {
                    d = detail.toString();
                }
                Matcher m = DETAIL_CODE.matcher(d);
                if (m.matches()) {
                    p.detailCode = parseIntSafe(m.group(1));
                }
                if (p.message == null) {
                    p.message = d;
                }
            }
            p.recordId = textField(n, "recordId");
            JsonNode data = n.get("data");
            if (p.recordId == null && data != null && data.isObject()) {
                p.recordId = textField(data, "recordId");
            }
            return p;
        }

        private static int intField(JsonNode n, String name) {
            JsonNode v = n.get(name);
            if (v == null || v.isNull()) {
                return 0;
            }
            if (v.isNumber()) {
                return v.asInt();
            }
            if (v.isTextual()) {
                return parseIntSafe(v.asText());
            }
            return 0;
        }

        private static String textField(JsonNode n, String name) {
            JsonNode v = n.get(name);
            if (v == null || v.isNull()) {
                return null;
            }
            return v.isValueNode() ? v.asText() : v.toString();
        }
    }

    /**
     * Exposes the HTTP fallback table, for documentation and tests.
     *
     * @return unmodifiable status to category name map
     */
    public static Map<Integer, String> httpFallbackTable() {
        return ErrorTable.HTTP_FALLBACK;
    }
}