Merge pull request #81 from christianlouis/copilot/slow-down-processall-endpoint
Add configurable rate limiting to /processall endpoint
This commit is contained in:
@@ -6,6 +6,11 @@ EXTERNAL_HOSTNAME=docuelevate.example.com
|
|||||||
GOTENBERG_URL=http://gotenberg:3000
|
GOTENBERG_URL=http://gotenberg:3000
|
||||||
ALLOW_FILE_DELETE=true # Allow deletion of file records
|
ALLOW_FILE_DELETE=true # Allow deletion of file records
|
||||||
|
|
||||||
|
# **Batch Processing Settings**
|
||||||
|
# Control throttling behavior for the /processall endpoint to prevent overwhelming downstream APIs
|
||||||
|
PROCESSALL_THROTTLE_THRESHOLD=20 # Number of files above which throttling is applied (default: 20)
|
||||||
|
PROCESSALL_THROTTLE_DELAY=3 # Delay in seconds between each task submission when throttling (default: 3)
|
||||||
|
|
||||||
# **Authentication**
|
# **Authentication**
|
||||||
AUTH_ENABLED=true
|
AUTH_ENABLED=true
|
||||||
# Generate a secure random string, for example:
|
# Generate a secure random string, for example:
|
||||||
|
|||||||
+68
-45
@@ -1,26 +1,29 @@
|
|||||||
"""
|
"""
|
||||||
Document processing API endpoints
|
Document processing API endpoints
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from app.api.common import resolve_file_path
|
||||||
from app.auth import require_login
|
from app.auth import require_login
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.api.common import resolve_file_path
|
|
||||||
from app.tasks.process_document import process_document
|
from app.tasks.process_document import process_document
|
||||||
from app.tasks.upload_to_dropbox import upload_to_dropbox
|
|
||||||
from app.tasks.upload_to_paperless import upload_to_paperless
|
|
||||||
from app.tasks.upload_to_nextcloud import upload_to_nextcloud
|
|
||||||
from app.tasks.upload_to_google_drive import upload_to_google_drive
|
|
||||||
from app.tasks.upload_to_onedrive import upload_to_onedrive
|
|
||||||
from app.tasks.send_to_all import send_to_all_destinations
|
from app.tasks.send_to_all import send_to_all_destinations
|
||||||
|
from app.tasks.upload_to_dropbox import upload_to_dropbox
|
||||||
|
from app.tasks.upload_to_google_drive import upload_to_google_drive
|
||||||
|
from app.tasks.upload_to_nextcloud import upload_to_nextcloud
|
||||||
|
from app.tasks.upload_to_onedrive import upload_to_onedrive
|
||||||
|
from app.tasks.upload_to_paperless import upload_to_paperless
|
||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/process/")
|
@router.post("/process/")
|
||||||
@require_login
|
@require_login
|
||||||
def process(file_path: str):
|
def process(file_path: str):
|
||||||
@@ -28,96 +31,92 @@ def process(file_path: str):
|
|||||||
file_path = resolve_file_path(file_path)
|
file_path = resolve_file_path(file_path)
|
||||||
|
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=400, detail=f"File {file_path} not found.")
|
||||||
status_code=400, detail=f"File {file_path} not found."
|
|
||||||
)
|
|
||||||
|
|
||||||
task = process_document.delay(file_path)
|
task = process_document.delay(file_path)
|
||||||
return {"task_id": task.id, "status": "queued"}
|
return {"task_id": task.id, "status": "queued"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/send_to_dropbox/")
|
@router.post("/send_to_dropbox/")
|
||||||
@require_login
|
@require_login
|
||||||
def send_to_dropbox_endpoint(file_path: str):
|
def send_to_dropbox_endpoint(file_path: str):
|
||||||
"""Send a document to Dropbox."""
|
"""Send a document to Dropbox."""
|
||||||
file_path = resolve_file_path(file_path, 'processed')
|
file_path = resolve_file_path(file_path, "processed")
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=400, detail=f"File {file_path} not found.")
|
||||||
status_code=400, detail=f"File {file_path} not found."
|
|
||||||
)
|
|
||||||
task = upload_to_dropbox.delay(file_path)
|
task = upload_to_dropbox.delay(file_path)
|
||||||
return {"task_id": task.id, "status": "queued"}
|
return {"task_id": task.id, "status": "queued"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/send_to_paperless/")
|
@router.post("/send_to_paperless/")
|
||||||
@require_login
|
@require_login
|
||||||
def send_to_paperless_endpoint(file_path: str):
|
def send_to_paperless_endpoint(file_path: str):
|
||||||
"""Send a document to Paperless-ngx."""
|
"""Send a document to Paperless-ngx."""
|
||||||
file_path = resolve_file_path(file_path, 'processed')
|
file_path = resolve_file_path(file_path, "processed")
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=400, detail=f"File {file_path} not found.")
|
||||||
status_code=400, detail=f"File {file_path} not found."
|
|
||||||
)
|
|
||||||
task = upload_to_paperless.delay(file_path)
|
task = upload_to_paperless.delay(file_path)
|
||||||
return {"task_id": task.id, "status": "queued"}
|
return {"task_id": task.id, "status": "queued"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/send_to_nextcloud/")
|
@router.post("/send_to_nextcloud/")
|
||||||
@require_login
|
@require_login
|
||||||
def send_to_nextcloud_endpoint(file_path: str):
|
def send_to_nextcloud_endpoint(file_path: str):
|
||||||
"""Send a document to NextCloud."""
|
"""Send a document to NextCloud."""
|
||||||
file_path = resolve_file_path(file_path, 'processed')
|
file_path = resolve_file_path(file_path, "processed")
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=400, detail=f"File {file_path} not found.")
|
||||||
status_code=400, detail=f"File {file_path} not found."
|
|
||||||
)
|
|
||||||
task = upload_to_nextcloud.delay(file_path)
|
task = upload_to_nextcloud.delay(file_path)
|
||||||
return {"task_id": task.id, "status": "queued"}
|
return {"task_id": task.id, "status": "queued"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/send_to_google_drive/")
|
@router.post("/send_to_google_drive/")
|
||||||
@require_login
|
@require_login
|
||||||
def send_to_google_drive_endpoint(file_path: str):
|
def send_to_google_drive_endpoint(file_path: str):
|
||||||
"""Send a document to Google Drive."""
|
"""Send a document to Google Drive."""
|
||||||
file_path = resolve_file_path(file_path, 'processed')
|
file_path = resolve_file_path(file_path, "processed")
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=400, detail=f"File {file_path} not found.")
|
||||||
status_code=400, detail=f"File {file_path} not found."
|
|
||||||
)
|
|
||||||
task = upload_to_google_drive.delay(file_path)
|
task = upload_to_google_drive.delay(file_path)
|
||||||
return {"task_id": task.id, "status": "queued"}
|
return {"task_id": task.id, "status": "queued"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/send_to_onedrive/")
|
@router.post("/send_to_onedrive/")
|
||||||
@require_login
|
@require_login
|
||||||
def send_to_onedrive_endpoint(file_path: str):
|
def send_to_onedrive_endpoint(file_path: str):
|
||||||
"""Send a document to OneDrive."""
|
"""Send a document to OneDrive."""
|
||||||
file_path = resolve_file_path(file_path, 'processed')
|
file_path = resolve_file_path(file_path, "processed")
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=400, detail=f"File {file_path} not found.")
|
||||||
status_code=400, detail=f"File {file_path} not found."
|
|
||||||
)
|
|
||||||
task = upload_to_onedrive.delay(file_path)
|
task = upload_to_onedrive.delay(file_path)
|
||||||
return {"task_id": task.id, "status": "queued"}
|
return {"task_id": task.id, "status": "queued"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/send_to_all_destinations/")
|
@router.post("/send_to_all_destinations/")
|
||||||
@require_login
|
@require_login
|
||||||
def send_to_all_destinations_endpoint(file_path: str):
|
def send_to_all_destinations_endpoint(file_path: str):
|
||||||
"""Call the aggregator task that sends this file to all configured destinations."""
|
"""Call the aggregator task that sends this file to all configured destinations."""
|
||||||
file_path = resolve_file_path(file_path, 'processed')
|
file_path = resolve_file_path(file_path, "processed")
|
||||||
|
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=400, detail=f"File {file_path} not found.")
|
||||||
status_code=400, detail=f"File {file_path} not found."
|
|
||||||
)
|
|
||||||
|
|
||||||
task = send_to_all_destinations.delay(file_path)
|
task = send_to_all_destinations.delay(file_path)
|
||||||
return {"task_id": task.id, "status": "queued", "file_path": file_path}
|
return {"task_id": task.id, "status": "queued", "file_path": file_path}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/processall")
|
@router.post("/processall")
|
||||||
@require_login
|
@require_login
|
||||||
def process_all_pdfs_in_workdir():
|
def process_all_pdfs_in_workdir():
|
||||||
"""Finds all .pdf files in <workdir> and enqueues them for processing."""
|
"""
|
||||||
|
Finds all .pdf files in <workdir> and enqueues them for processing.
|
||||||
|
|
||||||
|
For large batches (>processall_throttle_threshold files), tasks are staggered
|
||||||
|
to avoid overwhelming downstream APIs.
|
||||||
|
"""
|
||||||
target_dir = settings.workdir
|
target_dir = settings.workdir
|
||||||
if not os.path.exists(target_dir):
|
if not os.path.exists(target_dir):
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=400, detail=f"Directory {target_dir} does not exist.")
|
||||||
status_code=400, detail=f"Directory {target_dir} does not exist."
|
|
||||||
)
|
|
||||||
|
|
||||||
pdf_files = []
|
pdf_files = []
|
||||||
for filename in os.listdir(target_dir):
|
for filename in os.listdir(target_dir):
|
||||||
@@ -128,13 +127,37 @@ def process_all_pdfs_in_workdir():
|
|||||||
return {"message": "No PDF files found in that directory."}
|
return {"message": "No PDF files found in that directory."}
|
||||||
|
|
||||||
task_ids = []
|
task_ids = []
|
||||||
for pdf in pdf_files:
|
num_files = len(pdf_files)
|
||||||
|
|
||||||
|
# Apply throttling if we have more files than the threshold
|
||||||
|
apply_throttle = num_files > settings.processall_throttle_threshold
|
||||||
|
|
||||||
|
if apply_throttle:
|
||||||
|
logger.info(
|
||||||
|
f"Processing {num_files} files with throttling "
|
||||||
|
f"(threshold: {settings.processall_throttle_threshold}, "
|
||||||
|
f"delay: {settings.processall_throttle_delay}s per file)"
|
||||||
|
)
|
||||||
|
|
||||||
|
for index, pdf in enumerate(pdf_files):
|
||||||
file_path = os.path.join(target_dir, pdf)
|
file_path = os.path.join(target_dir, pdf)
|
||||||
task = process_document.delay(file_path)
|
|
||||||
|
if apply_throttle:
|
||||||
|
# Stagger task submission with countdown
|
||||||
|
# First file starts immediately (countdown=0)
|
||||||
|
# Each subsequent file has an increasing delay
|
||||||
|
countdown = index * settings.processall_throttle_delay
|
||||||
|
task = process_document.apply_async(args=[file_path], countdown=countdown)
|
||||||
|
logger.debug(f"Scheduled {pdf} with {countdown}s delay")
|
||||||
|
else:
|
||||||
|
# No throttling - enqueue immediately
|
||||||
|
task = process_document.delay(file_path)
|
||||||
|
|
||||||
task_ids.append(task.id)
|
task_ids.append(task.id)
|
||||||
|
|
||||||
return {
|
message = f"Enqueued {num_files} PDFs for processing"
|
||||||
"message": f"Enqueued {len(pdf_files)} PDFs to upload_to_s3",
|
if apply_throttle:
|
||||||
"pdf_files": pdf_files,
|
total_time = (num_files - 1) * settings.processall_throttle_delay
|
||||||
"task_ids": task_ids
|
message += f" (throttled over {total_time} seconds)"
|
||||||
}
|
|
||||||
|
return {"message": message, "pdf_files": pdf_files, "task_ids": task_ids, "throttled": apply_throttle}
|
||||||
|
|||||||
+44
-43
@@ -1,10 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings
|
|
||||||
from typing import Optional, List, Dict, Any, Union
|
|
||||||
from pydantic import Field, validator
|
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
|
from pydantic import Field, validator
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
database_url: str
|
database_url: str
|
||||||
@@ -14,23 +16,23 @@ class Settings(BaseSettings):
|
|||||||
openai_model: str = "gpt-4o-mini" # Default model
|
openai_model: str = "gpt-4o-mini" # Default model
|
||||||
workdir: str
|
workdir: str
|
||||||
debug: bool = False # Default to False
|
debug: bool = False # Default to False
|
||||||
|
|
||||||
# Making Dropbox optional
|
# Making Dropbox optional
|
||||||
dropbox_app_key: Optional[str] = None
|
dropbox_app_key: Optional[str] = None
|
||||||
dropbox_app_secret: Optional[str] = None
|
dropbox_app_secret: Optional[str] = None
|
||||||
dropbox_folder: Optional[str] = None
|
dropbox_folder: Optional[str] = None
|
||||||
dropbox_refresh_token: Optional[str] = None
|
dropbox_refresh_token: Optional[str] = None
|
||||||
|
|
||||||
# Making Nextcloud optional
|
# Making Nextcloud optional
|
||||||
nextcloud_upload_url: Optional[str] = None
|
nextcloud_upload_url: Optional[str] = None
|
||||||
nextcloud_username: Optional[str] = None
|
nextcloud_username: Optional[str] = None
|
||||||
nextcloud_password: Optional[str] = None
|
nextcloud_password: Optional[str] = None
|
||||||
nextcloud_folder: Optional[str] = None
|
nextcloud_folder: Optional[str] = None
|
||||||
|
|
||||||
# Making Paperless optional
|
# Making Paperless optional
|
||||||
paperless_ngx_api_token: Optional[str] = None
|
paperless_ngx_api_token: Optional[str] = None
|
||||||
paperless_host: Optional[str] = None
|
paperless_host: Optional[str] = None
|
||||||
|
|
||||||
azure_ai_key: str
|
azure_ai_key: str
|
||||||
azure_region: str
|
azure_region: str
|
||||||
azure_endpoint: str
|
azure_endpoint: str
|
||||||
@@ -71,7 +73,7 @@ class Settings(BaseSettings):
|
|||||||
google_drive_credentials_json: Optional[str] = ""
|
google_drive_credentials_json: Optional[str] = ""
|
||||||
google_drive_folder_id: Optional[str] = ""
|
google_drive_folder_id: Optional[str] = ""
|
||||||
google_drive_delegate_to: Optional[str] = "" # Optional delegated user email
|
google_drive_delegate_to: Optional[str] = "" # Optional delegated user email
|
||||||
|
|
||||||
# Google Drive OAuth settings
|
# Google Drive OAuth settings
|
||||||
google_drive_use_oauth: bool = False # Default to service account method
|
google_drive_use_oauth: bool = False # Default to service account method
|
||||||
google_drive_client_id: Optional[str] = ""
|
google_drive_client_id: Optional[str] = ""
|
||||||
@@ -135,49 +137,45 @@ class Settings(BaseSettings):
|
|||||||
# Feature flags
|
# Feature flags
|
||||||
allow_file_delete: bool = True # Default to allowing file deletion from database
|
allow_file_delete: bool = True # Default to allowing file deletion from database
|
||||||
|
|
||||||
|
# Batch processing settings
|
||||||
|
processall_throttle_threshold: int = Field(
|
||||||
|
default=20, description="Number of files above which throttling is applied in /processall endpoint"
|
||||||
|
)
|
||||||
|
processall_throttle_delay: int = Field(
|
||||||
|
default=3, description="Delay in seconds between each task submission when throttling in /processall"
|
||||||
|
)
|
||||||
|
|
||||||
# Notification settings
|
# Notification settings
|
||||||
notification_urls: Union[List[str], str] = Field(
|
notification_urls: Union[List[str], str] = Field(
|
||||||
default_factory=list,
|
default_factory=list, description="List of Apprise notification URLs (e.g., discord://, telegram://, etc.)"
|
||||||
description="List of Apprise notification URLs (e.g., discord://, telegram://, etc.)"
|
|
||||||
)
|
|
||||||
notify_on_task_failure: bool = Field(
|
|
||||||
default=True,
|
|
||||||
description="Send notifications when Celery tasks fail"
|
|
||||||
)
|
)
|
||||||
|
notify_on_task_failure: bool = Field(default=True, description="Send notifications when Celery tasks fail")
|
||||||
notify_on_credential_failure: bool = Field(
|
notify_on_credential_failure: bool = Field(
|
||||||
default=True,
|
default=True, description="Send notifications when credential checks fail"
|
||||||
description="Send notifications when credential checks fail"
|
|
||||||
)
|
|
||||||
notify_on_startup: bool = Field(
|
|
||||||
default=True,
|
|
||||||
description="Send notifications when application starts"
|
|
||||||
)
|
|
||||||
notify_on_shutdown: bool = Field(
|
|
||||||
default=False,
|
|
||||||
description="Send notifications when application shuts down"
|
|
||||||
)
|
)
|
||||||
|
notify_on_startup: bool = Field(default=True, description="Send notifications when application starts")
|
||||||
|
notify_on_shutdown: bool = Field(default=False, description="Send notifications when application shuts down")
|
||||||
notify_on_file_processed: bool = Field(
|
notify_on_file_processed: bool = Field(
|
||||||
default=True,
|
default=True, description="Send notifications when files are successfully processed"
|
||||||
description="Send notifications when files are successfully processed"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@validator('notification_urls', pre=True)
|
@validator("notification_urls", pre=True)
|
||||||
def parse_notification_urls(cls, v):
|
def parse_notification_urls(cls, v):
|
||||||
"""Parse notification URLs from string or list"""
|
"""Parse notification URLs from string or list"""
|
||||||
if isinstance(v, str):
|
if isinstance(v, str):
|
||||||
if ',' in v:
|
if "," in v:
|
||||||
return [url.strip() for url in v.split(',') if url.strip()]
|
return [url.strip() for url in v.split(",") if url.strip()]
|
||||||
elif v.strip():
|
elif v.strip():
|
||||||
return [v.strip()]
|
return [v.strip()]
|
||||||
return []
|
return []
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@validator('session_secret')
|
@validator("session_secret")
|
||||||
def validate_session_secret(cls, v, values):
|
def validate_session_secret(cls, v, values):
|
||||||
"""Validate that session_secret is set and has sufficient length when auth is enabled"""
|
"""Validate that session_secret is set and has sufficient length when auth is enabled"""
|
||||||
if values.get('auth_enabled') and not v:
|
if values.get("auth_enabled") and not v:
|
||||||
raise ValueError("SESSION_SECRET must be set when AUTH_ENABLED=True")
|
raise ValueError("SESSION_SECRET must be set when AUTH_ENABLED=True")
|
||||||
if values.get('auth_enabled') and v and len(v) < 32:
|
if values.get("auth_enabled") and v and len(v) < 32:
|
||||||
raise ValueError("SESSION_SECRET must be at least 32 characters long")
|
raise ValueError("SESSION_SECRET must be at least 32 characters long")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@@ -188,13 +186,13 @@ class Settings(BaseSettings):
|
|||||||
env_build_date = os.environ.get("BUILD_DATE")
|
env_build_date = os.environ.get("BUILD_DATE")
|
||||||
if env_build_date:
|
if env_build_date:
|
||||||
return env_build_date
|
return env_build_date
|
||||||
|
|
||||||
# Then try to get build date from BUILD_DATE file
|
# Then try to get build date from BUILD_DATE file
|
||||||
build_date_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "BUILD_DATE")
|
build_date_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "BUILD_DATE")
|
||||||
if os.path.exists(build_date_file):
|
if os.path.exists(build_date_file):
|
||||||
with open(build_date_file, "r") as f:
|
with open(build_date_file, "r") as f:
|
||||||
return f.read().strip()
|
return f.read().strip()
|
||||||
|
|
||||||
# Default to unknown if not found
|
# Default to unknown if not found
|
||||||
return "Unknown build date"
|
return "Unknown build date"
|
||||||
|
|
||||||
@@ -205,35 +203,38 @@ class Settings(BaseSettings):
|
|||||||
env_version = os.environ.get("APP_VERSION")
|
env_version = os.environ.get("APP_VERSION")
|
||||||
if env_version:
|
if env_version:
|
||||||
return env_version
|
return env_version
|
||||||
|
|
||||||
# Then try to get version from VERSION file
|
# Then try to get version from VERSION file
|
||||||
version_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "VERSION")
|
version_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "VERSION")
|
||||||
if os.path.exists(version_file):
|
if os.path.exists(version_file):
|
||||||
with open(version_file, "r") as f:
|
with open(version_file, "r") as f:
|
||||||
return f.read().strip()
|
return f.read().strip()
|
||||||
|
|
||||||
# Default version if not found
|
# Default version if not found
|
||||||
return "0.3.2-dev"
|
return "0.3.2-dev"
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
|
|
||||||
# Convert string representations of booleans to actual booleans
|
# Convert string representations of booleans to actual booleans
|
||||||
# and strip quotes from string values
|
# and strip quotes from string values
|
||||||
@classmethod
|
@classmethod
|
||||||
def parse_env_var(cls, field_name: str, raw_val: str) -> Any:
|
def parse_env_var(cls, field_name: str, raw_val: str) -> Any:
|
||||||
# First, strip quotes from the value if it's a string
|
# First, strip quotes from the value if it's a string
|
||||||
if isinstance(raw_val, str):
|
if isinstance(raw_val, str):
|
||||||
if (raw_val.startswith('"') and raw_val.endswith('"')) or \
|
if (raw_val.startswith('"') and raw_val.endswith('"')) or (
|
||||||
(raw_val.startswith("'") and raw_val.endswith("'")):
|
raw_val.startswith("'") and raw_val.endswith("'")
|
||||||
|
):
|
||||||
raw_val = raw_val[1:-1]
|
raw_val = raw_val[1:-1]
|
||||||
raw_val = raw_val.strip()
|
raw_val = raw_val.strip()
|
||||||
|
|
||||||
# Convert string representations of booleans to actual booleans
|
# Convert string representations of booleans to actual booleans
|
||||||
if field_name.endswith('_enabled') or field_name == 'debug':
|
if field_name.endswith("_enabled") or field_name == "debug":
|
||||||
if raw_val.lower() in ('false', '0', 'no', 'n', 'f'):
|
if raw_val.lower() in ("false", "0", "no", "n", "f"):
|
||||||
return False
|
return False
|
||||||
if raw_val.lower() in ('true', '1', 'yes', 'y', 't'):
|
if raw_val.lower() in ("true", "1", "yes", "y", "t"):
|
||||||
return True
|
return True
|
||||||
return raw_val
|
return raw_val
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
+23
@@ -101,6 +101,29 @@ Reprocess a specific file.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Batch Processing
|
||||||
|
|
||||||
|
**POST** `/api/processall`
|
||||||
|
|
||||||
|
Process all PDF files in the configured workdir directory.
|
||||||
|
|
||||||
|
**Throttling**: For large batches (>20 files by default), tasks are automatically staggered to prevent overwhelming downstream APIs. The throttling behavior can be configured via environment variables:
|
||||||
|
|
||||||
|
- `PROCESSALL_THROTTLE_THRESHOLD`: Number of files above which throttling is applied (default: 20)
|
||||||
|
- `PROCESSALL_THROTTLE_DELAY`: Delay in seconds between each task submission when throttling (default: 3)
|
||||||
|
|
||||||
|
**Example**: When processing 25 files with default settings, the first file is queued immediately, the second after 3 seconds, the third after 6 seconds, etc., spreading the load over 72 seconds total.
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Enqueued 25 PDFs for processing (throttled over 72 seconds)",
|
||||||
|
"pdf_files": ["file1.pdf", "file2.pdf", ...],
|
||||||
|
"task_ids": ["a1b2c3...", "d4e5f6...", ...],
|
||||||
|
"throttled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
**POST** `/send_to_google_drive/`
|
**POST** `/send_to_google_drive/`
|
||||||
|
|
||||||
Send a processed file to Google Drive.
|
Send a processed file to Google Drive.
|
||||||
|
|||||||
@@ -17,6 +17,20 @@ Configuration is primarily done through environment variables specified in a `.e
|
|||||||
| `EXTERNAL_HOSTNAME` | The external hostname for the application. | `docuelevate.example.com` |
|
| `EXTERNAL_HOSTNAME` | The external hostname for the application. | `docuelevate.example.com` |
|
||||||
| `ALLOW_FILE_DELETE` | Enable file deletion in the web interface (`true`/`false`). | `true` |
|
| `ALLOW_FILE_DELETE` | Enable file deletion in the web interface (`true`/`false`). | `true` |
|
||||||
|
|
||||||
|
### Batch Processing Settings
|
||||||
|
|
||||||
|
Control how the `/processall` endpoint handles large batches of files to prevent overwhelming downstream APIs.
|
||||||
|
|
||||||
|
| **Variable** | **Description** | **Default** |
|
||||||
|
|-----------------------------------|----------------------------------------------------------------------------------------------------|-------------|
|
||||||
|
| `PROCESSALL_THROTTLE_THRESHOLD` | Number of files above which throttling is applied. Files <= threshold are processed immediately. | `20` |
|
||||||
|
| `PROCESSALL_THROTTLE_DELAY` | Delay in seconds between each task submission when throttling is active. | `3` |
|
||||||
|
|
||||||
|
**Example Usage**: When processing 25 files with default settings:
|
||||||
|
- Files are staggered: file 0 at 0s, file 1 at 3s, file 2 at 6s, etc.
|
||||||
|
- Total queue time: (25-1) × 3 = 72 seconds
|
||||||
|
- Prevents API rate limit issues and ensures smooth processing
|
||||||
|
|
||||||
### IMAP Configuration
|
### IMAP Configuration
|
||||||
|
|
||||||
DocuElevate can monitor multiple IMAP mailboxes for document attachments. Each mailbox uses a numbered prefix (e.g., `IMAP1_`, `IMAP2_`).
|
DocuElevate can monitor multiple IMAP mailboxes for document attachments. Each mailbox uses a numbered prefix (e.g., `IMAP1_`, `IMAP2_`).
|
||||||
|
|||||||
+2
-1
@@ -70,7 +70,8 @@ def client(db_session) -> TestClient:
|
|||||||
|
|
||||||
fastapi_app.dependency_overrides[get_db] = override_get_db
|
fastapi_app.dependency_overrides[get_db] = override_get_db
|
||||||
|
|
||||||
with TestClient(fastapi_app) as test_client:
|
# Use base_url to satisfy TrustedHostMiddleware
|
||||||
|
with TestClient(fastapi_app, base_url="http://localhost") as test_client:
|
||||||
yield test_client
|
yield test_client
|
||||||
|
|
||||||
# Clean up
|
# Clean up
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
"""
|
||||||
|
Tests for /processall endpoint throttling behavior.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_throttle_settings(monkeypatch):
|
||||||
|
"""Fixture to configure throttle settings for tests."""
|
||||||
|
|
||||||
|
def _configure(workdir, threshold=20, delay=3):
|
||||||
|
from app.api import process
|
||||||
|
|
||||||
|
monkeypatch.setattr(process.settings, "workdir", str(workdir))
|
||||||
|
monkeypatch.setattr(process.settings, "processall_throttle_threshold", threshold)
|
||||||
|
monkeypatch.setattr(process.settings, "processall_throttle_delay", delay)
|
||||||
|
|
||||||
|
return _configure
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestProcessAllThrottling:
|
||||||
|
"""Tests for processall endpoint with throttling."""
|
||||||
|
|
||||||
|
@patch("app.api.process.process_document")
|
||||||
|
def test_processall_no_throttling_for_small_batch(
|
||||||
|
self, mock_task, client: TestClient, tmp_path, mock_throttle_settings
|
||||||
|
):
|
||||||
|
"""Test that small batches (<=20 files) are not throttled."""
|
||||||
|
# Create test directory with 10 PDF files
|
||||||
|
workdir = tmp_path / "workdir"
|
||||||
|
workdir.mkdir()
|
||||||
|
|
||||||
|
for i in range(10):
|
||||||
|
(workdir / f"test{i}.pdf").write_text("dummy pdf content")
|
||||||
|
|
||||||
|
# Mock the task
|
||||||
|
mock_task.delay = Mock(return_value=Mock(id="task-id"))
|
||||||
|
mock_task.apply_async = Mock(return_value=Mock(id="task-id"))
|
||||||
|
|
||||||
|
# Configure settings
|
||||||
|
mock_throttle_settings(workdir, threshold=20, delay=3)
|
||||||
|
|
||||||
|
response = client.post("/api/processall")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Should use .delay() (not throttled)
|
||||||
|
assert mock_task.delay.call_count == 10
|
||||||
|
assert mock_task.apply_async.call_count == 0
|
||||||
|
|
||||||
|
# Response should indicate no throttling
|
||||||
|
assert data["throttled"] is False
|
||||||
|
assert len(data["pdf_files"]) == 10
|
||||||
|
assert len(data["task_ids"]) == 10
|
||||||
|
|
||||||
|
@patch("app.api.process.process_document")
|
||||||
|
def test_processall_throttling_for_large_batch(
|
||||||
|
self, mock_task, client: TestClient, tmp_path, mock_throttle_settings
|
||||||
|
):
|
||||||
|
"""Test that large batches (>20 files) are throttled."""
|
||||||
|
# Create test directory with 25 PDF files
|
||||||
|
workdir = tmp_path / "workdir"
|
||||||
|
workdir.mkdir()
|
||||||
|
|
||||||
|
for i in range(25):
|
||||||
|
(workdir / f"test{i}.pdf").write_text("dummy pdf content")
|
||||||
|
|
||||||
|
# Mock the task
|
||||||
|
mock_task_result = Mock(id="task-id")
|
||||||
|
mock_task.apply_async = Mock(return_value=mock_task_result)
|
||||||
|
|
||||||
|
# Configure settings
|
||||||
|
mock_throttle_settings(workdir, threshold=20, delay=3)
|
||||||
|
|
||||||
|
response = client.post("/api/processall")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Should use .apply_async() with countdown (throttled)
|
||||||
|
assert mock_task.apply_async.call_count == 25
|
||||||
|
|
||||||
|
# Verify countdown values are increasing
|
||||||
|
calls = mock_task.apply_async.call_args_list
|
||||||
|
for i, call_args in enumerate(calls):
|
||||||
|
expected_countdown = i * 3 # 3 seconds delay
|
||||||
|
assert call_args[1]["countdown"] == expected_countdown
|
||||||
|
|
||||||
|
# Response should indicate throttling
|
||||||
|
assert data["throttled"] is True
|
||||||
|
assert len(data["pdf_files"]) == 25
|
||||||
|
assert len(data["task_ids"]) == 25
|
||||||
|
assert "throttled over" in data["message"]
|
||||||
|
|
||||||
|
@patch("app.api.process.process_document")
|
||||||
|
def test_processall_exactly_at_threshold(self, mock_task, client: TestClient, tmp_path, mock_throttle_settings):
|
||||||
|
"""Test behavior when file count equals threshold."""
|
||||||
|
# Create test directory with exactly 20 PDF files
|
||||||
|
workdir = tmp_path / "workdir"
|
||||||
|
workdir.mkdir()
|
||||||
|
|
||||||
|
for i in range(20):
|
||||||
|
(workdir / f"test{i}.pdf").write_text("dummy pdf content")
|
||||||
|
|
||||||
|
# Mock the task
|
||||||
|
mock_task.delay = Mock(return_value=Mock(id="task-id"))
|
||||||
|
|
||||||
|
# Configure settings
|
||||||
|
mock_throttle_settings(workdir, threshold=20, delay=3)
|
||||||
|
|
||||||
|
response = client.post("/api/processall")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# At threshold, should NOT throttle (only >threshold)
|
||||||
|
assert mock_task.delay.call_count == 20
|
||||||
|
assert data["throttled"] is False
|
||||||
|
|
||||||
|
@patch("app.api.process.process_document")
|
||||||
|
def test_processall_one_over_threshold(self, mock_task, client: TestClient, tmp_path, mock_throttle_settings):
|
||||||
|
"""Test that throttling activates at threshold + 1."""
|
||||||
|
# Create test directory with 21 PDF files (threshold is 20)
|
||||||
|
workdir = tmp_path / "workdir"
|
||||||
|
workdir.mkdir()
|
||||||
|
|
||||||
|
for i in range(21):
|
||||||
|
(workdir / f"test{i}.pdf").write_text("dummy pdf content")
|
||||||
|
|
||||||
|
# Mock the task
|
||||||
|
mock_task.apply_async = Mock(return_value=Mock(id="task-id"))
|
||||||
|
|
||||||
|
# Configure settings
|
||||||
|
mock_throttle_settings(workdir, threshold=20, delay=3)
|
||||||
|
|
||||||
|
response = client.post("/api/processall")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Should be throttled
|
||||||
|
assert mock_task.apply_async.call_count == 21
|
||||||
|
assert data["throttled"] is True
|
||||||
|
|
||||||
|
def test_processall_empty_directory(self, client: TestClient, tmp_path, mock_throttle_settings):
|
||||||
|
"""Test processall with no PDF files."""
|
||||||
|
workdir = tmp_path / "workdir"
|
||||||
|
workdir.mkdir()
|
||||||
|
|
||||||
|
# Configure settings
|
||||||
|
mock_throttle_settings(workdir)
|
||||||
|
|
||||||
|
response = client.post("/api/processall")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["message"] == "No PDF files found in that directory."
|
||||||
|
|
||||||
|
def test_processall_nonexistent_directory(self, client: TestClient, tmp_path, mock_throttle_settings):
|
||||||
|
"""Test processall with non-existent directory."""
|
||||||
|
workdir = tmp_path / "nonexistent"
|
||||||
|
|
||||||
|
# Configure settings
|
||||||
|
mock_throttle_settings(workdir)
|
||||||
|
|
||||||
|
response = client.post("/api/processall")
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.json()
|
||||||
|
assert "does not exist" in data["detail"]
|
||||||
|
|
||||||
|
@patch("app.api.process.process_document")
|
||||||
|
def test_processall_custom_threshold(self, mock_task, client: TestClient, tmp_path, mock_throttle_settings):
|
||||||
|
"""Test that custom threshold value is respected."""
|
||||||
|
# Create test directory with 15 PDF files
|
||||||
|
workdir = tmp_path / "workdir"
|
||||||
|
workdir.mkdir()
|
||||||
|
|
||||||
|
for i in range(15):
|
||||||
|
(workdir / f"test{i}.pdf").write_text("dummy pdf content")
|
||||||
|
|
||||||
|
# Mock the task
|
||||||
|
mock_task.apply_async = Mock(return_value=Mock(id="task-id"))
|
||||||
|
|
||||||
|
# Configure settings with lower threshold
|
||||||
|
mock_throttle_settings(workdir, threshold=10, delay=2)
|
||||||
|
|
||||||
|
response = client.post("/api/processall")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Should be throttled because 15 > 10
|
||||||
|
assert mock_task.apply_async.call_count == 15
|
||||||
|
assert data["throttled"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestThrottlingConfiguration:
|
||||||
|
"""Tests for throttling configuration settings."""
|
||||||
|
|
||||||
|
def test_default_throttle_threshold(self):
|
||||||
|
"""Test that default threshold is 20."""
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
settings = Settings(
|
||||||
|
database_url="sqlite:///test.db",
|
||||||
|
redis_url="redis://localhost",
|
||||||
|
openai_api_key="test-key",
|
||||||
|
workdir="/tmp",
|
||||||
|
azure_ai_key="test-key",
|
||||||
|
azure_region="test-region",
|
||||||
|
azure_endpoint="https://test.endpoint",
|
||||||
|
gotenberg_url="http://gotenberg",
|
||||||
|
session_secret="a" * 32,
|
||||||
|
)
|
||||||
|
assert settings.processall_throttle_threshold == 20
|
||||||
|
|
||||||
|
def test_default_throttle_delay(self):
|
||||||
|
"""Test that default delay is 3 seconds."""
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
settings = Settings(
|
||||||
|
database_url="sqlite:///test.db",
|
||||||
|
redis_url="redis://localhost",
|
||||||
|
openai_api_key="test-key",
|
||||||
|
workdir="/tmp",
|
||||||
|
azure_ai_key="test-key",
|
||||||
|
azure_region="test-region",
|
||||||
|
azure_endpoint="https://test.endpoint",
|
||||||
|
gotenberg_url="http://gotenberg",
|
||||||
|
session_secret="a" * 32,
|
||||||
|
)
|
||||||
|
assert settings.processall_throttle_delay == 3
|
||||||
Reference in New Issue
Block a user