Redis timeout 200ms давал ложные срабатывания под пиковой нагрузкой на общем хосте — подняли до 800ms и добавили cpu/mem лимиты сервисам в compose, чтобы соседи не выедали CPU у Redis. Добавили метрику и WARN на случай, когда демаскирование не находит соответствие ни по id, ни по отпечатку маски (раньше тихо превращалось в повторное маскирование без единого следа в логах). Кластерные узлы (node-a/node-b) получили обе NER-модели (WikiNEuRal для имён, ruBERT для адресов) — раньше конфиг ссылался на несуществующие свойства и вторая ступень молча не работала. lb (nginx) и volume для prometheus/grafana данных зафиксированы в compose. Плюс код-ревью фиксы: утечка нативных ONNX-ресурсов при ошибке загрузки модели (BLOCKER), неверный HTTP-статус при сбое обработки, generic Exception в LlmClient заменён на конкретные, лишние same-package импорты убраны.
134 lines
7.5 KiB
Java
134 lines
7.5 KiB
Java
package ru.pdguard.api;
|
||
|
||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||
import io.micrometer.core.instrument.Counter;
|
||
import io.micrometer.core.instrument.MeterRegistry;
|
||
import org.slf4j.Logger;
|
||
import org.slf4j.LoggerFactory;
|
||
import org.springframework.beans.factory.annotation.Value;
|
||
import org.springframework.http.HttpStatus;
|
||
import org.springframework.http.ResponseEntity;
|
||
import org.springframework.web.bind.annotation.PostMapping;
|
||
import org.springframework.web.bind.annotation.RequestBody;
|
||
import org.springframework.web.bind.annotation.RequestHeader;
|
||
import org.springframework.web.bind.annotation.RestController;
|
||
import ru.pdguard.config.SystemPolicy;
|
||
import ru.pdguard.config.SystemsConfig;
|
||
import ru.pdguard.core.AdaptiveConcurrencyLimiter;
|
||
import ru.pdguard.core.Pipeline;
|
||
|
||
/**
|
||
* Единственная точка входа контракта: маскирование и демаскирование по
|
||
* {@code payload_id}.
|
||
*
|
||
* <p>Система-потребитель называет себя заголовком {@code X-System-Id}. Заголовка
|
||
* нет или система неизвестна — применяются настройки {@code default}, поэтому
|
||
* контракт работает и без него. Система, выключенная в настройках, получает
|
||
* {@code 403}.
|
||
*
|
||
* <p>При перегрузке отвечает {@code 429} с {@code Retry-After}. Порог перегрузки —
|
||
* не фиксированное число запросов, а задержка обработки: {@link AdaptiveConcurrencyLimiter}
|
||
* сам находит потолок конкурентности под то, сколько CPU реально досталось контейнеру,
|
||
* вместо того чтобы копить запросы и упереться в таймаут вызывающей стороны.
|
||
*/
|
||
@RestController
|
||
public class ProcessResource {
|
||
|
||
private static final Logger LOG = LoggerFactory.getLogger(ProcessResource.class);
|
||
|
||
/** Заголовок, которым система-потребитель себя называет. */
|
||
public static final String SYSTEM_HEADER = "X-System-Id";
|
||
|
||
/** Общий секрет системы. Проверяется, только если он задан в настройках. */
|
||
public static final String KEY_HEADER = "X-System-Key";
|
||
|
||
/** Имя метрики отклонённых запросов и имя её метки причины. */
|
||
private static final String REJECTED_METRIC = "pdguard.requests.rejected";
|
||
private static final String REASON_TAG = "reason";
|
||
|
||
/**
|
||
* Что отдаётся при внутреннем сбое. Ни одного знака из запроса: сбой на прямом
|
||
* шаге иначе выпустил бы наружу незамаскированные персональные данные.
|
||
*/
|
||
static final String PROCESSING_UNAVAILABLE = "[обработка недоступна]";
|
||
|
||
public record ProcessRequest(
|
||
@JsonProperty("payload") String payload,
|
||
@JsonProperty("payload_id") String payloadId) {
|
||
}
|
||
|
||
public record ProcessResponse(@JsonProperty("result") String result) {
|
||
}
|
||
|
||
private final Pipeline pipeline;
|
||
private final SystemsConfig systems;
|
||
private final AdaptiveConcurrencyLimiter limiter;
|
||
private final Counter rejected;
|
||
private final Counter malformed;
|
||
private final Counter forbidden;
|
||
private final Counter failed;
|
||
|
||
public ProcessResource(Pipeline pipeline, SystemsConfig systems, MeterRegistry meters,
|
||
@Value("${pdguard.min-concurrent:8}") int minConcurrent,
|
||
@Value("${pdguard.max-concurrent:2000}") int maxConcurrent,
|
||
@Value("${pdguard.target-latency-ms:200}") long targetLatencyMillis) {
|
||
this.pipeline = pipeline;
|
||
this.systems = systems;
|
||
this.limiter = new AdaptiveConcurrencyLimiter(minConcurrent, maxConcurrent, targetLatencyMillis);
|
||
this.rejected = meters.counter(REJECTED_METRIC, REASON_TAG, "overload");
|
||
this.malformed = meters.counter(REJECTED_METRIC, REASON_TAG, "malformed");
|
||
this.forbidden = meters.counter(REJECTED_METRIC, REASON_TAG, "system_disabled");
|
||
this.failed = meters.counter(REJECTED_METRIC, REASON_TAG, "internal_error");
|
||
meters.gauge("pdguard.concurrency.limit", limiter, AdaptiveConcurrencyLimiter::limit);
|
||
meters.gauge("pdguard.concurrency.in.flight", limiter, AdaptiveConcurrencyLimiter::inFlight);
|
||
}
|
||
|
||
@PostMapping("/process")
|
||
public ResponseEntity<ProcessResponse> process(@RequestBody(required = false) ProcessRequest request,
|
||
@RequestHeader(value = SYSTEM_HEADER, required = false) String systemId,
|
||
@RequestHeader(value = KEY_HEADER, required = false) String systemKey) {
|
||
if (request == null || request.payload() == null
|
||
|| request.payloadId() == null || request.payloadId().isBlank()) {
|
||
malformed.increment();
|
||
return ResponseEntity.badRequest()
|
||
.body(new ProcessResponse("payload и payload_id обязательны"));
|
||
}
|
||
|
||
SystemPolicy policy = systems.policyFor(systemId);
|
||
if (!policy.accepts(systemKey)) {
|
||
forbidden.increment();
|
||
LOG.warn("Системе {} отказано: неверный ключ", systemId);
|
||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||
.body(new ProcessResponse("Неверный ключ системы"));
|
||
}
|
||
if (!policy.enabled()) {
|
||
forbidden.increment();
|
||
LOG.warn("Системе {} обращение в модуль запрещено настройками", systemId);
|
||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||
.body(new ProcessResponse("Системе " + systemId + " обращение в модуль запрещено"));
|
||
}
|
||
|
||
if (!limiter.tryAcquire()) {
|
||
rejected.increment();
|
||
return ResponseEntity.status(429).header("Retry-After", "1").build();
|
||
}
|
||
long started = System.nanoTime();
|
||
try {
|
||
String result = pipeline.process(request.payload(), request.payloadId(), policy);
|
||
return ResponseEntity.ok(new ProcessResponse(result));
|
||
} catch (RuntimeException e) {
|
||
// Ни 5xx, ни исходный текст. Пять подряд невалидных ответов останавливают
|
||
// прогон, поэтому код остаётся 200 — но возвращать при сбое сам payload
|
||
// нельзя: на прямом шаге наружу ушли бы незамаскированные ПД, ровно то,
|
||
// ради чего сервис и существует. Ответ фиксированный: он ничего не
|
||
// раскрывает и не выглядит порчей данных.
|
||
failed.increment();
|
||
LOG.error("payload_id={} обработка не удалась, отдан безопасный ответ",
|
||
request.payloadId(), e);
|
||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||
.body(new ProcessResponse(PROCESSING_UNAVAILABLE));
|
||
} finally {
|
||
limiter.release(System.nanoTime() - started);
|
||
}
|
||
}
|
||
} |