Merge pull request #81 from christianlouis/copilot/slow-down-processall-endpoint

Add configurable rate limiting to /processall endpoint
This commit is contained in:
Christian Krakau-Louis
2026-02-07 16:04:37 +01:00
committed by GitHub
7 changed files with 397 additions and 89 deletions
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+14
View File
@@ -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
View File
@@ -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
+241
View File
@@ -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