ErrorMapper.java
// SPDX-License-Identifier: Apache-2.0
package com.shengzhiai.yugu.stcompat.internal;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.net.SocketTimeoutException;
import java.security.cert.CertificateException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.net.ssl.SSLPeerUnverifiedException;
/**
* Error classification shared with the other Yugu SDKs (DESIGN 2.3 and 2.4) and the errId rule of
* the compat envelope (DESIGN 6.2).
*
* <p>Retryable, first match wins: local codes 90001, 90002, 90007; a business code from
* spec/errors.json uses its {@code retryable} flag; otherwise HTTP 408, 425, 429, 500, 502, 503,
* 504. errId: 20009 for every retryable failure once retries are exhausted, the platform
* business code for other server errors, the local code for other local failures.
*/
public final class ErrorMapper {
private static final Pattern DETAIL_CODE = Pattern.compile("^\\s*\\[(\\d{3,6})\\]\\s*(.*)$", Pattern.DOTALL);
private ErrorMapper() {
}
/** {@code isRetryable(error)} of DESIGN 2.3. */
public static boolean isRetryable(int code, int httpStatus) {
if (code != 0) {
ErrorTable.Entry local = ErrorTable.LOCAL.get(code);
if (local != null && httpStatus == 0) {
return local.retryable;
}
ErrorTable.Entry e = ErrorTable.ERRORS.get(code);
if (e != null) {
return e.retryable;
}
}
for (int s : ErrorTable.RETRYABLE_HTTP) {
if (s == httpStatus) {
return true;
}
}
return false;
}
/** Maps a transport exception to a local error (90001 network, 90002 timeout, 90011 TLS). */
public static CompatError fromException(IOException e) {
if (e instanceof SocketTimeoutException || (e instanceof InterruptedIOException && isTimeoutMessage(e))) {
return CompatError.local(ErrorTable.TIMEOUT, "timeout: " + e.getMessage(), e);
}
if (e instanceof SSLPeerUnverifiedException || hasCause(e, CertificateException.class)) {
return CompatError.local(ErrorTable.TLS_ERROR, "TLS certificate check failed: " + e.getMessage(), e);
}
return CompatError.local(ErrorTable.NETWORK_ERROR, "network error: " + e.getClass().getSimpleName()
+ (e.getMessage() == null ? "" : " " + e.getMessage()), e);
}
/**
* Failure of a realtime connection: a refused handshake is classified by its HTTP status like a
* REST answer without body, a protocol violation is 90005, anything else as {@link #fromException}.
*/
public static CompatError fromWsException(IOException e) {
if (e instanceof WsClient.HandshakeException) {
WsClient.HandshakeException h = (WsClient.HandshakeException) e;
return new CompatError(0, h.status, isRetryable(0, h.status), h.retryAfterMs, e.getMessage(), null, e);
}
if (e instanceof WsClient.ProtocolError) {
return CompatError.local(ErrorTable.PROTOCOL_ERROR, "realtime protocol error: " + e.getMessage(), e);
}
return fromException(e);
}
/**
* Error frame {@code {"event":"error","code":N,"message":"..."}} of the realtime channel. A
* code keeps the retryable flag of the table (40901, 42901, 50200 are retried, DESIGN 2.5); a
* frame without code (also none in the {@code [2003] ...} message form) is a terminal server
* error with code 50000.
*/
public static CompatError fromWsError(int code, String message, String rawFrame) {
String text = message == null || message.isEmpty() ? null : message;
if (code == 0 && text != null) {
Matcher m = DETAIL_CODE.matcher(text);
if (m.matches()) {
code = Integer.parseInt(m.group(1));
}
}
if (code == 0) {
return new CompatError(ErrorTable.INTERNAL_ERROR, 0, false, -1,
text != null ? text : "realtime evaluation failed on the platform", rawFrame, null);
}
return new CompatError(code, 0, isRetryable(code, 0), -1, text != null ? text : CompatErrIds.message(code),
rawFrame, null);
}
/**
* Close frame of the realtime channel before the result. 1000, 1002, 1003, 1007 to 1010 and
* 4000 to 4999 end the round at once with 90005, because a new connection would be closed the
* same way (DESIGN 2.5); other codes such as 1001, 1011 and 1012 are network failures and may
* be retried.
*/
public static CompatError fromWsClose(int code, String reason) {
String text = "realtime connection closed by the platform with code " + code
+ (reason == null || reason.isEmpty() ? "" : " " + reason);
boolean terminal = code == 1000 || code == 1002 || code == 1003 || (code >= 1007 && code <= 1010)
|| (code >= 4000 && code <= 4999);
return CompatError.local(terminal ? ErrorTable.PROTOCOL_ERROR : ErrorTable.NETWORK_ERROR, text, null);
}
private static boolean isTimeoutMessage(IOException e) {
return e.getMessage() != null && e.getMessage().toLowerCase(Locale.ROOT).contains("timed out");
}
private static boolean hasCause(Throwable t, Class<? extends Throwable> type) {
for (Throwable c = t; c != null; c = c.getCause()) {
if (type.isInstance(c)) {
return true;
}
if (c.getCause() == c) {
break;
}
}
return false;
}
/**
* Maps a non-success response. Body shapes: platform {@code {code, message, timestamp}},
* FastAPI {@code {detail: "..."}} or {@code {detail: [...]}}, Shengtong style
* {@code {errId, error}}, or anything else (HTML from a proxy).
*/
public static CompatError fromResponse(HttpTransport.Response r) {
int code = 0;
String message = null;
try {
Map<String, String> m = RawJson.members(r.body.trim());
code = (int) RawJson.toLong(m.get("code"), 0);
if (code == 0) {
code = (int) RawJson.toLong(m.get("errId"), 0);
}
message = firstString(m, "message", "msg", "error");
String detail = m.get("detail");
if (detail != null) {
if (RawJson.isString(detail)) {
String d = RawJson.unquote(detail);
Matcher mm = DETAIL_CODE.matcher(d);
if (mm.matches() && code == 0) {
code = Integer.parseInt(mm.group(1));
}
if (message == null) {
message = d;
}
} else if (message == null) {
message = detail.length() > 300 ? detail.substring(0, 300) : detail;
}
}
} catch (IllegalArgumentException notJson) {
// fall back to the HTTP status
}
if (message == null || message.isEmpty()) {
message = "HTTP " + r.status;
}
long retryAfter = parseRetryAfter(r.header("Retry-After"), System.currentTimeMillis());
return new CompatError(code, r.status, isRetryable(code, r.status), retryAfter, message, r.body, null);
}
private static String firstString(Map<String, String> m, String... keys) {
for (String k : keys) {
String v = m.get(k);
if (v != null && RawJson.isString(v)) {
return RawJson.unquote(v);
}
}
return null;
}
/** Retry-After in ms: delta seconds or an HTTP date; -1 when absent or unparsable. */
public static long parseRetryAfter(String value, long nowMs) {
if (value == null || value.trim().isEmpty()) {
return -1;
}
String v = value.trim();
try {
long s = Long.parseLong(v);
return s < 0 ? -1 : s * 1000L;
} catch (NumberFormatException ignored) {
// HTTP date
}
try {
SimpleDateFormat f = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US);
Date d = f.parse(v);
return Math.max(0, d.getTime() - nowMs);
} catch (ParseException e) {
return -1;
}
}
/** errId delivered in the compat error JSON (DESIGN 6.2). */
public static int errId(CompatError e) {
if (e.retryable) {
return CompatErrIds.RETRYABLE;
}
if (e.code != 0) {
return e.code;
}
return httpFallbackCode(e.httpStatus);
}
/** Representative platform code when a non-retryable response carried no business code. */
public static int httpFallbackCode(int status) {
String category = ErrorTable.HTTP_FALLBACK.get(status);
if (category == null) {
if (status >= 400 && status < 500) {
return ErrorTable.VALIDATION_ERROR;
}
if (status >= 500) {
return ErrorTable.INTERNAL_ERROR;
}
return ErrorTable.PROTOCOL_ERROR;
}
if ("AUTH".equals(category)) {
return ErrorTable.UNAUTHORIZED;
}
if ("PERMISSION".equals(category)) {
return ErrorTable.FORBIDDEN;
}
if ("NOT_FOUND".equals(category)) {
return ErrorTable.NOT_FOUND;
}
if ("CONFLICT".equals(category)) {
return ErrorTable.CONFLICT;
}
if ("RATE_LIMIT".equals(category)) {
return ErrorTable.TOO_MANY_REQUESTS;
}
if ("SERVER".equals(category)) {
return status == 501 ? ErrorTable.FEATURE_NOT_IMPLEMENTED : ErrorTable.INTERNAL_ERROR;
}
if ("UPSTREAM".equals(category)) {
return ErrorTable.UPSTREAM_SERVICE_ERROR;
}
if ("TIMEOUT".equals(category)) {
return CompatErrIds.RETRYABLE;
}
return ErrorTable.VALIDATION_ERROR;
}
/** Human readable message for the {@code error} field of the error JSON. */
public static String message(CompatError e, int errId) {
StringBuilder sb = new StringBuilder();
if (errId == CompatErrIds.RETRYABLE) {
sb.append(CompatErrIds.message(errId)).append(": ").append(e.describe());
if (e.getMessage() != null && !e.getMessage().isEmpty()) {
sb.append(' ').append(e.getMessage());
}
if (e.getAttempts() > 0) {
sb.append(" (attempts ").append(e.getAttempts()).append(')');
}
return sb.toString();
}
if (e.getMessage() != null && !e.getMessage().isEmpty()) {
return e.getMessage();
}
return CompatErrIds.message(errId);
}
}