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:
ConnorYoh
2026-06-18 14:32:54 +00:00
committed by GitHub
co-authored by EthanHealy01
parent c3795c1a3c
commit 900b66b030
7 changed files with 2 additions and 1261 deletions
@@ -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);
}
}
@@ -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());
}
}