mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
chore(saas): remove dead ErrorTrackingService island + credits path exclusion (#6718)
## What Residual dead-code cleanup following the credits engine teardown (#6687). - **Delete the `ErrorTrackingService` dead island** (6 files): the service, `UserErrorTrackerRepository`, `UserErrorTracker`, `ProcessingErrorType`, `CreditsProperties`, and `ErrorTrackingServiceTest`. These formed a self-referential cluster with **zero external callers** once the credit machinery was removed. - **Remove `/api/v1/credits/**`** from both `excludePathPatterns` blocks in `PaygWebMvcConfig` — the credits controller no longer exists, so the exclusion is defunct. (spotless collapsed the lists to one line.) ## Verification - `./gradlew :saas:compileJava :saas:compileTestJava` → **BUILD SUCCESSFUL** - grep confirms zero dangling references to the deleted types ## Not in scope (deliberately deferred) Destructive DB drops (`user_credits`/`team_credits`/`user_subscription_plans` tables, dead `payg_shadow_charge` columns, `user_error_tracker` table) are gated behind the post-release soak (`live_ratio==1.0 ≥7d`) and tracked in a separate bundle. Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
This commit is contained in:
co-authored by
EthanHealy01
parent
c3795c1a3c
commit
900b66b030
@@ -1,75 +0,0 @@
|
||||
package stirling.software.saas.config;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Component
|
||||
@Profile("saas")
|
||||
@ConfigurationProperties(prefix = "credits")
|
||||
public class CreditsProperties {
|
||||
|
||||
/** Whether the credits system is enabled */
|
||||
private boolean enabled = true;
|
||||
|
||||
/** Credit allocations per billing cycle (monthly) */
|
||||
private CycleAllocations cycle = new CycleAllocations();
|
||||
|
||||
/** Reset configuration */
|
||||
private Reset reset = new Reset();
|
||||
|
||||
/** Error tracking configuration */
|
||||
private Errors errors = new Errors();
|
||||
|
||||
/** Cache configuration */
|
||||
private Cache cache = new Cache();
|
||||
|
||||
@Data
|
||||
public static class CycleAllocations {
|
||||
/** Whether admin role has unlimited credits */
|
||||
private boolean adminUnlimited = true;
|
||||
|
||||
/** Credit allocations per billing cycle (monthly) per role */
|
||||
private Map<String, Integer> allocations =
|
||||
Map.of(
|
||||
"ROLE_ADMIN", 1000,
|
||||
"ROLE_PRO_USER", 500,
|
||||
"ROLE_USER", 50,
|
||||
"ROLE_LIMITED_API_USER", 10,
|
||||
"ROLE_EXTRA_LIMITED_API_USER", 20,
|
||||
"ROLE_WEB_ONLY_USER", 0,
|
||||
"ROLE_DEMO_USER", 100);
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Reset {
|
||||
/** Cron expression for monthly reset (default: 1st of month 02:00 UTC) */
|
||||
private String cron = "0 0 2 1 * *";
|
||||
|
||||
/** Time zone for the reset schedule */
|
||||
private String zone = "UTC";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Errors {
|
||||
/** How long error counts are tracked (in minutes) */
|
||||
private int ttlMinutes = 60;
|
||||
|
||||
/** Number of free processing errors before charging */
|
||||
private int freeProcessingErrors = 2;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Cache {
|
||||
/** Enable local Caffeine cache for error counts */
|
||||
private boolean localEnabled = true;
|
||||
|
||||
/** Enable Redis cache for multi-instance deployments */
|
||||
private boolean redisEnabled = false;
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package stirling.software.saas.model;
|
||||
|
||||
public enum ProcessingErrorType {
|
||||
|
||||
/**
|
||||
* Validation errors. should never cost credits. Examples: missing parameters, invalid file
|
||||
* types, size limits exceeded, malformed requests, authentication failures
|
||||
*/
|
||||
VALIDATION_ERROR,
|
||||
|
||||
/**
|
||||
* Processing errors. should cost credits after 3rd attempt per user/endpoint. Examples: corrupt
|
||||
* PDF files, unsupported PDF features, memory issues during processing, OCR failures on valid
|
||||
* PDFs, conversion errors on valid files
|
||||
*/
|
||||
PROCESSING_ERROR,
|
||||
|
||||
/**
|
||||
* System errors. should not cost credits (our fault). Examples: database connection issues,
|
||||
* filesystem problems, service unavailable, internal server errors
|
||||
*/
|
||||
SYSTEM_ERROR;
|
||||
|
||||
/** Determine error type from exception and HTTP status */
|
||||
public static ProcessingErrorType classifyError(
|
||||
Throwable throwable, int httpStatus, String endpoint) {
|
||||
if (throwable == null) {
|
||||
return classifyByHttpStatus(httpStatus);
|
||||
}
|
||||
|
||||
String errorMessage = throwable.getMessage();
|
||||
String exceptionClass = throwable.getClass().getSimpleName();
|
||||
|
||||
// Validation errors (client-side issues)
|
||||
if (httpStatus == 400 || httpStatus == 422) {
|
||||
if (isValidationError(errorMessage, exceptionClass)) {
|
||||
return VALIDATION_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
// Authentication/Authorization errors
|
||||
if (httpStatus == 401 || httpStatus == 403) {
|
||||
return VALIDATION_ERROR;
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
if (httpStatus == 429) {
|
||||
return VALIDATION_ERROR;
|
||||
}
|
||||
|
||||
// System errors (our fault)
|
||||
if (httpStatus >= 500 || isSystemError(errorMessage, exceptionClass)) {
|
||||
return SYSTEM_ERROR;
|
||||
}
|
||||
|
||||
// Processing errors (user's data issue but valid request)
|
||||
if (isProcessingError(errorMessage, exceptionClass, endpoint)) {
|
||||
return PROCESSING_ERROR;
|
||||
}
|
||||
|
||||
// Default to validation error to be safe
|
||||
return VALIDATION_ERROR;
|
||||
}
|
||||
|
||||
private static ProcessingErrorType classifyByHttpStatus(int httpStatus) {
|
||||
if (httpStatus >= 400 && httpStatus < 500) {
|
||||
return VALIDATION_ERROR;
|
||||
} else if (httpStatus >= 500) {
|
||||
return SYSTEM_ERROR;
|
||||
}
|
||||
return VALIDATION_ERROR;
|
||||
}
|
||||
|
||||
private static boolean isValidationError(String errorMessage, String exceptionClass) {
|
||||
if (errorMessage == null && exceptionClass == null) return false;
|
||||
|
||||
String[] validationKeywords = {
|
||||
"validation", "invalid parameter", "missing parameter", "malformed",
|
||||
"bad request", "illegal argument", "file too large", "unsupported file type",
|
||||
"empty file", "no file provided", "invalid format"
|
||||
};
|
||||
|
||||
String[] validationExceptions = {
|
||||
"IllegalArgumentException",
|
||||
"ValidationException",
|
||||
"BindException",
|
||||
"MethodArgumentNotValidException",
|
||||
"MissingServletRequestParameterException",
|
||||
"HttpMessageNotReadableException",
|
||||
"MaxUploadSizeExceededException"
|
||||
};
|
||||
|
||||
return containsAny(errorMessage, validationKeywords)
|
||||
|| containsAny(exceptionClass, validationExceptions);
|
||||
}
|
||||
|
||||
private static boolean isSystemError(String errorMessage, String exceptionClass) {
|
||||
if (errorMessage == null && exceptionClass == null) return false;
|
||||
|
||||
String[] systemExceptions = {
|
||||
"SQLException",
|
||||
"IOException",
|
||||
"OutOfMemoryError",
|
||||
"TimeoutException",
|
||||
"ConnectException",
|
||||
"UnknownHostException",
|
||||
"ServiceUnavailableException"
|
||||
};
|
||||
|
||||
return containsAny(exceptionClass, systemExceptions);
|
||||
}
|
||||
|
||||
private static boolean isProcessingError(
|
||||
String errorMessage, String exceptionClass, String endpoint) {
|
||||
if (errorMessage == null && exceptionClass == null) return false;
|
||||
|
||||
String[] processingExceptions = {
|
||||
"PDFException", "COSVisitorException", "InvalidPDFException",
|
||||
"ConversionException", "OCRException", "ParseException"
|
||||
};
|
||||
|
||||
// If we're checking errors for an endpoint, it's already been identified as a tracked
|
||||
// endpoint
|
||||
// through @AutoJobPostMapping annotation, so we can assume it's a PDF processing endpoint
|
||||
return containsAny(exceptionClass, processingExceptions)
|
||||
|| (endpoint != null && !isValidationError(errorMessage, exceptionClass));
|
||||
}
|
||||
|
||||
private static boolean containsAny(String text, String[] keywords) {
|
||||
if (text == null) return false;
|
||||
String lowerText = text.toLowerCase();
|
||||
for (String keyword : keywords) {
|
||||
if (lowerText.contains(keyword.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package stirling.software.saas.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@Entity
|
||||
@Table(name = "user_error_tracker")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class UserErrorTracker implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "error_tracker_id")
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
|
||||
@Column(name = "endpoint")
|
||||
private String endpoint;
|
||||
|
||||
@Column(name = "processing_error_count")
|
||||
private Integer processingErrorCount = 0;
|
||||
|
||||
@Column(name = "last_processing_error")
|
||||
private LocalDateTime lastProcessingError;
|
||||
|
||||
@Column(name = "reset_after")
|
||||
private LocalDateTime resetAfter;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public UserErrorTracker(User user, String endpoint, int ttlMinutes) {
|
||||
this.user = user;
|
||||
this.endpoint = endpoint;
|
||||
this.resetAfter = LocalDateTime.now().plusMinutes(ttlMinutes);
|
||||
}
|
||||
|
||||
public boolean shouldChargeForProcessingError(int freeProcessingErrors) {
|
||||
return processingErrorCount != null && processingErrorCount > freeProcessingErrors;
|
||||
}
|
||||
|
||||
public void recordProcessingError(int ttlMinutes) {
|
||||
this.processingErrorCount = (processingErrorCount != null ? processingErrorCount : 0) + 1;
|
||||
this.lastProcessingError = LocalDateTime.now();
|
||||
|
||||
// Refresh TTL on each error
|
||||
this.resetAfter = LocalDateTime.now().plusMinutes(ttlMinutes);
|
||||
}
|
||||
|
||||
public void resetErrorCount(int ttlMinutes) {
|
||||
this.processingErrorCount = 0;
|
||||
this.lastProcessingError = null;
|
||||
this.resetAfter = LocalDateTime.now().plusMinutes(ttlMinutes);
|
||||
}
|
||||
|
||||
public boolean isExpired() {
|
||||
return resetAfter != null && LocalDateTime.now().isAfter(resetAfter);
|
||||
}
|
||||
|
||||
public int getErrorsUntilCharged(int freeProcessingErrors) {
|
||||
int current = processingErrorCount != null ? processingErrorCount : 0;
|
||||
return Math.max(0, freeProcessingErrors + 1 - current);
|
||||
}
|
||||
}
|
||||
@@ -62,20 +62,12 @@ public class PaygWebMvcConfig implements WebMvcConfigurer {
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(paygChargeInterceptor)
|
||||
.addPathPatterns("/api/**")
|
||||
.excludePathPatterns(
|
||||
"/api/v1/credits/**",
|
||||
"/api/v1/config/**",
|
||||
"/api/v1/info/**",
|
||||
"/api/v1/admin/**")
|
||||
.excludePathPatterns("/api/v1/config/**", "/api/v1/info/**", "/api/v1/admin/**")
|
||||
.order(INTERCEPTOR_ORDER);
|
||||
|
||||
registry.addInterceptor(entitlementGuard)
|
||||
.addPathPatterns("/api/**")
|
||||
.excludePathPatterns(
|
||||
"/api/v1/credits/**",
|
||||
"/api/v1/config/**",
|
||||
"/api/v1/info/**",
|
||||
"/api/v1/admin/**")
|
||||
.excludePathPatterns("/api/v1/config/**", "/api/v1/info/**", "/api/v1/admin/**")
|
||||
.order(ENTITLEMENT_GUARD_ORDER);
|
||||
}
|
||||
}
|
||||
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
package stirling.software.saas.repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.model.UserErrorTracker;
|
||||
|
||||
public interface UserErrorTrackerRepository extends JpaRepository<UserErrorTracker, Long> {
|
||||
|
||||
Optional<UserErrorTracker> findByUserAndEndpoint(User user, String endpoint);
|
||||
|
||||
Optional<UserErrorTracker> findByUserIdAndEndpoint(Long userId, String endpoint);
|
||||
|
||||
@Query(
|
||||
"SELECT uet FROM UserErrorTracker uet WHERE uet.user.apiKey = :apiKey AND uet.endpoint = :endpoint")
|
||||
Optional<UserErrorTracker> findByUserApiKeyAndEndpoint(
|
||||
@Param("apiKey") String apiKey, @Param("endpoint") String endpoint);
|
||||
|
||||
@Query("SELECT uet FROM UserErrorTracker uet WHERE uet.resetAfter <= :currentDateTime")
|
||||
List<UserErrorTracker> findExpiredErrorTrackers(
|
||||
@Param("currentDateTime") LocalDateTime currentDateTime);
|
||||
|
||||
@Modifying
|
||||
@Query("DELETE FROM UserErrorTracker uet WHERE uet.resetAfter <= :currentDateTime")
|
||||
int deleteExpiredErrorTrackers(@Param("currentDateTime") LocalDateTime currentDateTime);
|
||||
|
||||
@Query(
|
||||
"SELECT uet FROM UserErrorTracker uet WHERE uet.user = :user AND uet.processingErrorCount >= 3")
|
||||
List<UserErrorTracker> findHighErrorCountForUser(@Param("user") User user);
|
||||
|
||||
@Query(
|
||||
"SELECT COUNT(uet) FROM UserErrorTracker uet WHERE uet.processingErrorCount >= :threshold")
|
||||
Long countUsersWithHighErrorCount(@Param("threshold") int threshold);
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.config.CreditsProperties;
|
||||
import stirling.software.saas.model.ProcessingErrorType;
|
||||
import stirling.software.saas.model.UserErrorTracker;
|
||||
import stirling.software.saas.repository.UserErrorTrackerRepository;
|
||||
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@Slf4j
|
||||
@Transactional
|
||||
public class ErrorTrackingService {
|
||||
|
||||
private final UserErrorTrackerRepository errorTrackerRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final CreditsProperties creditsProperties;
|
||||
|
||||
/**
|
||||
* Local cache for error counts to reduce database chatter.
|
||||
*
|
||||
* <p>This cache is used to temporarily store error counts for each API key and endpoint,
|
||||
* reducing the frequency of database writes and lookups.
|
||||
*
|
||||
* <p><b>Nullability:</b> This field may be {@code null} if local caching is disabled via {@link
|
||||
* CreditsProperties#getCache()#isLocalEnabled()}. All usages must check for null before
|
||||
* accessing or invoking methods on this cache.
|
||||
*
|
||||
* <p><b>Lifecycle:</b> The cache is initialized in the constructor based on configuration and
|
||||
* remains unchanged for the lifetime of this service instance.
|
||||
*
|
||||
* <p><b>Thread-safety:</b> The underlying Caffeine cache is thread-safe.
|
||||
*/
|
||||
private final Cache<String, ErrorCountCache> errorCountCache;
|
||||
|
||||
public ErrorTrackingService(
|
||||
UserErrorTrackerRepository errorTrackerRepository,
|
||||
UserRepository userRepository,
|
||||
CreditsProperties creditsProperties) {
|
||||
this.errorTrackerRepository = errorTrackerRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.creditsProperties = creditsProperties;
|
||||
|
||||
// Initialize cache based on configuration
|
||||
this.errorCountCache =
|
||||
creditsProperties.getCache().isLocalEnabled()
|
||||
? Caffeine.newBuilder()
|
||||
.maximumSize(10000)
|
||||
.expireAfterWrite(
|
||||
creditsProperties.getErrors().getTtlMinutes(),
|
||||
TimeUnit.MINUTES)
|
||||
.build()
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an error and determine if credits should be consumed
|
||||
*
|
||||
* @param apiKey User's API key
|
||||
* @param endpoint The endpoint that failed
|
||||
* @param throwable The exception that occurred
|
||||
* @param httpStatus HTTP response status
|
||||
* @return true if credits should be consumed for this error
|
||||
*/
|
||||
public boolean recordErrorAndShouldConsumeCredit(
|
||||
String apiKey, String endpoint, Throwable throwable, int httpStatus) {
|
||||
ProcessingErrorType errorType =
|
||||
ProcessingErrorType.classifyError(throwable, httpStatus, endpoint);
|
||||
|
||||
// Never charge for validation errors or system errors
|
||||
if (errorType != ProcessingErrorType.PROCESSING_ERROR) {
|
||||
log.debug(
|
||||
"Error classified as {}, no credit consumption for API key: {}, endpoint: {}",
|
||||
errorType,
|
||||
maskApiKey(apiKey),
|
||||
endpoint);
|
||||
return false;
|
||||
}
|
||||
|
||||
String cacheKey = apiKey + "|" + endpoint;
|
||||
|
||||
if (errorCountCache != null) {
|
||||
// Use cache for fast tracking
|
||||
ErrorCountCache cachedCount = errorCountCache.get(cacheKey, k -> new ErrorCountCache());
|
||||
cachedCount.incrementErrorCount();
|
||||
|
||||
boolean shouldCharge =
|
||||
cachedCount.getErrorCount()
|
||||
> creditsProperties.getErrors().getFreeProcessingErrors();
|
||||
|
||||
// Persist to DB when crossing the charging threshold or on first error
|
||||
if (shouldCharge
|
||||
&& cachedCount.getErrorCount()
|
||||
== creditsProperties.getErrors().getFreeProcessingErrors() + 1) {
|
||||
persistErrorToDatabase(apiKey, endpoint);
|
||||
}
|
||||
|
||||
log.info(
|
||||
"Processing error recorded (cached) for API key: {}, endpoint: {}, error count: {}, will charge: {}",
|
||||
maskApiKey(apiKey),
|
||||
endpoint,
|
||||
cachedCount.getErrorCount(),
|
||||
shouldCharge);
|
||||
|
||||
return shouldCharge;
|
||||
} else {
|
||||
// Fallback to direct DB tracking
|
||||
return recordErrorDirectToDatabase(apiKey, endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean recordErrorDirectToDatabase(String apiKey, String endpoint) {
|
||||
Optional<User> userOpt = userRepository.findByApiKey(apiKey);
|
||||
if (userOpt.isEmpty()) {
|
||||
log.warn("User not found for API key: {}", maskApiKey(apiKey));
|
||||
return false;
|
||||
}
|
||||
|
||||
User user = userOpt.get();
|
||||
UserErrorTracker tracker = getOrCreateErrorTracker(user, endpoint);
|
||||
|
||||
tracker.recordProcessingError(creditsProperties.getErrors().getTtlMinutes());
|
||||
errorTrackerRepository.save(tracker);
|
||||
|
||||
boolean shouldCharge =
|
||||
tracker.shouldChargeForProcessingError(
|
||||
creditsProperties.getErrors().getFreeProcessingErrors());
|
||||
|
||||
log.info(
|
||||
"Processing error recorded (DB) for user: {}, endpoint: {}, error count: {}, will charge: {}",
|
||||
user.getUsername(),
|
||||
endpoint,
|
||||
tracker.getProcessingErrorCount(),
|
||||
shouldCharge);
|
||||
|
||||
return shouldCharge;
|
||||
}
|
||||
|
||||
private void persistErrorToDatabase(String apiKey, String endpoint) {
|
||||
try {
|
||||
Optional<User> userOpt = userRepository.findByApiKey(apiKey);
|
||||
if (userOpt.isPresent()) {
|
||||
User user = userOpt.get();
|
||||
UserErrorTracker tracker = getOrCreateErrorTracker(user, endpoint);
|
||||
// Set to threshold + 1 to indicate charging has started
|
||||
tracker.setProcessingErrorCount(
|
||||
creditsProperties.getErrors().getFreeProcessingErrors() + 1);
|
||||
tracker.setLastProcessingError(LocalDateTime.now());
|
||||
tracker.setResetAfter(
|
||||
LocalDateTime.now()
|
||||
.plusMinutes(creditsProperties.getErrors().getTtlMinutes()));
|
||||
errorTrackerRepository.save(tracker);
|
||||
log.debug(
|
||||
"Persisted error threshold crossing to DB for API key: {}, endpoint: {}",
|
||||
maskApiKey(apiKey),
|
||||
endpoint);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"Failed to persist error to database for API key: {}, endpoint: {}",
|
||||
maskApiKey(apiKey),
|
||||
endpoint,
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if a user has high error counts that might indicate abuse */
|
||||
public boolean hasHighErrorCount(String apiKey, String endpoint) {
|
||||
Optional<UserErrorTracker> trackerOpt =
|
||||
errorTrackerRepository.findByUserApiKeyAndEndpoint(apiKey, endpoint);
|
||||
return trackerOpt
|
||||
.map(
|
||||
t ->
|
||||
t.shouldChargeForProcessingError(
|
||||
creditsProperties.getErrors().getFreeProcessingErrors()))
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/** Get error information for a user and endpoint */
|
||||
public ErrorInfo getErrorInfo(String apiKey, String endpoint) {
|
||||
String cacheKey = apiKey + "|" + endpoint;
|
||||
|
||||
if (errorCountCache != null) {
|
||||
// Check cache first
|
||||
ErrorCountCache cachedCount = errorCountCache.getIfPresent(cacheKey);
|
||||
if (cachedCount != null) {
|
||||
int currentCount = cachedCount.getErrorCount();
|
||||
int freeErrors = creditsProperties.getErrors().getFreeProcessingErrors();
|
||||
return new ErrorInfo(
|
||||
currentCount,
|
||||
Math.max(0, freeErrors - currentCount),
|
||||
currentCount > freeErrors,
|
||||
cachedCount.getLastErrorTime());
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to DB
|
||||
Optional<UserErrorTracker> trackerOpt =
|
||||
errorTrackerRepository.findByUserApiKeyAndEndpoint(apiKey, endpoint);
|
||||
if (trackerOpt.isEmpty()) {
|
||||
return new ErrorInfo(
|
||||
0, creditsProperties.getErrors().getFreeProcessingErrors(), false, null);
|
||||
}
|
||||
|
||||
UserErrorTracker tracker = trackerOpt.get();
|
||||
|
||||
// Reset if expired
|
||||
if (tracker.isExpired()) {
|
||||
tracker.resetErrorCount(creditsProperties.getErrors().getTtlMinutes());
|
||||
errorTrackerRepository.save(tracker);
|
||||
return new ErrorInfo(
|
||||
0, creditsProperties.getErrors().getFreeProcessingErrors(), false, null);
|
||||
}
|
||||
|
||||
return new ErrorInfo(
|
||||
tracker.getProcessingErrorCount(),
|
||||
tracker.getErrorsUntilCharged(
|
||||
creditsProperties.getErrors().getFreeProcessingErrors()),
|
||||
tracker.shouldChargeForProcessingError(
|
||||
creditsProperties.getErrors().getFreeProcessingErrors()),
|
||||
tracker.getLastProcessingError());
|
||||
}
|
||||
|
||||
private UserErrorTracker getOrCreateErrorTracker(User user, String endpoint) {
|
||||
Optional<UserErrorTracker> existing =
|
||||
errorTrackerRepository.findByUserAndEndpoint(user, endpoint);
|
||||
|
||||
if (existing.isPresent()) {
|
||||
UserErrorTracker tracker = existing.get();
|
||||
|
||||
// Reset if expired
|
||||
if (tracker.isExpired()) {
|
||||
tracker.resetErrorCount(creditsProperties.getErrors().getTtlMinutes());
|
||||
}
|
||||
|
||||
return tracker;
|
||||
}
|
||||
|
||||
// Create new tracker
|
||||
return new UserErrorTracker(user, endpoint, creditsProperties.getErrors().getTtlMinutes());
|
||||
}
|
||||
|
||||
/** Clean up expired error trackers every hour */
|
||||
@Scheduled(cron = "0 0 * * * *")
|
||||
public void cleanupExpiredErrorTrackers() {
|
||||
try {
|
||||
int deleted = errorTrackerRepository.deleteExpiredErrorTrackers(LocalDateTime.now());
|
||||
if (deleted > 0) {
|
||||
log.debug("Cleaned up {} expired error trackers", deleted);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error cleaning up expired error trackers", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String maskApiKey(String apiKey) {
|
||||
if (apiKey == null || apiKey.length() < 8) {
|
||||
return "***";
|
||||
}
|
||||
return apiKey.substring(0, 4) + "***" + apiKey.substring(apiKey.length() - 4);
|
||||
}
|
||||
|
||||
/** Information about user's error status for an endpoint */
|
||||
public static class ErrorInfo {
|
||||
public final int currentErrorCount;
|
||||
public final int errorsUntilCharged;
|
||||
public final boolean isChargingForErrors;
|
||||
public final LocalDateTime lastError;
|
||||
|
||||
public ErrorInfo(
|
||||
int currentErrorCount,
|
||||
int errorsUntilCharged,
|
||||
boolean isChargingForErrors,
|
||||
LocalDateTime lastError) {
|
||||
this.currentErrorCount = currentErrorCount;
|
||||
this.errorsUntilCharged = errorsUntilCharged;
|
||||
this.isChargingForErrors = isChargingForErrors;
|
||||
this.lastError = lastError;
|
||||
}
|
||||
}
|
||||
|
||||
/** Cache entry for tracking error counts in memory */
|
||||
private static class ErrorCountCache {
|
||||
private int errorCount = 0;
|
||||
private LocalDateTime lastErrorTime = LocalDateTime.now();
|
||||
|
||||
public void incrementErrorCount() {
|
||||
errorCount++;
|
||||
lastErrorTime = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public int getErrorCount() {
|
||||
return errorCount;
|
||||
}
|
||||
|
||||
public LocalDateTime getLastErrorTime() {
|
||||
return lastErrorTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,586 +0,0 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.config.CreditsProperties;
|
||||
import stirling.software.saas.model.UserErrorTracker;
|
||||
import stirling.software.saas.repository.UserErrorTrackerRepository;
|
||||
import stirling.software.saas.service.ErrorTrackingService.ErrorInfo;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link ErrorTrackingService}.
|
||||
*
|
||||
* <p>The service has two distinct tracking paths chosen at construction time based on {@link
|
||||
* CreditsProperties.Cache#isLocalEnabled()}: an in-memory Caffeine cache path and a direct-to-DB
|
||||
* fallback. Because the cache field is final and decided in the constructor, each path is exercised
|
||||
* by building the service with a tailored {@link CreditsProperties}. Defaults are {@code
|
||||
* freeProcessingErrors = 2} and {@code ttlMinutes = 60}.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ErrorTrackingServiceTest {
|
||||
|
||||
@Mock private UserErrorTrackerRepository errorTrackerRepository;
|
||||
@Mock private UserRepository userRepository;
|
||||
|
||||
private static final String API_KEY =
|
||||
"test-api-key-0001"; // gitleaks:allow - test fixture, not a secret
|
||||
private static final String ENDPOINT = "/api/v1/convert/pdf-to-img";
|
||||
|
||||
/** Build a CreditsProperties with the given cache + error config. */
|
||||
private static CreditsProperties props(
|
||||
boolean localCacheEnabled, int freeProcessingErrors, int ttlMinutes) {
|
||||
CreditsProperties p = new CreditsProperties();
|
||||
p.getCache().setLocalEnabled(localCacheEnabled);
|
||||
p.getErrors().setFreeProcessingErrors(freeProcessingErrors);
|
||||
p.getErrors().setTtlMinutes(ttlMinutes);
|
||||
return p;
|
||||
}
|
||||
|
||||
private ErrorTrackingService cachedService(int freeProcessingErrors) {
|
||||
return new ErrorTrackingService(
|
||||
errorTrackerRepository, userRepository, props(true, freeProcessingErrors, 60));
|
||||
}
|
||||
|
||||
private ErrorTrackingService dbService(int freeProcessingErrors) {
|
||||
return new ErrorTrackingService(
|
||||
errorTrackerRepository, userRepository, props(false, freeProcessingErrors, 60));
|
||||
}
|
||||
|
||||
private static User user(String username, String apiKey) {
|
||||
User u = new User();
|
||||
u.setUsername(username);
|
||||
u.setApiKey(apiKey);
|
||||
return u;
|
||||
}
|
||||
|
||||
/**
|
||||
* A throwable that classifies as PROCESSING_ERROR when paired with httpStatus 200 and a
|
||||
* non-null endpoint: not a validation/system error, so the endpoint-based processing branch
|
||||
* wins.
|
||||
*/
|
||||
private static Throwable processingThrowable() {
|
||||
return new RuntimeException("corrupt pdf stream while rendering page");
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("recordErrorAndShouldConsumeCredit - error classification gate")
|
||||
class ClassificationGate {
|
||||
|
||||
@Test
|
||||
@DisplayName("validation error (400) never charges and never touches DB or cache")
|
||||
void validationError_doesNotCharge() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
|
||||
boolean charge =
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY,
|
||||
ENDPOINT,
|
||||
new IllegalArgumentException("missing parameter"),
|
||||
400);
|
||||
|
||||
assertThat(charge).isFalse();
|
||||
verify(errorTrackerRepository, never()).save(any());
|
||||
verifyNoInteractions(userRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("auth error (401) classifies as validation and never charges")
|
||||
void authError_doesNotCharge() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
|
||||
boolean charge =
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, ENDPOINT, new RuntimeException("denied"), 401);
|
||||
|
||||
assertThat(charge).isFalse();
|
||||
verify(errorTrackerRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("system error (500) never charges")
|
||||
void systemError_doesNotCharge() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
|
||||
boolean charge =
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, ENDPOINT, new RuntimeException("server boom"), 500);
|
||||
|
||||
assertThat(charge).isFalse();
|
||||
verify(errorTrackerRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("system exception type (SQLException) at 200 never charges")
|
||||
void systemExceptionType_doesNotCharge() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
|
||||
boolean charge =
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, ENDPOINT, new SQLException("db down"), 200);
|
||||
|
||||
assertThat(charge).isFalse();
|
||||
verify(errorTrackerRepository, never()).save(any());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("recordErrorAndShouldConsumeCredit - cache path (freeProcessingErrors=2)")
|
||||
class CachePath {
|
||||
|
||||
@Test
|
||||
@DisplayName("first two processing errors are free; the 3rd charges")
|
||||
void firstTwoFree_thirdCharges() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
User u = user("alice", API_KEY);
|
||||
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
|
||||
when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
// count=1 -> not > 2
|
||||
assertThat(call(service)).isFalse();
|
||||
// count=2 -> not > 2
|
||||
assertThat(call(service)).isFalse();
|
||||
// count=3 -> > 2 -> charge, and threshold-crossing persists to DB
|
||||
assertThat(call(service)).isTrue();
|
||||
// count=4 -> still charges, but no second persist (only on the exact crossing)
|
||||
assertThat(call(service)).isTrue();
|
||||
|
||||
// Persisted exactly once: on the threshold-crossing call (count == free + 1).
|
||||
verify(errorTrackerRepository, times(1)).save(any(UserErrorTracker.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("threshold-crossing persists a tracker set to free+1 with future resetAfter")
|
||||
void thresholdCrossing_persistsTrackerWithCorrectCount() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
User u = user("bob", API_KEY);
|
||||
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
|
||||
when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
LocalDateTime before = LocalDateTime.now();
|
||||
call(service); // 1
|
||||
call(service); // 2
|
||||
call(service); // 3 -> persist
|
||||
|
||||
ArgumentCaptor<UserErrorTracker> captor =
|
||||
ArgumentCaptor.forClass(UserErrorTracker.class);
|
||||
verify(errorTrackerRepository).save(captor.capture());
|
||||
UserErrorTracker saved = captor.getValue();
|
||||
assertThat(saved.getProcessingErrorCount()).isEqualTo(3); // free(2) + 1
|
||||
assertThat(saved.getUser()).isSameAs(u);
|
||||
assertThat(saved.getEndpoint()).isEqualTo(ENDPOINT);
|
||||
assertThat(saved.getLastProcessingError()).isNotNull();
|
||||
assertThat(saved.getResetAfter()).isAfter(before);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("zero free errors: first processing error charges immediately")
|
||||
void zeroFree_firstErrorCharges() {
|
||||
ErrorTrackingService service = cachedService(0);
|
||||
User u = user("carol", API_KEY);
|
||||
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
|
||||
when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
// count=1 > free(0) -> charge, and 1 == free+1 -> persist
|
||||
assertThat(call(service)).isTrue();
|
||||
verify(errorTrackerRepository, times(1)).save(any(UserErrorTracker.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("distinct endpoints are tracked independently in the cache")
|
||||
void distinctEndpoints_trackedSeparately() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
|
||||
// Two errors on endpoint A, two on endpoint B -> neither crosses the free=2 threshold.
|
||||
assertThat(
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, "/a", processingThrowable(), 200))
|
||||
.isFalse();
|
||||
assertThat(
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, "/a", processingThrowable(), 200))
|
||||
.isFalse();
|
||||
assertThat(
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, "/b", processingThrowable(), 200))
|
||||
.isFalse();
|
||||
assertThat(
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, "/b", processingThrowable(), 200))
|
||||
.isFalse();
|
||||
|
||||
// Neither key crossed free+1, so no DB persistence at all.
|
||||
verify(errorTrackerRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("cache path does not blow up if the user is absent at persist time")
|
||||
void cachePersist_userAbsent_swallowsAndStillCharges() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
// No user for this key: persistErrorToDatabase finds nothing and saves nothing.
|
||||
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.empty());
|
||||
|
||||
assertThat(call(service)).isFalse(); // 1
|
||||
assertThat(call(service)).isFalse(); // 2
|
||||
assertThat(call(service)).isTrue(); // 3 -> tries persist, user missing -> no save
|
||||
|
||||
verify(errorTrackerRepository, never()).save(any());
|
||||
}
|
||||
|
||||
/** Convenience: record one processing error on the standard key. */
|
||||
private boolean call(ErrorTrackingService service) {
|
||||
return service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, ENDPOINT, processingThrowable(), 200);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("recordErrorAndShouldConsumeCredit - DB fallback path (cache disabled)")
|
||||
class DbFallbackPath {
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown API key returns false and saves nothing")
|
||||
void unknownApiKey_returnsFalse() {
|
||||
ErrorTrackingService service = dbService(2);
|
||||
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.empty());
|
||||
|
||||
boolean charge =
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, ENDPOINT, processingThrowable(), 200);
|
||||
|
||||
assertThat(charge).isFalse();
|
||||
verify(errorTrackerRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"creates a new tracker, records the error and persists; below threshold no charge")
|
||||
void newTracker_belowThreshold_noCharge() {
|
||||
ErrorTrackingService service = dbService(2);
|
||||
User u = user("dave", API_KEY);
|
||||
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
|
||||
when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
boolean charge =
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, ENDPOINT, processingThrowable(), 200);
|
||||
|
||||
assertThat(charge).isFalse(); // count 1, free 2
|
||||
|
||||
ArgumentCaptor<UserErrorTracker> captor =
|
||||
ArgumentCaptor.forClass(UserErrorTracker.class);
|
||||
verify(errorTrackerRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getProcessingErrorCount()).isEqualTo(1);
|
||||
assertThat(captor.getValue().getUser()).isSameAs(u);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("existing tracker already at the threshold rolls over to charging")
|
||||
void existingTracker_atThreshold_charges() {
|
||||
ErrorTrackingService service = dbService(2);
|
||||
User u = user("erin", API_KEY);
|
||||
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
|
||||
|
||||
UserErrorTracker tracker = new UserErrorTracker(u, ENDPOINT, 60);
|
||||
tracker.setProcessingErrorCount(2); // at the free limit
|
||||
when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
|
||||
.thenReturn(Optional.of(tracker));
|
||||
|
||||
boolean charge =
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, ENDPOINT, processingThrowable(), 200);
|
||||
|
||||
// recordProcessingError bumps 2 -> 3, which is > free(2) -> charge
|
||||
assertThat(charge).isTrue();
|
||||
assertThat(tracker.getProcessingErrorCount()).isEqualTo(3);
|
||||
verify(errorTrackerRepository).save(tracker);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("expired existing tracker is reset before recording the new error")
|
||||
void expiredTracker_isResetThenRecorded() {
|
||||
ErrorTrackingService service = dbService(2);
|
||||
User u = user("finn", API_KEY);
|
||||
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
|
||||
|
||||
UserErrorTracker tracker = new UserErrorTracker(u, ENDPOINT, 60);
|
||||
tracker.setProcessingErrorCount(5);
|
||||
tracker.setResetAfter(LocalDateTime.now().minusMinutes(1)); // expired
|
||||
when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
|
||||
.thenReturn(Optional.of(tracker));
|
||||
|
||||
boolean charge =
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, ENDPOINT, processingThrowable(), 200);
|
||||
|
||||
// reset to 0, then recordProcessingError -> 1, which is not > free(2)
|
||||
assertThat(charge).isFalse();
|
||||
assertThat(tracker.getProcessingErrorCount()).isEqualTo(1);
|
||||
verify(errorTrackerRepository).save(tracker);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("hasHighErrorCount")
|
||||
class HasHighErrorCount {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns false when no tracker exists for the key")
|
||||
void noTracker_false() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
assertThat(service.hasHighErrorCount(API_KEY, ENDPOINT)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("true when the tracker's count exceeds the free allowance")
|
||||
void aboveFree_true() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
UserErrorTracker tracker = new UserErrorTracker(user("g", API_KEY), ENDPOINT, 60);
|
||||
tracker.setProcessingErrorCount(3); // > 2
|
||||
when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
|
||||
.thenReturn(Optional.of(tracker));
|
||||
|
||||
assertThat(service.hasHighErrorCount(API_KEY, ENDPOINT)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("false when the count is exactly at the free allowance (boundary)")
|
||||
void atFree_false() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
UserErrorTracker tracker = new UserErrorTracker(user("g", API_KEY), ENDPOINT, 60);
|
||||
tracker.setProcessingErrorCount(2); // not > 2
|
||||
when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
|
||||
.thenReturn(Optional.of(tracker));
|
||||
|
||||
assertThat(service.hasHighErrorCount(API_KEY, ENDPOINT)).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("getErrorInfo")
|
||||
class GetErrorInfo {
|
||||
|
||||
@Test
|
||||
@DisplayName("cache hit reflects the live cached count, remaining free and charging flag")
|
||||
void cacheHit_reportsLiveCount() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
|
||||
// Drive the cache to 3 errors on the key so a subsequent getErrorInfo reads it.
|
||||
for (int i = 0; i < 3; i++) {
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, ENDPOINT, processingThrowable(), 200);
|
||||
}
|
||||
|
||||
ErrorInfo info = service.getErrorInfo(API_KEY, ENDPOINT);
|
||||
|
||||
assertThat(info.currentErrorCount).isEqualTo(3);
|
||||
// Math.max(0, free(2) - 3) == 0
|
||||
assertThat(info.errorsUntilCharged).isZero();
|
||||
assertThat(info.isChargingForErrors).isTrue(); // 3 > 2
|
||||
assertThat(info.lastError).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("cache miss falls back to an empty/zeroed ErrorInfo when no DB row exists")
|
||||
void cacheMiss_noDbRow_zeroedInfo() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
ErrorInfo info = service.getErrorInfo(API_KEY, ENDPOINT);
|
||||
|
||||
assertThat(info.currentErrorCount).isZero();
|
||||
assertThat(info.errorsUntilCharged).isEqualTo(2); // full free allowance
|
||||
assertThat(info.isChargingForErrors).isFalse();
|
||||
assertThat(info.lastError).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DB-backed (cache disabled): live tracker is reported with derived fields")
|
||||
void dbBacked_liveTracker_reported() {
|
||||
ErrorTrackingService service = dbService(2);
|
||||
UserErrorTracker tracker = new UserErrorTracker(user("h", API_KEY), ENDPOINT, 60);
|
||||
tracker.setProcessingErrorCount(3);
|
||||
tracker.setLastProcessingError(LocalDateTime.now());
|
||||
// not expired (constructor set resetAfter ~60m out)
|
||||
when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
|
||||
.thenReturn(Optional.of(tracker));
|
||||
|
||||
ErrorInfo info = service.getErrorInfo(API_KEY, ENDPOINT);
|
||||
|
||||
assertThat(info.currentErrorCount).isEqualTo(3);
|
||||
// getErrorsUntilCharged = max(0, free+1 - current) = max(0, 3 - 3) = 0
|
||||
assertThat(info.errorsUntilCharged).isZero();
|
||||
assertThat(info.isChargingForErrors).isTrue(); // 3 > 2
|
||||
assertThat(info.lastError).isNotNull();
|
||||
verify(errorTrackerRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DB-backed: expired tracker is reset, persisted and reported as zeroed")
|
||||
void dbBacked_expiredTracker_resetAndZeroed() {
|
||||
ErrorTrackingService service = dbService(2);
|
||||
UserErrorTracker tracker = new UserErrorTracker(user("i", API_KEY), ENDPOINT, 60);
|
||||
tracker.setProcessingErrorCount(7);
|
||||
tracker.setResetAfter(LocalDateTime.now().minusMinutes(1)); // expired
|
||||
when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
|
||||
.thenReturn(Optional.of(tracker));
|
||||
|
||||
ErrorInfo info = service.getErrorInfo(API_KEY, ENDPOINT);
|
||||
|
||||
assertThat(info.currentErrorCount).isZero();
|
||||
assertThat(info.errorsUntilCharged).isEqualTo(2);
|
||||
assertThat(info.isChargingForErrors).isFalse();
|
||||
assertThat(info.lastError).isNull();
|
||||
assertThat(tracker.getProcessingErrorCount()).isZero(); // reset mutated the entity
|
||||
verify(errorTrackerRepository).save(tracker);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("cleanupExpiredErrorTrackers")
|
||||
class Cleanup {
|
||||
|
||||
@Test
|
||||
@DisplayName("delegates to the repository delete with a 'now' cutoff")
|
||||
void delegatesDelete() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
when(errorTrackerRepository.deleteExpiredErrorTrackers(any(LocalDateTime.class)))
|
||||
.thenReturn(4);
|
||||
|
||||
service.cleanupExpiredErrorTrackers();
|
||||
|
||||
verify(errorTrackerRepository).deleteExpiredErrorTrackers(any(LocalDateTime.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("swallows repository exceptions so the scheduler keeps running")
|
||||
void swallowsRepositoryException() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
when(errorTrackerRepository.deleteExpiredErrorTrackers(any(LocalDateTime.class)))
|
||||
.thenThrow(new RuntimeException("delete blew up"));
|
||||
|
||||
// Must not propagate.
|
||||
service.cleanupExpiredErrorTrackers();
|
||||
|
||||
verify(errorTrackerRepository).deleteExpiredErrorTrackers(any(LocalDateTime.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("zero deletions still completes cleanly")
|
||||
void zeroDeletions_ok() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
when(errorTrackerRepository.deleteExpiredErrorTrackers(any(LocalDateTime.class)))
|
||||
.thenReturn(0);
|
||||
|
||||
service.cleanupExpiredErrorTrackers();
|
||||
|
||||
verify(errorTrackerRepository).deleteExpiredErrorTrackers(any(LocalDateTime.class));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("ErrorInfo value holder")
|
||||
class ErrorInfoHolder {
|
||||
|
||||
@Test
|
||||
@DisplayName("constructor wires the public fields verbatim")
|
||||
void fieldsWiredVerbatim() {
|
||||
LocalDateTime ts = LocalDateTime.now();
|
||||
ErrorInfo info = new ErrorInfo(5, 1, true, ts);
|
||||
|
||||
assertThat(info.currentErrorCount).isEqualTo(5);
|
||||
assertThat(info.errorsUntilCharged).isEqualTo(1);
|
||||
assertThat(info.isChargingForErrors).isTrue();
|
||||
assertThat(info.lastError).isEqualTo(ts);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("API key masking is exercised without leaking (smoke via logging branches)")
|
||||
class MaskingSmoke {
|
||||
|
||||
@Test
|
||||
@DisplayName("short API keys are tolerated end-to-end on the cache path")
|
||||
void shortApiKey_tolerated() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
when(userRepository.findByApiKey(anyString())).thenReturn(Optional.empty());
|
||||
|
||||
// "key" is < 8 chars, masked as *** inside the service; must not throw on persist path.
|
||||
boolean c1 =
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
"key", ENDPOINT, processingThrowable(), 200);
|
||||
boolean c2 =
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
"key", ENDPOINT, processingThrowable(), 200);
|
||||
boolean c3 =
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
"key", ENDPOINT, processingThrowable(), 200);
|
||||
|
||||
assertThat(c1).isFalse();
|
||||
assertThat(c2).isFalse();
|
||||
assertThat(c3).isTrue();
|
||||
// user absent -> no save even at threshold crossing
|
||||
verify(errorTrackerRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null API key is tolerated on the DB fallback path")
|
||||
void nullApiKey_tolerated() {
|
||||
ErrorTrackingService service = dbService(2);
|
||||
when(userRepository.findByApiKey(eq(null))).thenReturn(Optional.empty());
|
||||
|
||||
boolean charge =
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
null, ENDPOINT, processingThrowable(), 200);
|
||||
|
||||
assertThat(charge).isFalse();
|
||||
verify(errorTrackerRepository, never()).save(any());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("IOException as a system error type does not charge even at 200")
|
||||
void ioExceptionSystemError_doesNotCharge() {
|
||||
ErrorTrackingService service = cachedService(2);
|
||||
|
||||
boolean charge =
|
||||
service.recordErrorAndShouldConsumeCredit(
|
||||
API_KEY, ENDPOINT, new IOException("disk gone"), 200);
|
||||
|
||||
assertThat(charge).isFalse();
|
||||
verify(errorTrackerRepository, never()).save(any());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user