feat(bugcollect): add ingestion backpressure
这个提交包含在:
父节点
228c87f5da
当前提交
3a48de3417
@ -143,6 +143,26 @@ POST /bugcollect/v1/issues/batch
|
|||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 摄取过载
|
||||||
|
|
||||||
|
`issues/batch` 与 `events/batch` 单批最多 30 条。平台触发容量保护时返回:
|
||||||
|
|
||||||
|
```http
|
||||||
|
HTTP/1.1 429 Too Many Requests
|
||||||
|
Retry-After: 27
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 429,
|
||||||
|
"message": "BUGCOLLECT_RATE_LIMITED",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
SDK 应保留本地队列,在 `Retry-After` 指定时间后重试;不得把该响应解释为永久停用。
|
||||||
|
|
||||||
#### 请求体
|
#### 请求体
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@ -0,0 +1,35 @@
|
|||||||
|
# BugCollect 摄取防雪崩设计
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
线上同一故障可能在短时间内由大量设备同时触发。摄取链路必须保护数据库和服务线程,
|
||||||
|
同时不能让 BugCollect 故障阻断宿主 App。
|
||||||
|
|
||||||
|
## 权威链路
|
||||||
|
|
||||||
|
1. SDK 本地按指纹采样和限频,批量上报最多 30 条。
|
||||||
|
2. 服务端按 `appKey` 统计固定窗口内的事件数,默认每分钟 6000 条。
|
||||||
|
3. 多实例部署使用 Redis Lua 原子计数;Redis 不可用时退回进程内窗口,继续保护当前实例。
|
||||||
|
4. 超限返回 HTTP 429、`message=BUGCOLLECT_RATE_LIMITED` 和标准
|
||||||
|
`Retry-After` 秒数。
|
||||||
|
5. RN/Android SDK 保留原队列,按 `Retry-After` 冷却,最长一小时;冷却期间不重复请求。
|
||||||
|
6. `BUGCOLLECT_DISABLED` 仍表示租户关闭服务,SDK 清队列并停用。它与临时限流不能混用。
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
| 环境变量 | 默认值 | 说明 |
|
||||||
|
|---|---:|---|
|
||||||
|
| `BUGCOLLECT_MAX_EVENTS_PER_MINUTE` | `6000` | 单个 appKey 每个窗口允许的事件数,最小 30 |
|
||||||
|
| `BUGCOLLECT_RATE_WINDOW_SECONDS` | `60` | 固定窗口长度,最小 10 秒 |
|
||||||
|
|
||||||
|
限流属于平台容量保护,不是租户发布配置。租户平台不向开发者暴露“上传网络建议”或
|
||||||
|
让租户关闭服务端保护。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
- 单批 31 条必须在 Bean Validation 阶段拒绝。
|
||||||
|
- Redis 可用时,多实例共享同一 appKey 计数。
|
||||||
|
- Redis 不可用时,单实例超过本地额度仍返回 429。
|
||||||
|
- 429 必须携带正整数 `Retry-After`。
|
||||||
|
- SDK 收到 429 后不得丢队列、立即重试或向宿主业务线程抛未处理异常。
|
||||||
|
- 服务关闭返回 403 `BUGCOLLECT_DISABLED`,行为不受限流改动影响。
|
||||||
@ -3,6 +3,7 @@ package com.xuqm.bugcollect.controller;
|
|||||||
import com.xuqm.common.exception.BusinessException;
|
import com.xuqm.common.exception.BusinessException;
|
||||||
import com.xuqm.common.model.ApiResponse;
|
import com.xuqm.common.model.ApiResponse;
|
||||||
import com.xuqm.bugcollect.service.BugCollectDisabledException;
|
import com.xuqm.bugcollect.service.BugCollectDisabledException;
|
||||||
|
import com.xuqm.bugcollect.service.IngestionRateLimitedException;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@ -30,6 +31,13 @@ public class GlobalExceptionHandler {
|
|||||||
.body(ApiResponse.error(403, BugCollectDisabledException.ERROR_CODE));
|
.body(ApiResponse.error(403, BugCollectDisabledException.ERROR_CODE));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(IngestionRateLimitedException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handle(IngestionRateLimitedException ex) {
|
||||||
|
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||||
|
.header("Retry-After", Long.toString(ex.retryAfterSeconds()))
|
||||||
|
.body(ApiResponse.error(429, IngestionRateLimitedException.ERROR_CODE));
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(BusinessException.class)
|
@ExceptionHandler(BusinessException.class)
|
||||||
public ResponseEntity<ApiResponse<Void>> handle(BusinessException ex, HttpServletRequest request) {
|
public ResponseEntity<ApiResponse<Void>> handle(BusinessException ex, HttpServletRequest request) {
|
||||||
if (ex.getCode() >= 500) {
|
if (ex.getCode() >= 500) {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import com.xuqm.bugcollect.service.LogService;
|
|||||||
import com.xuqm.bugcollect.service.SourcemapService;
|
import com.xuqm.bugcollect.service.SourcemapService;
|
||||||
import com.xuqm.bugcollect.service.WebhookService;
|
import com.xuqm.bugcollect.service.WebhookService;
|
||||||
import com.xuqm.bugcollect.service.BugCollectAvailabilityService;
|
import com.xuqm.bugcollect.service.BugCollectAvailabilityService;
|
||||||
|
import com.xuqm.bugcollect.service.IngestionRateLimiter;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.security.access.AccessDeniedException;
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
@ -25,20 +26,28 @@ public class LogController {
|
|||||||
private final SourcemapService sourcemapService;
|
private final SourcemapService sourcemapService;
|
||||||
private final WebhookService webhookService;
|
private final WebhookService webhookService;
|
||||||
private final BugCollectAvailabilityService availabilityService;
|
private final BugCollectAvailabilityService availabilityService;
|
||||||
|
private final IngestionRateLimiter ingestionRateLimiter;
|
||||||
|
|
||||||
public LogController(LogService logService, SourcemapService sourcemapService,
|
public LogController(LogService logService, SourcemapService sourcemapService,
|
||||||
WebhookService webhookService,
|
WebhookService webhookService,
|
||||||
BugCollectAvailabilityService availabilityService) {
|
BugCollectAvailabilityService availabilityService,
|
||||||
|
IngestionRateLimiter ingestionRateLimiter) {
|
||||||
this.logService = logService;
|
this.logService = logService;
|
||||||
this.sourcemapService = sourcemapService;
|
this.sourcemapService = sourcemapService;
|
||||||
this.webhookService = webhookService;
|
this.webhookService = webhookService;
|
||||||
this.availabilityService = availabilityService;
|
this.availabilityService = availabilityService;
|
||||||
|
this.ingestionRateLimiter = ingestionRateLimiter;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Ingestion ──────────────────────────────────────────────────────────────
|
// ── Ingestion ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@PostMapping("/issues/batch")
|
@PostMapping("/issues/batch")
|
||||||
public ApiResponse<Void> ingestIssues(@Valid @RequestBody IssueBatchRequest request) {
|
public ApiResponse<Void> ingestIssues(@Valid @RequestBody IssueBatchRequest request) {
|
||||||
|
request.events().stream()
|
||||||
|
.collect(java.util.stream.Collectors.groupingBy(
|
||||||
|
IssueBatchRequest.IssueEventItem::appKey,
|
||||||
|
java.util.stream.Collectors.counting()))
|
||||||
|
.forEach((appKey, count) -> ingestionRateLimiter.acquire(appKey, count.intValue()));
|
||||||
request.events().stream()
|
request.events().stream()
|
||||||
.map(item -> item.appKey() + "\n" + item.platform())
|
.map(item -> item.appKey() + "\n" + item.platform())
|
||||||
.distinct()
|
.distinct()
|
||||||
@ -52,6 +61,11 @@ public class LogController {
|
|||||||
|
|
||||||
@PostMapping("/events/batch")
|
@PostMapping("/events/batch")
|
||||||
public ApiResponse<Void> ingestEvents(@Valid @RequestBody EventBatchRequest request) {
|
public ApiResponse<Void> ingestEvents(@Valid @RequestBody EventBatchRequest request) {
|
||||||
|
request.events().stream()
|
||||||
|
.collect(java.util.stream.Collectors.groupingBy(
|
||||||
|
EventBatchRequest.EventItem::appKey,
|
||||||
|
java.util.stream.Collectors.counting()))
|
||||||
|
.forEach((appKey, count) -> ingestionRateLimiter.acquire(appKey, count.intValue()));
|
||||||
request.events().stream()
|
request.events().stream()
|
||||||
.map(item -> item.appKey() + "\n" + item.platform())
|
.map(item -> item.appKey() + "\n" + item.platform())
|
||||||
.distinct()
|
.distinct()
|
||||||
|
|||||||
@ -3,12 +3,13 @@ package com.xuqm.bugcollect.dto;
|
|||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotEmpty;
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public record EventBatchRequest(
|
public record EventBatchRequest(
|
||||||
@JsonProperty("sentAt") String sentAt,
|
@JsonProperty("sentAt") String sentAt,
|
||||||
@JsonProperty("sdk") IssueBatchRequest.SdkInfo sdk,
|
@JsonProperty("sdk") IssueBatchRequest.SdkInfo sdk,
|
||||||
@JsonProperty("events") @NotEmpty List<EventItem> events
|
@JsonProperty("events") @NotEmpty @Size(max = 30) List<EventItem> events
|
||||||
) {
|
) {
|
||||||
public record EventItem(
|
public record EventItem(
|
||||||
@JsonProperty("eventId") String eventId,
|
@JsonProperty("eventId") String eventId,
|
||||||
|
|||||||
@ -3,13 +3,14 @@ package com.xuqm.bugcollect.dto;
|
|||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotEmpty;
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
public record IssueBatchRequest(
|
public record IssueBatchRequest(
|
||||||
@JsonProperty("sentAt") String sentAt,
|
@JsonProperty("sentAt") String sentAt,
|
||||||
@JsonProperty("sdk") SdkInfo sdk,
|
@JsonProperty("sdk") SdkInfo sdk,
|
||||||
@JsonProperty("events") @NotEmpty List<IssueEventItem> events
|
@JsonProperty("events") @NotEmpty @Size(max = 30) List<IssueEventItem> events
|
||||||
) {
|
) {
|
||||||
public record SdkInfo(
|
public record SdkInfo(
|
||||||
String name,
|
String name,
|
||||||
|
|||||||
@ -0,0 +1,23 @@
|
|||||||
|
package com.xuqm.bugcollect.service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BugCollect 摄取保护触发时返回给 SDK 的稳定信号。
|
||||||
|
*
|
||||||
|
* <p>客户端收到该信号后应保留本地队列,并至少等待 {@link #retryAfterSeconds()}
|
||||||
|
* 后再尝试,不能把限流当作永久停用。</p>
|
||||||
|
*/
|
||||||
|
public final class IngestionRateLimitedException extends RuntimeException {
|
||||||
|
|
||||||
|
public static final String ERROR_CODE = "BUGCOLLECT_RATE_LIMITED";
|
||||||
|
|
||||||
|
private final long retryAfterSeconds;
|
||||||
|
|
||||||
|
public IngestionRateLimitedException(long retryAfterSeconds) {
|
||||||
|
super(ERROR_CODE);
|
||||||
|
this.retryAfterSeconds = Math.max(1, retryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public long retryAfterSeconds() {
|
||||||
|
return retryAfterSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
package com.xuqm.bugcollect.service;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 appKey 统计摄取事件数,防止线上同一错误集中爆发时压垮数据库。
|
||||||
|
*
|
||||||
|
* <p>Redis Lua 保证多实例计数原子;Redis 暂不可用时退回进程内固定窗口,
|
||||||
|
* 继续提供单实例保护,不把基础设施异常传播给 App。</p>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class IngestionRateLimiter {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(IngestionRateLimiter.class);
|
||||||
|
private static final DefaultRedisScript<Long> LIMIT_SCRIPT = new DefaultRedisScript<>("""
|
||||||
|
local current = redis.call('INCRBY', KEYS[1], ARGV[1])
|
||||||
|
if current == tonumber(ARGV[1]) then
|
||||||
|
redis.call('PEXPIRE', KEYS[1], ARGV[3])
|
||||||
|
end
|
||||||
|
if current > tonumber(ARGV[2]) then
|
||||||
|
return redis.call('PTTL', KEYS[1])
|
||||||
|
end
|
||||||
|
return 0
|
||||||
|
""", Long.class);
|
||||||
|
|
||||||
|
private final StringRedisTemplate redis;
|
||||||
|
private final long maxEventsPerWindow;
|
||||||
|
private final long windowMillis;
|
||||||
|
private final Clock clock;
|
||||||
|
private final Map<String, LocalWindow> localWindows = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public IngestionRateLimiter(
|
||||||
|
StringRedisTemplate redis,
|
||||||
|
@Value("${bugcollect-service.ingestion.max-events-per-minute:6000}") long maxEventsPerMinute,
|
||||||
|
@Value("${bugcollect-service.ingestion.window-seconds:60}") long windowSeconds) {
|
||||||
|
this(redis, maxEventsPerMinute, windowSeconds, Clock.systemUTC());
|
||||||
|
}
|
||||||
|
|
||||||
|
IngestionRateLimiter(
|
||||||
|
StringRedisTemplate redis,
|
||||||
|
long maxEventsPerMinute,
|
||||||
|
long windowSeconds,
|
||||||
|
Clock clock) {
|
||||||
|
this.redis = redis;
|
||||||
|
this.maxEventsPerWindow = Math.max(30, maxEventsPerMinute);
|
||||||
|
this.windowMillis = Math.max(10, windowSeconds) * 1_000L;
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void acquire(String appKey, int eventCount) {
|
||||||
|
if (appKey == null || appKey.isBlank() || eventCount <= 0) return;
|
||||||
|
String normalized = appKey.trim();
|
||||||
|
try {
|
||||||
|
Long retryAfterMillis = redis.execute(
|
||||||
|
LIMIT_SCRIPT,
|
||||||
|
List.of("xuqm:bugcollect:ingestion:" + normalized),
|
||||||
|
Integer.toString(eventCount),
|
||||||
|
Long.toString(maxEventsPerWindow),
|
||||||
|
Long.toString(windowMillis));
|
||||||
|
if (retryAfterMillis != null && retryAfterMillis > 0) {
|
||||||
|
throw new IngestionRateLimitedException(toSeconds(retryAfterMillis));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} catch (IngestionRateLimitedException ex) {
|
||||||
|
throw ex;
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
log.warn("BugCollect Redis 限流不可用,退回进程内保护: appKey={} cause={}",
|
||||||
|
normalized, ex.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
acquireLocally(normalized, eventCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void acquireLocally(String appKey, int eventCount) {
|
||||||
|
long now = clock.millis();
|
||||||
|
LocalWindow current = localWindows.compute(appKey, (key, previous) -> {
|
||||||
|
if (previous == null || now >= previous.endsAtMillis()) {
|
||||||
|
return new LocalWindow(eventCount, now + windowMillis);
|
||||||
|
}
|
||||||
|
return new LocalWindow(previous.count() + eventCount, previous.endsAtMillis());
|
||||||
|
});
|
||||||
|
if (current.count() > maxEventsPerWindow) {
|
||||||
|
throw new IngestionRateLimitedException(toSeconds(current.endsAtMillis() - now));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long toSeconds(long millis) {
|
||||||
|
return Math.max(1, (millis + 999L) / 1_000L);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record LocalWindow(long count, long endsAtMillis) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,9 +5,9 @@ spring:
|
|||||||
application:
|
application:
|
||||||
name: xuqm-bugcollect-service
|
name: xuqm-bugcollect-service
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:mysql://39.107.53.187:3306/xuqm_bugcollect?useUnicode=true&characterEncoding=utf8&useSSL=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
|
url: ${BUGCOLLECT_DB_URL:jdbc:mysql://127.0.0.1:3306/xuqm_bugcollect?useUnicode=true&characterEncoding=utf8&useSSL=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true}
|
||||||
username: xuqm
|
username: ${BUGCOLLECT_DB_USERNAME:xuqm}
|
||||||
password: Xuqm@2026
|
password: ${BUGCOLLECT_DB_PASSWORD:}
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
hikari:
|
hikari:
|
||||||
minimum-idle: 5
|
minimum-idle: 5
|
||||||
@ -25,10 +25,10 @@ spring:
|
|||||||
format_sql: true
|
format_sql: true
|
||||||
data:
|
data:
|
||||||
redis:
|
redis:
|
||||||
host: redisdev.xuqinmin.com
|
host: ${BUGCOLLECT_REDIS_HOST:127.0.0.1}
|
||||||
port: 6379
|
port: ${BUGCOLLECT_REDIS_PORT:6379}
|
||||||
password: xuqinmin1022
|
password: ${BUGCOLLECT_REDIS_PASSWORD:}
|
||||||
database: 2
|
database: ${BUGCOLLECT_REDIS_DATABASE:2}
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
lettuce:
|
lettuce:
|
||||||
pool:
|
pool:
|
||||||
@ -58,6 +58,9 @@ sdk:
|
|||||||
bugcollect-service:
|
bugcollect-service:
|
||||||
tenant-sdk-config-url: ${TENANT_SDK_CONFIG_URL:http://127.0.0.1:8081/api/sdk/config}
|
tenant-sdk-config-url: ${TENANT_SDK_CONFIG_URL:http://127.0.0.1:8081/api/sdk/config}
|
||||||
availability-cache-seconds: ${BUGCOLLECT_AVAILABILITY_CACHE_SECONDS:30}
|
availability-cache-seconds: ${BUGCOLLECT_AVAILABILITY_CACHE_SECONDS:30}
|
||||||
|
ingestion:
|
||||||
|
max-events-per-minute: ${BUGCOLLECT_MAX_EVENTS_PER_MINUTE:6000}
|
||||||
|
window-seconds: ${BUGCOLLECT_RATE_WINDOW_SECONDS:60}
|
||||||
sourcemap:
|
sourcemap:
|
||||||
storage-dir: ${LOG_SOURCEMAP_DIR:/data/bugcollect-service/sourcemaps}
|
storage-dir: ${LOG_SOURCEMAP_DIR:/data/bugcollect-service/sourcemaps}
|
||||||
webhook:
|
webhook:
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package com.xuqm.bugcollect.controller;
|
package com.xuqm.bugcollect.controller;
|
||||||
|
|
||||||
import com.xuqm.bugcollect.service.BugCollectDisabledException;
|
import com.xuqm.bugcollect.service.BugCollectDisabledException;
|
||||||
|
import com.xuqm.bugcollect.service.IngestionRateLimitedException;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@ -15,4 +16,14 @@ class GlobalExceptionHandlerTest {
|
|||||||
assertThat(response.getBody()).isNotNull();
|
assertThat(response.getBody()).isNotNull();
|
||||||
assertThat(response.getBody().message()).isEqualTo("BUGCOLLECT_DISABLED");
|
assertThat(response.getBody().message()).isEqualTo("BUGCOLLECT_DISABLED");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exposesRetryAfterForIngestionBackpressure() {
|
||||||
|
var response = new GlobalExceptionHandler().handle(new IngestionRateLimitedException(27));
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(429);
|
||||||
|
assertThat(response.getHeaders().getFirst("Retry-After")).isEqualTo("27");
|
||||||
|
assertThat(response.getBody()).isNotNull();
|
||||||
|
assertThat(response.getBody().message()).isEqualTo("BUGCOLLECT_RATE_LIMITED");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,46 @@
|
|||||||
|
package com.xuqm.bugcollect.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class IngestionRateLimiterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void usesDistributedRetryWindowReturnedByRedis() {
|
||||||
|
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||||
|
when(redis.execute(any(), any(), any(Object[].class)))
|
||||||
|
.thenReturn(0L, 27_000L);
|
||||||
|
IngestionRateLimiter limiter = new IngestionRateLimiter(
|
||||||
|
redis, 6_000, 60, Clock.systemUTC());
|
||||||
|
|
||||||
|
limiter.acquire("app-key", 30);
|
||||||
|
assertThatThrownBy(() -> limiter.acquire("app-key", 30))
|
||||||
|
.isInstanceOf(IngestionRateLimitedException.class)
|
||||||
|
.satisfies(error -> org.assertj.core.api.Assertions.assertThat(
|
||||||
|
((IngestionRateLimitedException) error).retryAfterSeconds()).isEqualTo(27));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fallsBackToLocalWindowWhenRedisIsUnavailable() {
|
||||||
|
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||||
|
when(redis.execute(any(), any(), any(Object[].class)))
|
||||||
|
.thenThrow(new IllegalStateException("redis unavailable"));
|
||||||
|
Clock clock = Clock.fixed(Instant.ofEpochMilli(10_000), ZoneOffset.UTC);
|
||||||
|
IngestionRateLimiter limiter = new IngestionRateLimiter(redis, 30, 60, clock);
|
||||||
|
|
||||||
|
limiter.acquire("app-key", 20);
|
||||||
|
assertThatThrownBy(() -> limiter.acquire("app-key", 11))
|
||||||
|
.isInstanceOf(IngestionRateLimitedException.class)
|
||||||
|
.satisfies(error -> org.assertj.core.api.Assertions.assertThat(
|
||||||
|
((IngestionRateLimitedException) error).retryAfterSeconds()).isEqualTo(60));
|
||||||
|
}
|
||||||
|
}
|
||||||
正在加载...
在新工单中引用
屏蔽一个用户