YuguException.java

package com.shengzhiai.yugu.errors;

/**
 * Base type of every error the SDK throws or reports.
 *
 * <p>Branch on the subclass ({@link AuthException}, {@link RateLimitException} and so on) or on
 * {@link #getCategory()}. {@link #isRetryable()} uses the same rules as the SDK retry loop and the
 * WebSocket reconnect logic, see {@link YuguErrors#isRetryable(Throwable)}.
 *
 * <p>The exception is unchecked. Methods still declare it in their {@code throws} clause so that it
 * shows up in the API documentation.
 */
public class YuguException extends RuntimeException {
    private static final long serialVersionUID = 2L;

    private final ErrorCategory category;
    private final int code;
    private final CodeKind codeKind;
    private final int httpStatus;
    private final boolean retryable;
    private final String rawMessage;
    private final String idempotencyKey;
    private final String recordId;
    private final int attempts;
    private final String rawBody;
    private final long retryAfterMs;

    /**
     * Creates an exception from details. The retryable flag is derived from the code, its table and
     * the HTTP status, never passed in, so every exception agrees with {@link YuguErrors#isRetryable}.
     *
     * @param details error details
     */
    public YuguException(ErrorDetails details) {
        super(describe(details), details.cause);
        this.category = details.category;
        this.code = details.code;
        this.codeKind = details.codeKind;
        this.httpStatus = details.httpStatus;
        this.retryable = YuguErrors.isRetryable(details.codeKind, details.code, details.httpStatus);
        this.rawMessage = details.message;
        this.idempotencyKey = details.idempotencyKey;
        this.recordId = details.recordId;
        this.attempts = details.attempts;
        this.rawBody = details.rawBody;
        this.retryAfterMs = details.retryAfterMs;
    }

    private static String describe(ErrorDetails d) {
        StringBuilder sb = new StringBuilder();
        sb.append(d.message == null || d.message.isEmpty() ? d.category.name() : d.message);
        sb.append(" (category=").append(d.category);
        if (d.code != 0) {
            sb.append(", code=").append(d.code);
        }
        if (d.httpStatus != 0) {
            sb.append(", httpStatus=").append(d.httpStatus);
        }
        if (d.attempts > 1) {
            sb.append(", attempts=").append(d.attempts);
        }
        if (d.idempotencyKey != null) {
            sb.append(", idempotencyKey=").append(d.idempotencyKey);
        }
        sb.append(')');
        return sb.toString();
    }

    /** @return error category */
    public ErrorCategory getCategory() {
        return category;
    }

    /**
     * Business code from the platform, or a local code 90001 to 90203. 0 when the error carried no
     * code and was classified by HTTP status or local cause.
     *
     * @return code or 0
     */
    public int getCode() {
        return code;
    }

    /** @return table of spec/errors.json the code comes from */
    public CodeKind getCodeKind() {
        return codeKind;
    }

    /** @return the constant name of the code in spec/errors.json, or null when unlisted */
    public String getCodeName() {
        ErrorTable.Entry e = YuguErrors.entry(codeKind, code);
        return e == null ? null : e.name;
    }

    /** @return HTTP status of the failed response, 0 when there was no HTTP response */
    public int getHttpStatus() {
        return httpStatus;
    }

    /** @return whether repeating the same request with the same idempotency key may succeed */
    public boolean isRetryable() {
        return retryable;
    }

    /** @return the message from the platform or the SDK, without the context suffix */
    public String getRawMessage() {
        return rawMessage;
    }

    /** @return idempotency key of the failed call, null for calls without one */
    public String getIdempotencyKey() {
        return idempotencyKey;
    }

    /** @return platform record id when known, else null */
    public String getRecordId() {
        return recordId;
    }

    /** @return attempts made before this error was raised, at least 1 */
    public int getAttempts() {
        return attempts;
    }

    /** @return raw response body or WebSocket frame, truncated to 4 KB, null when none */
    public String getRawBody() {
        return rawBody;
    }

    /** @return Retry-After of the response in milliseconds, -1 when absent */
    public long getRetryAfterMs() {
        return retryAfterMs;
    }

    /** @return the details this exception was built from, for re-wrapping with more context */
    public ErrorDetails toDetails() {
        return ErrorDetails.builder()
                .category(category)
                .code(code)
                .codeKind(codeKind)
                .httpStatus(httpStatus)
                .message(rawMessage)
                .idempotencyKey(idempotencyKey)
                .recordId(recordId)
                .attempts(attempts)
                .rawBody(rawBody)
                .retryAfterMs(retryAfterMs)
                .cause(getCause())
                .build();
    }
}