Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ services:
ENABLE_AUTH: "true"
API_KEY: ${OCR_API_KEY:?Set OCR_API_KEY for backend-to-OCR authentication}
CORS_ORIGINS: ${OCR_CORS_ORIGINS:-}
TRUSTED_PROXY_IPS: ${OCR_TRUSTED_PROXY_IPS:-}
# Server Configuration
HOST: 0.0.0.0
PORT: 8000
Expand Down Expand Up @@ -188,6 +189,10 @@ services:
SERVICE_VERSION: 1.0.0
ENVIRONMENT: ${ENVIRONMENT:-docker}
DEBUG: ${OCR_DEBUG:-false}
ENABLE_AUTH: "true"
API_KEY: ${OCR_API_KEY:?Set OCR_API_KEY for backend-to-OCR authentication}
CORS_ORIGINS: ${OCR_CORS_ORIGINS:-}
TRUSTED_PROXY_IPS: ${OCR_TRUSTED_PROXY_IPS:-}
HOST: 0.0.0.0
PORT: 8000
WORKERS: ${OCR_WORKERS:-1}
Expand All @@ -207,7 +212,7 @@ services:
LOG_FORMAT: json
ENABLE_METRICS: "true"
ports:
- "${OCR_GPU_PORT:-8001}:8000"
- "${OCR_GPU_BIND_ADDRESS:-127.0.0.1}:${OCR_GPU_PORT:-8001}:8000"
volumes:
- ocr_models_gpu:/home/ocr/.paddleocr
healthcheck:
Expand Down
2 changes: 1 addition & 1 deletion infra/docker/ocr.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=90s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1

# Default command - run with uvicorn
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
CMD ["python", "-m", "app.main"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid importing the app twice at container startup

With the default Docker/Compose configuration, ENABLE_METRICS is true and this command first executes app.main as __main__; main() then asks Uvicorn to import app.main:app again in the same single-worker process. The second import re-registers the module-level Prometheus counters under the same names, causing ValueError: Duplicated timeseries in CollectorRegistry and preventing the OCR container from starting. Keep the direct Uvicorn entrypoint or otherwise ensure the application module is initialized only once.

Useful? React with 👍 / 👎.

Original file line number Diff line number Diff line change
Expand Up @@ -128,15 +128,15 @@ public Mono<OcrResponse> extractText(List<FilePart> files) {
.retryWhen(Retry.backoff(maxRetries, Duration.ofSeconds(1))
.filter(this::isRetryable)
.doBeforeRetry(signal -> log.warn(
"Retrying OCR request, attempt {}: {}",
"Retrying OCR request, attempt {}: type={}",
signal.totalRetries() + 1,
signal.failure().getMessage())))
signal.failure().getClass().getSimpleName())))
.doOnSuccess(response -> log.info(
"OCR request successful: requestId={}, status={}, confidence={}",
response.requestId(),
response.status(),
response.overallConfidence()))
.doOnError(error -> log.error("OCR request failed", error));
.doOnError(error -> log.error("OCR request failed: type={}", error.getClass().getSimpleName()));
}

/**
Expand All @@ -151,7 +151,8 @@ public Mono<OcrResponse> extractTextFromBytes(byte[] imageBytes, String filename
return Mono.error(new IllegalArgumentException("Image bytes cannot be empty"));
}

log.info("Sending OCR request for single image: {} ({} bytes)", filename, imageBytes.length);
log.info("Sending OCR request for single image: {} ({} bytes)",
safeFilename(filename), imageBytes.length);

MultipartBodyBuilder builder = new MultipartBodyBuilder();
builder.part("images", imageBytes)
Expand Down Expand Up @@ -182,7 +183,7 @@ public Mono<OcrResponse> extractTextFromBytes(byte[] imageBytes, String filename
"OCR request successful: requestId={}, status={}",
response.requestId(),
response.status()))
.doOnError(error -> log.error("OCR request failed", error));
.doOnError(error -> log.error("OCR request failed: type={}", error.getClass().getSimpleName()));
}

private void applyAuthentication(HttpHeaders headers) {
Expand All @@ -191,6 +192,14 @@ private void applyAuthentication(HttpHeaders headers) {
}
}

private static String safeFilename(String filename) {
if (filename == null || filename.isBlank()) {
return "<unnamed>";
}
String sanitized = filename.replace("\r", "").replace("\n", "");
return sanitized.substring(0, Math.min(sanitized.length(), 255));
}

/**
* Simple health check for the OCR service.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import ao.creativemode.kixi.client.OcrServiceClient.OcrClientException;
import ao.creativemode.kixi.client.OcrServiceClient.OcrServerException;
import ao.creativemode.kixi.common.dto.ProblemDetail;
import ao.creativemode.kixi.security.RequestIdWebFilter;
import reactor.core.publisher.Mono;

/**
Expand Down Expand Up @@ -79,12 +80,7 @@ public Mono<ResponseEntity<ProblemDetail>> handleValidationErrors(
? fieldError.getDefaultMessage()
: "Invalid value";
if (fieldError.getRejectedValue() != null) {
return Map.of(
"message",
msg,
"rejectedValue",
fieldError.getRejectedValue()
);
return Map.of("message", msg);
}
return msg;
}
Expand All @@ -110,16 +106,17 @@ public Mono<ResponseEntity<ProblemDetail>> handleOcrClientException(
ServerWebExchange exchange
) {
log.warn(
"OCR client error: status={}, message={}",
"OCR client error: status={}, type={}, requestId={}",
ex.getStatusCode(),
ex.getMessage()
ex.getClass().getSimpleName(),
RequestIdWebFilter.requestId(exchange)
);

ProblemDetail problem = new ProblemDetail(
OCR_ERROR_TYPE,
"OCR Processing Error",
ex.getStatusCode(),
ex.getMessage(),
"The OCR request was rejected. Please verify the uploaded file and try again.",
Map.of("service", "ocr-service", "errorType", "client_error")
);

Expand All @@ -139,9 +136,10 @@ public Mono<ResponseEntity<ProblemDetail>> handleOcrServerException(
ServerWebExchange exchange
) {
log.error(
"OCR server error: status={}, message={}",
"OCR server error: status={}, type={}, requestId={}",
ex.getStatusCode(),
ex.getMessage()
ex.getClass().getSimpleName(),
RequestIdWebFilter.requestId(exchange)
);

ProblemDetail problem = new ProblemDetail(
Expand All @@ -167,7 +165,8 @@ public Mono<ResponseEntity<ProblemDetail>> handleTimeoutException(
TimeoutException ex,
ServerWebExchange exchange
) {
log.error("Request timeout: {}", ex.getMessage());
log.error("Request timeout: type={}, requestId={}",
ex.getClass().getSimpleName(), RequestIdWebFilter.requestId(exchange));

ProblemDetail problem = new ProblemDetail(
URI.create("https://api.kixi.ao/errors/timeout"),
Expand All @@ -192,7 +191,8 @@ public Mono<ResponseEntity<ProblemDetail>> handleIllegalArgumentException(
IllegalArgumentException ex,
ServerWebExchange exchange
) {
log.warn("Illegal argument: {}", ex.getMessage());
log.warn("Illegal argument: type={}, requestId={}",
ex.getClass().getSimpleName(), RequestIdWebFilter.requestId(exchange));

ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST.value(),
Expand All @@ -212,7 +212,8 @@ public Mono<ResponseEntity<ProblemDetail>> handleGenericException(
Exception ex,
ServerWebExchange exchange
) {
log.error("Unhandled exception occurred", ex);
log.error("Unhandled exception: type={}, requestId={}",
ex.getClass().getSimpleName(), RequestIdWebFilter.requestId(exchange));

ProblemDetail problem = ProblemDetail.forStatusAndDetail(
500,
Expand All @@ -231,13 +232,14 @@ private ProblemDetail addInstance(
ServerWebExchange exchange,
ProblemDetail problem
) {
String requestUri = exchange.getRequest().getURI().toString();
String requestUri = exchange.getRequest().getPath().value();
Map<String, Object> currentProps =
problem.properties() != null ? problem.properties() : Map.of();
Map<String, Object> updatedProps = new java.util.HashMap<>(
currentProps
);
updatedProps.put("instance", requestUri);
updatedProps.put("requestId", RequestIdWebFilter.requestId(exchange));
return new ProblemDetail(
problem.type(),
problem.title(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package ao.creativemode.kixi.config;

import ao.creativemode.kixi.security.RequestIdWebFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RequestIdConfig {

@Bean
public RequestIdWebFilter requestIdWebFilter() {
return new RequestIdWebFilter();
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ao.creativemode.kixi.config;

import ao.creativemode.kixi.security.JwtAuthenticationFilter;
import ao.creativemode.kixi.security.RequestIdWebFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
Expand All @@ -20,9 +21,12 @@
public class SecurityConfig {

private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final RequestIdWebFilter requestIdWebFilter;

public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
RequestIdWebFilter requestIdWebFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.requestIdWebFilter = requestIdWebFilter;
}

@Bean
Expand Down Expand Up @@ -104,6 +108,7 @@ public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
.authenticationEntryPoint(new HttpStatusServerEntryPoint(HttpStatus.UNAUTHORIZED))
.accessDeniedHandler(new HttpStatusServerAccessDeniedHandler(HttpStatus.FORBIDDEN))
)
.addFilterAt(requestIdWebFilter, SecurityWebFiltersOrder.FIRST)
.addFilterAt(jwtAuthenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION)
.build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,7 @@ public Mono<ResponseEntity<OcrResponse>> extractText(
// Validate file types
for (FilePart file : fileList) {
if (!isAllowedFileType(file.filename())) {
return Mono.error(
ApiException.badRequest(
"Invalid file type: " +
file.filename() +
". Allowed: " +
String.join(", ", ALLOWED_EXTENSIONS)
)
);
return Mono.error(invalidFileTypeException());
}
}

Expand Down Expand Up @@ -144,7 +137,8 @@ public Mono<ResponseEntity<OcrResponse>> extractText(
response.getStatusCode()
)
)
.doOnError(error -> log.error("OCR extraction failed", error));
.doOnError(error -> log.error(
"OCR extraction failed: type={}", error.getClass().getSimpleName()));
}

/**
Expand All @@ -162,18 +156,13 @@ public Mono<ResponseEntity<OcrResponse>> extractTextSingle(
) {
log.info(
"Single-file OCR extraction request received: {}",
file.filename()
safeFilename(file.filename())
);

// Validate file type
if (!isAllowedFileType(file.filename())) {
return Mono.error(
ApiException.badRequest(
"Invalid file type: " +
file.filename() +
". Allowed: " +
String.join(", ", ALLOWED_EXTENSIONS)
)
invalidFileTypeException()
);
}

Expand All @@ -199,7 +188,8 @@ public Mono<ResponseEntity<OcrResponse>> extractTextSingle(
)
)
.doOnError(error ->
log.error("Single-file OCR extraction failed", error)
log.error("Single-file OCR extraction failed: type={}",
error.getClass().getSimpleName())
);
}

Expand Down Expand Up @@ -248,14 +238,7 @@ public Mono<ResponseEntity<ExamExtractionResponse>> extractExam(
// Validate file types
for (FilePart file : fileList) {
if (!isAllowedFileType(file.filename())) {
return Mono.error(
ApiException.badRequest(
"Invalid file type: " +
file.filename() +
". Allowed: " +
String.join(", ", ALLOWED_EXTENSIONS)
)
);
return Mono.error(invalidFileTypeException());
}
}

Expand Down Expand Up @@ -291,7 +274,8 @@ public Mono<ResponseEntity<ExamExtractionResponse>> extractExam(
)
)
.doOnError(error ->
log.error("Angolan exam extraction failed", error)
log.error("Angolan exam extraction failed: type={}",
error.getClass().getSimpleName())
);
}

Expand Down Expand Up @@ -340,14 +324,7 @@ > extractAndPersist(
// Validate file types
for (FilePart file : fileList) {
if (!isAllowedFileType(file.filename())) {
return Mono.error(
ApiException.badRequest(
"Invalid file type: " +
file.filename() +
". Allowed: " +
String.join(", ", ALLOWED_EXTENSIONS)
)
);
return Mono.error(invalidFileTypeException());
}
}

Expand Down Expand Up @@ -427,7 +404,8 @@ > extractAndPersist(
)
)
.doOnError(error ->
log.error("OCR extraction and persistence failed", error)
log.error("OCR extraction and persistence failed: type={}",
error.getClass().getSimpleName())
);
}

Expand Down Expand Up @@ -456,16 +434,15 @@ public Mono<ResponseEntity<Map<String, Object>>> checkHealth() {
).body(response);
})
.onErrorResume(error -> {
log.error("OCR health check failed", error);
log.error("OCR health check failed: type={}",
error.getClass().getSimpleName());
Map<String, Object> response = Map.of(
"service",
"ocr-service",
"status",
"unavailable",
"available",
false,
"error",
error.getMessage()
false
);
return Mono.just(
ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(
Expand All @@ -488,7 +465,8 @@ > getSupportedLanguages() {
.getSupportedLanguages()
.map(ResponseEntity::ok)
.onErrorResume(error -> {
log.error("Failed to get supported languages", error);
log.error("Failed to get supported languages: type={}",
error.getClass().getSimpleName());
return Mono.error(
ApiException.badRequest(
"Failed to retrieve supported languages"
Expand Down Expand Up @@ -557,6 +535,19 @@ private boolean isAllowedFileType(String filename) {
return ALLOWED_EXTENSIONS.stream().anyMatch(lowerFilename::endsWith);
}

private static ApiException invalidFileTypeException() {
return ApiException.badRequest(
"Invalid file type. Allowed: " + String.join(", ", ALLOWED_EXTENSIONS));
}

private static String safeFilename(String filename) {
if (filename == null || filename.isBlank()) {
return "<unnamed>";
}
String sanitized = filename.replace("\r", "").replace("\n", "");
return sanitized.substring(0, Math.min(sanitized.length(), 255));
}

// =========================================================================
// Response DTOs
// =========================================================================
Expand Down
Loading
Loading