Browse Source
- HttpTransport 接口 + OkHttpTransport(连接 5s / 读 30s / 统一 UA) - HttpRequest / HttpResponse 保留原始 header 与 body 供留痕 - JsonCodec + JacksonJsonCodec(点路径取值、数组反序列化) - Signer 接口:签名差异收敛到各适配器 - TokenManager:按账户隔离、提前续期、LockProvider 防击穿、 刷新失败不返回旧 token、日志只打指纹 - Responses:401→Auth、429→RateLimit(带 Retry-After)、5xx→Transient、 其他 4xx→Validation Co-Authored-By: Claude <noreply@anthropic.com>master
10 changed files with 555 additions and 0 deletions
@ -0,0 +1,110 @@ |
|||||||
|
package apelet.common.ecommerce.http; |
||||||
|
|
||||||
|
import java.util.Collections; |
||||||
|
import java.util.LinkedHashMap; |
||||||
|
import java.util.Map; |
||||||
|
import java.util.Objects; |
||||||
|
|
||||||
|
/** |
||||||
|
* 统一请求对象。保留原始 header/body 供留痕。 |
||||||
|
*/ |
||||||
|
public final class HttpRequest { |
||||||
|
|
||||||
|
private final String method; |
||||||
|
private final String url; |
||||||
|
private final Map<String, String> headers; |
||||||
|
private final String body; |
||||||
|
private final String operation; |
||||||
|
|
||||||
|
private HttpRequest(Builder b) { |
||||||
|
this.method = Objects.requireNonNull(b.method, "method"); |
||||||
|
this.url = Objects.requireNonNull(b.url, "url"); |
||||||
|
this.headers = Collections.unmodifiableMap(new LinkedHashMap<String, String>(b.headers)); |
||||||
|
this.body = b.body; |
||||||
|
this.operation = b.operation; |
||||||
|
} |
||||||
|
|
||||||
|
public static Builder builder() { |
||||||
|
return new Builder(); |
||||||
|
} |
||||||
|
|
||||||
|
public static HttpRequest get(String url) { |
||||||
|
return builder().method("GET").url(url).build(); |
||||||
|
} |
||||||
|
|
||||||
|
public static HttpRequest post(String url, String body) { |
||||||
|
return builder().method("POST").url(url).body(body).build(); |
||||||
|
} |
||||||
|
|
||||||
|
public String method() { |
||||||
|
return method; |
||||||
|
} |
||||||
|
|
||||||
|
public String url() { |
||||||
|
return url; |
||||||
|
} |
||||||
|
|
||||||
|
public Map<String, String> headers() { |
||||||
|
return headers; |
||||||
|
} |
||||||
|
|
||||||
|
public String body() { |
||||||
|
return body; |
||||||
|
} |
||||||
|
|
||||||
|
/** 操作名,如 salesOrders.pull / token.refresh。 */ |
||||||
|
public String operation() { |
||||||
|
return operation; |
||||||
|
} |
||||||
|
|
||||||
|
public Builder toBuilder() { |
||||||
|
return builder().method(method).url(url).headers(headers).body(body).operation(operation); |
||||||
|
} |
||||||
|
|
||||||
|
public static final class Builder { |
||||||
|
|
||||||
|
private String method; |
||||||
|
private String url; |
||||||
|
private Map<String, String> headers = new LinkedHashMap<String, String>(); |
||||||
|
private String body; |
||||||
|
private String operation; |
||||||
|
|
||||||
|
public Builder method(String method) { |
||||||
|
this.method = method; |
||||||
|
return this; |
||||||
|
} |
||||||
|
|
||||||
|
public Builder url(String url) { |
||||||
|
this.url = url; |
||||||
|
return this; |
||||||
|
} |
||||||
|
|
||||||
|
public Builder header(String name, String value) { |
||||||
|
if (value != null) { |
||||||
|
this.headers.put(name, value); |
||||||
|
} |
||||||
|
return this; |
||||||
|
} |
||||||
|
|
||||||
|
public Builder headers(Map<String, String> headers) { |
||||||
|
if (headers != null) { |
||||||
|
this.headers.putAll(headers); |
||||||
|
} |
||||||
|
return this; |
||||||
|
} |
||||||
|
|
||||||
|
public Builder body(String body) { |
||||||
|
this.body = body; |
||||||
|
return this; |
||||||
|
} |
||||||
|
|
||||||
|
public Builder operation(String operation) { |
||||||
|
this.operation = operation; |
||||||
|
return this; |
||||||
|
} |
||||||
|
|
||||||
|
public HttpRequest build() { |
||||||
|
return new HttpRequest(this); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,69 @@ |
|||||||
|
package apelet.common.ecommerce.http; |
||||||
|
|
||||||
|
import java.util.Collections; |
||||||
|
import java.util.LinkedHashMap; |
||||||
|
import java.util.Map; |
||||||
|
|
||||||
|
/** |
||||||
|
* 统一响应对象。保留原始 header/body 供留痕与错误映射。 |
||||||
|
*/ |
||||||
|
public final class HttpResponse { |
||||||
|
|
||||||
|
private final int statusCode; |
||||||
|
private final Map<String, String> headers; |
||||||
|
private final String body; |
||||||
|
private final long latencyMillis; |
||||||
|
|
||||||
|
private HttpResponse(int statusCode, Map<String, String> headers, String body, long latencyMillis) { |
||||||
|
this.statusCode = statusCode; |
||||||
|
this.headers = Collections.unmodifiableMap(new LinkedHashMap<String, String>(headers)); |
||||||
|
this.body = body; |
||||||
|
this.latencyMillis = latencyMillis; |
||||||
|
} |
||||||
|
|
||||||
|
public static HttpResponse of(int statusCode, Map<String, String> headers, String body, long latencyMillis) { |
||||||
|
return new HttpResponse(statusCode, headers == null ? Collections.<String, String>emptyMap() : headers, |
||||||
|
body, latencyMillis); |
||||||
|
} |
||||||
|
|
||||||
|
public int statusCode() { |
||||||
|
return statusCode; |
||||||
|
} |
||||||
|
|
||||||
|
public Map<String, String> headers() { |
||||||
|
return headers; |
||||||
|
} |
||||||
|
|
||||||
|
public String body() { |
||||||
|
return body; |
||||||
|
} |
||||||
|
|
||||||
|
public long latencyMillis() { |
||||||
|
return latencyMillis; |
||||||
|
} |
||||||
|
|
||||||
|
public boolean isSuccess() { |
||||||
|
return statusCode >= 200 && statusCode < 300; |
||||||
|
} |
||||||
|
|
||||||
|
public boolean isAuthFailure() { |
||||||
|
return statusCode == 401 || statusCode == 403; |
||||||
|
} |
||||||
|
|
||||||
|
public boolean isRateLimited() { |
||||||
|
return statusCode == 429; |
||||||
|
} |
||||||
|
|
||||||
|
public boolean isServerError() { |
||||||
|
return statusCode >= 500; |
||||||
|
} |
||||||
|
|
||||||
|
public String header(String name) { |
||||||
|
for (Map.Entry<String, String> e : headers.entrySet()) { |
||||||
|
if (e.getKey().equalsIgnoreCase(name)) { |
||||||
|
return e.getValue(); |
||||||
|
} |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,9 @@ |
|||||||
|
package apelet.common.ecommerce.http; |
||||||
|
|
||||||
|
/** |
||||||
|
* HTTP 传输接口。默认实现 OkHttp,可替换为 Apache HttpClient 或测试用 Fake。 |
||||||
|
*/ |
||||||
|
public interface HttpTransport { |
||||||
|
|
||||||
|
HttpResponse execute(HttpRequest request); |
||||||
|
} |
||||||
@ -0,0 +1,86 @@ |
|||||||
|
package apelet.common.ecommerce.http; |
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature; |
||||||
|
import com.fasterxml.jackson.databind.JsonNode; |
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||||
|
import com.fasterxml.jackson.databind.node.ArrayNode; |
||||||
|
|
||||||
|
import java.util.ArrayList; |
||||||
|
import java.util.List; |
||||||
|
|
||||||
|
/** |
||||||
|
* Jackson 实现。 |
||||||
|
*/ |
||||||
|
public final class JacksonJsonCodec implements JsonCodec { |
||||||
|
|
||||||
|
private final ObjectMapper mapper; |
||||||
|
|
||||||
|
public JacksonJsonCodec() { |
||||||
|
this.mapper = new ObjectMapper() |
||||||
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); |
||||||
|
} |
||||||
|
|
||||||
|
public ObjectMapper mapper() { |
||||||
|
return mapper; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String toJson(Object value) { |
||||||
|
try { |
||||||
|
return mapper.writeValueAsString(value); |
||||||
|
} catch (Exception e) { |
||||||
|
throw new IllegalArgumentException("serialize failed", e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public <T> T fromJson(String json, Class<T> type) { |
||||||
|
try { |
||||||
|
return mapper.readValue(json, type); |
||||||
|
} catch (Exception e) { |
||||||
|
throw new IllegalArgumentException("deserialize failed for " + type.getSimpleName(), e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String stringAt(String json, String pointer) { |
||||||
|
JsonNode node = nodeAt(json, pointer); |
||||||
|
return node == null || node.isNull() ? null : node.asText(); |
||||||
|
} |
||||||
|
|
||||||
|
/** 按点路径取节点,如 {@code data.list}。数组下标用数字段。 */ |
||||||
|
public JsonNode nodeAt(String json, String pointer) { |
||||||
|
if (json == null || pointer == null || pointer.isEmpty()) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
try { |
||||||
|
JsonNode cur = mapper.readTree(json); |
||||||
|
for (String seg : pointer.split("\\.")) { |
||||||
|
if (cur == null || cur.isNull()) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
if (cur.isArray() && seg.matches("\\d+")) { |
||||||
|
cur = cur.get(Integer.parseInt(seg)); |
||||||
|
} else { |
||||||
|
cur = cur.get(seg); |
||||||
|
} |
||||||
|
} |
||||||
|
return cur; |
||||||
|
} catch (Exception e) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** 把点路径下的数组节点反序列化为列表。 */ |
||||||
|
public <T> List<T> listAt(String json, String pointer, Class<T> type) { |
||||||
|
JsonNode node = nodeAt(json, pointer); |
||||||
|
if (node == null || !node.isArray()) { |
||||||
|
return new ArrayList<T>(); |
||||||
|
} |
||||||
|
List<T> out = new ArrayList<T>(node.size()); |
||||||
|
for (JsonNode item : (ArrayNode) node) { |
||||||
|
out.add(mapper.convertValue(item, type)); |
||||||
|
} |
||||||
|
return out; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
package apelet.common.ecommerce.http; |
||||||
|
|
||||||
|
/** |
||||||
|
* 够用的最小 JSON 读写抽象,避免 core 层绑死 Jackson。 |
||||||
|
*/ |
||||||
|
public interface JsonCodec { |
||||||
|
|
||||||
|
String toJson(Object value); |
||||||
|
|
||||||
|
<T> T fromJson(String json, Class<T> type); |
||||||
|
|
||||||
|
/** |
||||||
|
* 按点路径取字段值,用于解析平台响应里的 data.list 这类结构。 |
||||||
|
* 找不到返回 null。 |
||||||
|
*/ |
||||||
|
String stringAt(String json, String pointer); |
||||||
|
} |
||||||
@ -0,0 +1,70 @@ |
|||||||
|
package apelet.common.ecommerce.http; |
||||||
|
|
||||||
|
import apelet.common.ecommerce.error.TransientException; |
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit; |
||||||
|
|
||||||
|
/** |
||||||
|
* OkHttp 实现。连接池、超时、统一 User-Agent。 |
||||||
|
*/ |
||||||
|
public final class OkHttpTransport implements HttpTransport { |
||||||
|
|
||||||
|
private final okhttp3.OkHttpClient client; |
||||||
|
private final String userAgent; |
||||||
|
|
||||||
|
public OkHttpTransport(String userAgent) { |
||||||
|
this(userAgent, 5_000, 30_000); |
||||||
|
} |
||||||
|
|
||||||
|
public OkHttpTransport(String userAgent, long connectTimeoutMillis, long readTimeoutMillis) { |
||||||
|
this.userAgent = userAgent; |
||||||
|
this.client = new okhttp3.OkHttpClient.Builder() |
||||||
|
.connectTimeout(connectTimeoutMillis, TimeUnit.MILLISECONDS) |
||||||
|
.readTimeout(readTimeoutMillis, TimeUnit.MILLISECONDS) |
||||||
|
.writeTimeout(readTimeoutMillis, TimeUnit.MILLISECONDS) |
||||||
|
.build(); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public HttpResponse execute(HttpRequest request) { |
||||||
|
long start = System.nanoTime(); |
||||||
|
okhttp3.Request.Builder rb = new okhttp3.Request.Builder() |
||||||
|
.url(request.url()) |
||||||
|
.header("User-Agent", userAgent); |
||||||
|
for (java.util.Map.Entry<String, String> e : request.headers().entrySet()) { |
||||||
|
rb.header(e.getKey(), e.getValue()); |
||||||
|
} |
||||||
|
|
||||||
|
String method = request.method().toUpperCase(); |
||||||
|
okhttp3.RequestBody body = null; |
||||||
|
if (request.body() != null) { |
||||||
|
body = okhttp3.RequestBody.create(request.body(), |
||||||
|
okhttp3.MediaType.parse("application/json; charset=utf-8")); |
||||||
|
} |
||||||
|
if ("POST".equals(method)) { |
||||||
|
rb.post(body == null ? okhttp3.RequestBody.create(new byte[0], null) : body); |
||||||
|
} else if ("PUT".equals(method)) { |
||||||
|
rb.put(body == null ? okhttp3.RequestBody.create(new byte[0], null) : body); |
||||||
|
} else if ("DELETE".equals(method)) { |
||||||
|
rb.delete(); |
||||||
|
} else { |
||||||
|
rb.get(); |
||||||
|
} |
||||||
|
|
||||||
|
try (okhttp3.Response resp = client.newCall(rb.build()).execute()) { |
||||||
|
long latency = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); |
||||||
|
String respBody = resp.body() == null ? null : resp.body().string(); |
||||||
|
java.util.Map<String, String> headers = new java.util.LinkedHashMap<String, String>(); |
||||||
|
for (String name : resp.headers().names()) { |
||||||
|
headers.put(name, resp.header(name)); |
||||||
|
} |
||||||
|
return HttpResponse.of(resp.code(), headers, respBody, latency); |
||||||
|
} catch (java.io.IOException e) { |
||||||
|
throw TransientException.class.cast(new TransientException( |
||||||
|
apelet.common.ecommerce.error.ChannelException.builder() |
||||||
|
.message("http transport failed: " + e.getMessage()) |
||||||
|
.operation(request.operation()) |
||||||
|
.retryable(true), e)); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,56 @@ |
|||||||
|
package apelet.common.ecommerce.http; |
||||||
|
|
||||||
|
import apelet.common.ecommerce.error.AuthException; |
||||||
|
import apelet.common.ecommerce.error.ChannelException; |
||||||
|
import apelet.common.ecommerce.error.RateLimitException; |
||||||
|
import apelet.common.ecommerce.error.TransientException; |
||||||
|
|
||||||
|
/** |
||||||
|
* 响应判定与重试分类。 |
||||||
|
* |
||||||
|
* <ul> |
||||||
|
* <li>2xx:成功</li> |
||||||
|
* <li>401/403:{@link AuthException},刷新后重试一次</li> |
||||||
|
* <li>429:{@link RateLimitException},遵守 Retry-After</li> |
||||||
|
* <li>5xx / 网络:{@link TransientException},退避加抖动</li> |
||||||
|
* <li>其他 4xx:业务错误,不在此层重试</li> |
||||||
|
* </ul> |
||||||
|
*/ |
||||||
|
public final class Responses { |
||||||
|
|
||||||
|
private Responses() { |
||||||
|
} |
||||||
|
|
||||||
|
public static void throwIfNotSuccess(HttpResponse response, String operation) { |
||||||
|
if (response.isSuccess()) { |
||||||
|
return; |
||||||
|
} |
||||||
|
ChannelException.Builder b = ChannelException.builder() |
||||||
|
.message("http " + response.statusCode() + " on " + operation) |
||||||
|
.operation(operation) |
||||||
|
.platformCode(String.valueOf(response.statusCode())) |
||||||
|
.rawResponse(response.body()); |
||||||
|
|
||||||
|
if (response.isAuthFailure()) { |
||||||
|
throw new AuthException(b); |
||||||
|
} |
||||||
|
if (response.isRateLimited()) { |
||||||
|
throw new RateLimitException(b, parseRetryAfterMillis(response.header("Retry-After"))); |
||||||
|
} |
||||||
|
if (response.isServerError()) { |
||||||
|
throw new TransientException(b.retryable(true), null); |
||||||
|
} |
||||||
|
throw new apelet.common.ecommerce.error.ValidationException(b); |
||||||
|
} |
||||||
|
|
||||||
|
static long parseRetryAfterMillis(String raw) { |
||||||
|
if (raw == null || raw.trim().isEmpty()) { |
||||||
|
return 1_000L; |
||||||
|
} |
||||||
|
try { |
||||||
|
return Math.max(0L, Long.parseLong(raw.trim()) * 1000L); |
||||||
|
} catch (NumberFormatException e) { |
||||||
|
return 1_000L; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,10 @@ |
|||||||
|
package apelet.common.ecommerce.http; |
||||||
|
|
||||||
|
/** |
||||||
|
* 签名器。按平台实现,差异收敛在适配器内。 |
||||||
|
*/ |
||||||
|
public interface Signer { |
||||||
|
|
||||||
|
/** 在请求上补齐签名相关的 header / query。 */ |
||||||
|
HttpRequest sign(HttpRequest request, apelet.common.ecommerce.spi.ChannelSecret secret); |
||||||
|
} |
||||||
@ -0,0 +1,85 @@ |
|||||||
|
package apelet.common.ecommerce.http; |
||||||
|
|
||||||
|
import apelet.common.ecommerce.spi.AccessToken; |
||||||
|
import apelet.common.ecommerce.spi.ChannelSecret; |
||||||
|
import apelet.common.ecommerce.spi.LockProvider; |
||||||
|
import apelet.common.ecommerce.spi.TokenStore; |
||||||
|
import org.slf4j.Logger; |
||||||
|
import org.slf4j.LoggerFactory; |
||||||
|
|
||||||
|
import java.time.Clock; |
||||||
|
import java.time.Instant; |
||||||
|
import java.util.Optional; |
||||||
|
|
||||||
|
/** |
||||||
|
* 令牌管理模板。 |
||||||
|
* |
||||||
|
* <ul> |
||||||
|
* <li>按账户隔离缓存</li> |
||||||
|
* <li>提前 {@code earlyRefreshSeconds} 续期</li> |
||||||
|
* <li>刷新用 {@link LockProvider} 防击穿</li> |
||||||
|
* <li>刷新失败不返回旧 token(除显式宽限窗口)</li> |
||||||
|
* <li>日志只打印指纹</li> |
||||||
|
* </ul> |
||||||
|
*/ |
||||||
|
public abstract class TokenManager { |
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(TokenManager.class); |
||||||
|
|
||||||
|
protected final TokenStore tokenStore; |
||||||
|
protected final LockProvider lockProvider; |
||||||
|
protected final Clock clock; |
||||||
|
private final long earlyRefreshSeconds; |
||||||
|
|
||||||
|
protected TokenManager(TokenStore tokenStore, LockProvider lockProvider, Clock clock, long earlyRefreshSeconds) { |
||||||
|
this.tokenStore = tokenStore; |
||||||
|
this.lockProvider = lockProvider; |
||||||
|
this.clock = clock; |
||||||
|
this.earlyRefreshSeconds = earlyRefreshSeconds; |
||||||
|
} |
||||||
|
|
||||||
|
/** 拿到可用的 access token;必要时刷新。 */ |
||||||
|
public AccessToken getOrRefresh(String tokenKey, ChannelSecret secret) { |
||||||
|
Instant now = Instant.now(clock); |
||||||
|
Optional<AccessToken> cached = tokenStore.get(tokenKey); |
||||||
|
if (cached.isPresent() && !cached.get().needsRefresh(now, earlyRefreshSeconds)) { |
||||||
|
return cached.get(); |
||||||
|
} |
||||||
|
|
||||||
|
Optional<LockProvider.Lock> lock = lockProvider.tryLock("token-refresh:" + tokenKey, 30); |
||||||
|
if (!lock.isPresent()) { |
||||||
|
// 别的线程在刷新:再读一次,读不到就短等待后重读
|
||||||
|
Optional<AccessToken> again = tokenStore.get(tokenKey); |
||||||
|
if (again.isPresent() && !again.get().needsRefresh(now, earlyRefreshSeconds)) { |
||||||
|
return again.get(); |
||||||
|
} |
||||||
|
throw new apelet.common.ecommerce.error.AuthException( |
||||||
|
apelet.common.ecommerce.error.ChannelException.builder() |
||||||
|
.message("token refresh in progress for " + tokenKey) |
||||||
|
.operation("token.refresh")); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
Optional<AccessToken> again = tokenStore.get(tokenKey); |
||||||
|
Instant nowInLock = Instant.now(clock); |
||||||
|
if (again.isPresent() && !again.get().needsRefresh(nowInLock, earlyRefreshSeconds)) { |
||||||
|
return again.get(); |
||||||
|
} |
||||||
|
AccessToken refreshed = refresh(secret, cached.orElse(null)); |
||||||
|
tokenStore.save(tokenKey, refreshed); |
||||||
|
LOG.info("token refreshed for {} [{}]", tokenKey, refreshed.fingerprint()); |
||||||
|
return refreshed; |
||||||
|
} finally { |
||||||
|
lock.get().close(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** 401 后作废并强制重取一次。 */ |
||||||
|
public AccessToken forceRefresh(String tokenKey, ChannelSecret secret) { |
||||||
|
tokenStore.remove(tokenKey); |
||||||
|
return getOrRefresh(tokenKey, secret); |
||||||
|
} |
||||||
|
|
||||||
|
/** 由平台实现:向平台换取新令牌。 */ |
||||||
|
protected abstract AccessToken refresh(ChannelSecret secret, AccessToken previous); |
||||||
|
} |
||||||
@ -0,0 +1,43 @@ |
|||||||
|
package apelet.common.ecommerce.http; |
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test; |
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals; |
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows; |
||||||
|
|
||||||
|
class ResponsesTest { |
||||||
|
|
||||||
|
@Test |
||||||
|
void successPasses() { |
||||||
|
Responses.throwIfNotSuccess(HttpResponse.of(200, null, "{}", 1), "op"); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void authMapsToAuthException() { |
||||||
|
assertThrows(apelet.common.ecommerce.error.AuthException.class, |
||||||
|
() -> Responses.throwIfNotSuccess(HttpResponse.of(401, null, "{}", 1), "op")); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void rateLimitCarriesRetryAfter() { |
||||||
|
apelet.common.ecommerce.error.RateLimitException ex = assertThrows( |
||||||
|
apelet.common.ecommerce.error.RateLimitException.class, |
||||||
|
() -> Responses.throwIfNotSuccess( |
||||||
|
HttpResponse.of(429, java.util.Collections.singletonMap("Retry-After", "3"), "", 1), "op")); |
||||||
|
assertEquals(3000L, ex.retryAfterMillis()); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void serverErrorIsRetryable() { |
||||||
|
apelet.common.ecommerce.error.TransientException ex = assertThrows( |
||||||
|
apelet.common.ecommerce.error.TransientException.class, |
||||||
|
() -> Responses.throwIfNotSuccess(HttpResponse.of(503, null, "", 1), "op")); |
||||||
|
assertEquals(true, ex.retryable()); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void badRequestIsValidation() { |
||||||
|
assertThrows(apelet.common.ecommerce.error.ValidationException.class, |
||||||
|
() -> Responses.throwIfNotSuccess(HttpResponse.of(400, null, "", 1), "op")); |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue