arden scrape safety telemetry and polish run state UX #7

Merged
JustinZeus merged 1 commit from feat/scrape-safety-observability-and-run-state-ux into main 2026-02-19 22:10:46 +01:00
33 changed files with 2074 additions and 96 deletions

View file

@ -46,6 +46,10 @@ LOG_REQUEST_SKIP_PATHS=/healthz
LOG_REDACT_FIELDS= LOG_REDACT_FIELDS=
SCHEDULER_ENABLED=1 SCHEDULER_ENABLED=1
SCHEDULER_TICK_SECONDS=60 SCHEDULER_TICK_SECONDS=60
INGESTION_AUTOMATION_ALLOWED=1
INGESTION_MANUAL_RUN_ALLOWED=1
INGESTION_MIN_RUN_INTERVAL_MINUTES=15
INGESTION_MIN_REQUEST_DELAY_SECONDS=2
INGESTION_NETWORK_ERROR_RETRIES=1 INGESTION_NETWORK_ERROR_RETRIES=1
INGESTION_RETRY_BACKOFF_SECONDS=1.0 INGESTION_RETRY_BACKOFF_SECONDS=1.0
INGESTION_MAX_PAGES_PER_SCHOLAR=30 INGESTION_MAX_PAGES_PER_SCHOLAR=30
@ -53,6 +57,8 @@ INGESTION_PAGE_SIZE=100
INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD=1 INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD=1
INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD=2 INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD=2
INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD=3 INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD=3
INGESTION_SAFETY_COOLDOWN_BLOCKED_SECONDS=1800
INGESTION_SAFETY_COOLDOWN_NETWORK_SECONDS=900
INGESTION_CONTINUATION_QUEUE_ENABLED=1 INGESTION_CONTINUATION_QUEUE_ENABLED=1
INGESTION_CONTINUATION_BASE_DELAY_SECONDS=120 INGESTION_CONTINUATION_BASE_DELAY_SECONDS=120
INGESTION_CONTINUATION_MAX_DELAY_SECONDS=3600 INGESTION_CONTINUATION_MAX_DELAY_SECONDS=3600

View file

@ -70,6 +70,7 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml down
- `Dockerfile`: multi-stage build (frontend + backend runtime). - `Dockerfile`: multi-stage build (frontend + backend runtime).
- `README.md`: deployment and operations reference. - `README.md`: deployment and operations reference.
- `CONTRIBUTING.md`: contribution policy and merge checklist. - `CONTRIBUTING.md`: contribution policy and merge checklist.
- `docs/ops/scrape_safety_runbook.md`: scrape cooldown and blocked-IP operations guide.
- `scripts/check_no_generated_artifacts.sh`: tracked-artifact guard used by CI. - `scripts/check_no_generated_artifacts.sh`: tracked-artifact guard used by CI.
## Data Model Notes ## Data Model Notes
@ -170,6 +171,10 @@ Notes:
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `SCHEDULER_ENABLED` | `1` | boolean | deploy, dev | Enable background scheduler loop. | | `SCHEDULER_ENABLED` | `1` | boolean | deploy, dev | Enable background scheduler loop. |
| `SCHEDULER_TICK_SECONDS` | `60` | integer >= 1 | deploy, dev | Scheduler interval in seconds. | | `SCHEDULER_TICK_SECONDS` | `60` | integer >= 1 | deploy, dev | Scheduler interval in seconds. |
| `INGESTION_AUTOMATION_ALLOWED` | `1` | boolean | deploy, dev | Global safety switch for scheduled/automatic checks. When disabled, auto-run settings are forced off. |
| `INGESTION_MANUAL_RUN_ALLOWED` | `1` | boolean | deploy, dev | Global safety switch for manual checks from API/UI. |
| `INGESTION_MIN_RUN_INTERVAL_MINUTES` | `15` | integer >= 15 | deploy, dev | Server-enforced minimum for user-configured automatic check interval. |
| `INGESTION_MIN_REQUEST_DELAY_SECONDS` | `2` | integer >= 2 | deploy, dev | Server-enforced minimum delay between scholar requests. |
| `SCHEDULER_QUEUE_BATCH_SIZE` | `10` | integer >= 1 | deploy, dev | Queue items processed per scheduler tick. | | `SCHEDULER_QUEUE_BATCH_SIZE` | `10` | integer >= 1 | deploy, dev | Queue items processed per scheduler tick. |
| `INGESTION_NETWORK_ERROR_RETRIES` | `1` | integer >= 0 | deploy, dev | Retries for transient ingestion network errors. | | `INGESTION_NETWORK_ERROR_RETRIES` | `1` | integer >= 0 | deploy, dev | Retries for transient ingestion network errors. |
| `INGESTION_RETRY_BACKOFF_SECONDS` | `1.0` | float >= 0 | deploy, dev | Backoff delay for retry attempts. | | `INGESTION_RETRY_BACKOFF_SECONDS` | `1.0` | float >= 0 | deploy, dev | Backoff delay for retry attempts. |
@ -178,6 +183,8 @@ Notes:
| `INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD` | `1` | integer >= 1 | deploy, dev | Trigger blocked/captcha scrape alert flag when this many blocked failures occur in a run. | | `INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD` | `1` | integer >= 1 | deploy, dev | Trigger blocked/captcha scrape alert flag when this many blocked failures occur in a run. |
| `INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD` | `2` | integer >= 1 | deploy, dev | Trigger network scrape alert flag when this many network failures occur in a run. | | `INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD` | `2` | integer >= 1 | deploy, dev | Trigger network scrape alert flag when this many network failures occur in a run. |
| `INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD` | `3` | integer >= 1 | deploy, dev | Trigger retry alert flag when scheduled retry count reaches this threshold in a run. | | `INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD` | `3` | integer >= 1 | deploy, dev | Trigger retry alert flag when scheduled retry count reaches this threshold in a run. |
| `INGESTION_SAFETY_COOLDOWN_BLOCKED_SECONDS` | `1800` | integer >= 60 | deploy, dev | Cooldown duration applied when blocked-failure threshold is exceeded. |
| `INGESTION_SAFETY_COOLDOWN_NETWORK_SECONDS` | `900` | integer >= 60 | deploy, dev | Cooldown duration applied when network-failure threshold is exceeded. |
| `INGESTION_CONTINUATION_QUEUE_ENABLED` | `1` | boolean | deploy, dev | Enable continuation queue for long runs. | | `INGESTION_CONTINUATION_QUEUE_ENABLED` | `1` | boolean | deploy, dev | Enable continuation queue for long runs. |
| `INGESTION_CONTINUATION_BASE_DELAY_SECONDS` | `120` | integer >= 0 | deploy, dev | Initial delay before retrying continuation queue items. | | `INGESTION_CONTINUATION_BASE_DELAY_SECONDS` | `120` | integer >= 0 | deploy, dev | Initial delay before retrying continuation queue items. |
| `INGESTION_CONTINUATION_MAX_DELAY_SECONDS` | `3600` | integer >= 0 | deploy, dev | Maximum continuation retry delay. | | `INGESTION_CONTINUATION_MAX_DELAY_SECONDS` | `3600` | integer >= 0 | deploy, dev | Maximum continuation retry delay. |
@ -200,6 +207,16 @@ Notes:
| `SCHOLAR_NAME_SEARCH_ALERT_RETRY_COUNT_THRESHOLD` | `2` | integer >= 1 | deploy, dev | Emit retry-threshold observability warning when name-search retry count reaches this value. | | `SCHOLAR_NAME_SEARCH_ALERT_RETRY_COUNT_THRESHOLD` | `2` | integer >= 1 | deploy, dev | Emit retry-threshold observability warning when name-search retry count reaches this value. |
| `SCHOLAR_NAME_SEARCH_ALERT_COOLDOWN_REJECTIONS_THRESHOLD` | `3` | integer >= 1 | deploy, dev | Emit cooldown-threshold observability alert after this many requests are rejected during active cooldown. | | `SCHOLAR_NAME_SEARCH_ALERT_COOLDOWN_REJECTIONS_THRESHOLD` | `3` | integer >= 1 | deploy, dev | Emit cooldown-threshold observability alert after this many requests are rejected during active cooldown. |
### Scrape Safety Operations
- Structured safety events are emitted for:
- policy/manual run blocks,
- cooldown entered/cleared transitions,
- blocked/network/retry threshold trips,
- scheduler cooldown skips/deferments.
- Use `LOG_FORMAT=json` and ship logs to your preferred collector for alerting.
- Runbook: `docs/ops/scrape_safety_runbook.md`.
### Startup Bootstrap and DB Wait ### Startup Bootstrap and DB Wait
| Variable | Default | Options | Scope | Description | | Variable | Default | Options | Scope | Description |

View file

@ -0,0 +1,66 @@
"""Add scrape safety state fields to user settings.
Revision ID: 20260219_0009
Revises: 20260219_0008
Create Date: 2026-02-19 21:20:00.000000
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "20260219_0009"
down_revision: str | Sequence[str] | None = "20260219_0008"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
TABLE_NAME = "user_settings"
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {column["name"] for column in inspector.get_columns(TABLE_NAME)}
if "scrape_safety_state" not in columns:
op.add_column(
TABLE_NAME,
sa.Column(
"scrape_safety_state",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default=sa.text("'{}'::jsonb"),
),
)
if "scrape_cooldown_until" not in columns:
op.add_column(
TABLE_NAME,
sa.Column("scrape_cooldown_until", sa.DateTime(timezone=True), nullable=True),
)
if "scrape_cooldown_reason" not in columns:
op.add_column(
TABLE_NAME,
sa.Column("scrape_cooldown_reason", sa.String(length=64), nullable=True),
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {column["name"] for column in inspector.get_columns(TABLE_NAME)}
if "scrape_cooldown_reason" in columns:
op.drop_column(TABLE_NAME, "scrape_cooldown_reason")
if "scrape_cooldown_until" in columns:
op.drop_column(TABLE_NAME, "scrape_cooldown_until")
if "scrape_safety_state" in columns:
op.drop_column(TABLE_NAME, "scrape_safety_state")

View file

@ -22,6 +22,7 @@ from app.api.schemas import (
from app.db.models import RunStatus, RunTriggerType, User from app.db.models import RunStatus, RunTriggerType, User
from app.db.session import get_db_session from app.db.session import get_db_session
from app.services import ingestion as ingestion_service from app.services import ingestion as ingestion_service
from app.services import run_safety as run_safety_service
from app.services import runs as run_service from app.services import runs as run_service
from app.services import user_settings as user_settings_service from app.services import user_settings as user_settings_service
from app.settings import settings from app.settings import settings
@ -253,6 +254,7 @@ def _manual_run_payload_from_run(
*, *,
idempotency_key: str | None, idempotency_key: str | None,
reused_existing_run: bool, reused_existing_run: bool,
safety_state: dict[str, Any],
) -> dict[str, Any]: ) -> dict[str, Any]:
summary = run_service.extract_run_summary(run.error_log) summary = run_service.extract_run_summary(run.error_log)
return { return {
@ -265,9 +267,37 @@ def _manual_run_payload_from_run(
"new_publication_count": int(run.new_pub_count or 0), "new_publication_count": int(run.new_pub_count or 0),
"reused_existing_run": reused_existing_run, "reused_existing_run": reused_existing_run,
"idempotency_key": idempotency_key, "idempotency_key": idempotency_key,
"safety_state": safety_state,
} }
async def _load_safety_state(
db_session: AsyncSession,
*,
user_id: int,
) -> dict[str, Any]:
user_settings = await user_settings_service.get_or_create_settings(
db_session,
user_id=user_id,
)
previous_safety_state = run_safety_service.get_safety_event_context(user_settings)
if run_safety_service.clear_expired_cooldown(user_settings):
await db_session.commit()
await db_session.refresh(user_settings)
logger.info(
"api.runs.safety_cooldown_cleared",
extra={
"event": "api.runs.safety_cooldown_cleared",
"user_id": user_id,
"reason": previous_safety_state.get("cooldown_reason"),
"cooldown_until": previous_safety_state.get("cooldown_until"),
"metric_name": "api_runs_safety_cooldown_cleared_total",
"metric_value": 1,
},
)
return run_safety_service.get_safety_state_payload(user_settings)
@router.get( @router.get(
"", "",
response_model=RunsListEnvelope, response_model=RunsListEnvelope,
@ -285,10 +315,15 @@ async def list_runs(
limit=limit, limit=limit,
failed_only=failed_only, failed_only=failed_only,
) )
safety_state = await _load_safety_state(
db_session,
user_id=current_user.id,
)
return success_payload( return success_payload(
request, request,
data={ data={
"runs": [_serialize_run(run) for run in runs], "runs": [_serialize_run(run) for run in runs],
"safety_state": safety_state,
}, },
) )
@ -318,12 +353,17 @@ async def get_run(
scholar_results = error_log.get("scholar_results") scholar_results = error_log.get("scholar_results")
if not isinstance(scholar_results, list): if not isinstance(scholar_results, list):
scholar_results = [] scholar_results = []
safety_state = await _load_safety_state(
db_session,
user_id=current_user.id,
)
return success_payload( return success_payload(
request, request,
data={ data={
"run": _serialize_run(run), "run": _serialize_run(run),
"summary": run_service.extract_run_summary(error_log), "summary": run_service.extract_run_summary(error_log),
"scholar_results": [_normalize_scholar_result(item) for item in scholar_results], "scholar_results": [_normalize_scholar_result(item) for item in scholar_results],
"safety_state": safety_state,
}, },
) )
@ -338,6 +378,34 @@ async def run_manual(
current_user: User = Depends(get_api_current_user), current_user: User = Depends(get_api_current_user),
ingest_service: ingestion_service.ScholarIngestionService = Depends(get_ingestion_service), ingest_service: ingestion_service.ScholarIngestionService = Depends(get_ingestion_service),
): ):
safety_state = await _load_safety_state(
db_session,
user_id=current_user.id,
)
if not settings.ingestion_manual_run_allowed:
logger.warning(
"api.runs.manual_blocked_policy",
extra={
"event": "api.runs.manual_blocked_policy",
"user_id": current_user.id,
"policy": {"manual_run_allowed": False},
"safety_state": safety_state,
"metric_name": "api_runs_manual_blocked_policy_total",
"metric_value": 1,
},
)
raise ApiException(
status_code=403,
code="manual_runs_disabled",
message="Manual checks are disabled by server policy.",
details={
"policy": {
"manual_run_allowed": False,
},
"safety_state": safety_state,
},
)
idempotency_key = _normalize_idempotency_key(request.headers.get(IDEMPOTENCY_HEADER)) idempotency_key = _normalize_idempotency_key(request.headers.get(IDEMPOTENCY_HEADER))
if idempotency_key is not None: if idempotency_key is not None:
previous_run = await run_service.get_manual_run_by_idempotency_key( previous_run = await run_service.get_manual_run_by_idempotency_key(
@ -362,6 +430,7 @@ async def run_manual(
previous_run, previous_run,
idempotency_key=idempotency_key, idempotency_key=idempotency_key,
reused_existing_run=True, reused_existing_run=True,
safety_state=safety_state,
), ),
) )
@ -393,6 +462,28 @@ async def run_manual(
code="run_in_progress", code="run_in_progress",
message="A run is already in progress for this account.", message="A run is already in progress for this account.",
) from exc ) from exc
except ingestion_service.RunBlockedBySafetyPolicyError as exc:
await db_session.rollback()
logger.info(
"api.runs.manual_blocked_safety",
extra={
"event": "api.runs.manual_blocked_safety",
"user_id": current_user.id,
"reason": exc.safety_state.get("cooldown_reason"),
"cooldown_until": exc.safety_state.get("cooldown_until"),
"cooldown_remaining_seconds": exc.safety_state.get("cooldown_remaining_seconds"),
"metric_name": "api_runs_manual_blocked_safety_total",
"metric_value": 1,
},
)
raise ApiException(
status_code=429,
code=exc.code,
message=exc.message,
details={
"safety_state": exc.safety_state,
},
) from exc
except IntegrityError as exc: except IntegrityError as exc:
await db_session.rollback() await db_session.rollback()
if idempotency_key is not None: if idempotency_key is not None:
@ -418,6 +509,10 @@ async def run_manual(
existing_run, existing_run,
idempotency_key=idempotency_key, idempotency_key=idempotency_key,
reused_existing_run=True, reused_existing_run=True,
safety_state=await _load_safety_state(
db_session,
user_id=current_user.id,
),
), ),
) )
logger.exception( logger.exception(
@ -447,6 +542,10 @@ async def run_manual(
message="Manual run failed.", message="Manual run failed.",
) from exc ) from exc
current_safety_state = await _load_safety_state(
db_session,
user_id=current_user.id,
)
return success_payload( return success_payload(
request, request,
data={ data={
@ -459,6 +558,7 @@ async def run_manual(
"new_publication_count": run_summary.new_publication_count, "new_publication_count": run_summary.new_publication_count,
"reused_existing_run": False, "reused_existing_run": False,
"idempotency_key": idempotency_key, "idempotency_key": idempotency_key,
"safety_state": current_safety_state,
}, },
) )

View file

@ -11,19 +11,38 @@ from app.api.responses import success_payload
from app.api.schemas import SettingsEnvelope, SettingsUpdateRequest from app.api.schemas import SettingsEnvelope, SettingsUpdateRequest
from app.db.models import User from app.db.models import User
from app.db.session import get_db_session from app.db.session import get_db_session
from app.services import run_safety as run_safety_service
from app.services import user_settings as user_settings_service from app.services import user_settings as user_settings_service
from app.settings import settings as settings_module
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/settings", tags=["api-settings"]) router = APIRouter(prefix="/settings", tags=["api-settings"])
def _serialize_settings(settings) -> dict[str, object]: def _serialize_settings(user_settings) -> dict[str, object]:
min_run_interval_minutes = user_settings_service.resolve_run_interval_minimum(
settings_module.ingestion_min_run_interval_minutes
)
min_request_delay_seconds = user_settings_service.resolve_request_delay_minimum(
settings_module.ingestion_min_request_delay_seconds
)
return { return {
"auto_run_enabled": bool(settings.auto_run_enabled), "auto_run_enabled": bool(user_settings.auto_run_enabled) and settings_module.ingestion_automation_allowed,
"run_interval_minutes": int(settings.run_interval_minutes), "run_interval_minutes": int(user_settings.run_interval_minutes),
"request_delay_seconds": int(settings.request_delay_seconds), "request_delay_seconds": int(user_settings.request_delay_seconds),
"nav_visible_pages": list(settings.nav_visible_pages or []), "nav_visible_pages": list(user_settings.nav_visible_pages or []),
"policy": {
"min_run_interval_minutes": min_run_interval_minutes,
"min_request_delay_seconds": min_request_delay_seconds,
"automation_allowed": bool(settings_module.ingestion_automation_allowed),
"manual_run_allowed": bool(settings_module.ingestion_manual_run_allowed),
"blocked_failure_threshold": max(1, int(settings_module.ingestion_alert_blocked_failure_threshold)),
"network_failure_threshold": max(1, int(settings_module.ingestion_alert_network_failure_threshold)),
"cooldown_blocked_seconds": max(60, int(settings_module.ingestion_safety_cooldown_blocked_seconds)),
"cooldown_network_seconds": max(60, int(settings_module.ingestion_safety_cooldown_network_seconds)),
},
"safety_state": run_safety_service.get_safety_state_payload(user_settings),
} }
@ -36,13 +55,28 @@ async def get_settings(
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_api_current_user), current_user: User = Depends(get_api_current_user),
): ):
settings = await user_settings_service.get_or_create_settings( user_settings = await user_settings_service.get_or_create_settings(
db_session, db_session,
user_id=current_user.id, user_id=current_user.id,
) )
previous_safety_state = run_safety_service.get_safety_event_context(user_settings)
if run_safety_service.clear_expired_cooldown(user_settings):
await db_session.commit()
await db_session.refresh(user_settings)
logger.info(
"api.settings.safety_cooldown_cleared",
extra={
"event": "api.settings.safety_cooldown_cleared",
"user_id": current_user.id,
"reason": previous_safety_state.get("cooldown_reason"),
"cooldown_until": previous_safety_state.get("cooldown_until"),
"metric_name": "api_settings_safety_cooldown_cleared_total",
"metric_value": 1,
},
)
return success_payload( return success_payload(
request, request,
data=_serialize_settings(settings), data=_serialize_settings(user_settings),
) )
@ -56,22 +90,31 @@ async def update_settings(
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_api_current_user), current_user: User = Depends(get_api_current_user),
): ):
settings = await user_settings_service.get_or_create_settings( user_settings = await user_settings_service.get_or_create_settings(
db_session, db_session,
user_id=current_user.id, user_id=current_user.id,
) )
min_run_interval_minutes = user_settings_service.resolve_run_interval_minimum(
settings_module.ingestion_min_run_interval_minutes
)
min_request_delay_seconds = user_settings_service.resolve_request_delay_minimum(
settings_module.ingestion_min_request_delay_seconds
)
try: try:
parsed_interval = user_settings_service.parse_run_interval_minutes( parsed_interval = user_settings_service.parse_run_interval_minutes(
str(payload.run_interval_minutes) str(payload.run_interval_minutes),
minimum=min_run_interval_minutes,
) )
parsed_delay = user_settings_service.parse_request_delay_seconds( parsed_delay = user_settings_service.parse_request_delay_seconds(
str(payload.request_delay_seconds) str(payload.request_delay_seconds),
minimum=min_request_delay_seconds,
) )
parsed_nav_visible_pages = user_settings_service.parse_nav_visible_pages( parsed_nav_visible_pages = user_settings_service.parse_nav_visible_pages(
payload.nav_visible_pages payload.nav_visible_pages
if payload.nav_visible_pages is not None if payload.nav_visible_pages is not None
else list(settings.nav_visible_pages or user_settings_service.DEFAULT_NAV_VISIBLE_PAGES) else list(user_settings.nav_visible_pages or user_settings_service.DEFAULT_NAV_VISIBLE_PAGES)
) )
except user_settings_service.UserSettingsServiceError as exc: except user_settings_service.UserSettingsServiceError as exc:
raise ApiException( raise ApiException(
@ -80,14 +123,33 @@ async def update_settings(
message=str(exc), message=str(exc),
) from exc ) from exc
effective_auto_run_enabled = (
bool(payload.auto_run_enabled) and settings_module.ingestion_automation_allowed
)
updated = await user_settings_service.update_settings( updated = await user_settings_service.update_settings(
db_session, db_session,
settings=settings, settings=user_settings,
auto_run_enabled=bool(payload.auto_run_enabled), auto_run_enabled=effective_auto_run_enabled,
run_interval_minutes=parsed_interval, run_interval_minutes=parsed_interval,
request_delay_seconds=parsed_delay, request_delay_seconds=parsed_delay,
nav_visible_pages=parsed_nav_visible_pages, nav_visible_pages=parsed_nav_visible_pages,
) )
previous_safety_state = run_safety_service.get_safety_event_context(updated)
if run_safety_service.clear_expired_cooldown(updated):
await db_session.commit()
await db_session.refresh(updated)
logger.info(
"api.settings.safety_cooldown_cleared",
extra={
"event": "api.settings.safety_cooldown_cleared",
"user_id": current_user.id,
"reason": previous_safety_state.get("cooldown_reason"),
"cooldown_until": previous_safety_state.get("cooldown_until"),
"metric_name": "api_settings_safety_cooldown_cleared_total",
"metric_value": 1,
},
)
logger.info( logger.info(
"api.settings.updated", "api.settings.updated",
extra={ extra={

View file

@ -202,6 +202,7 @@ class RunListItemData(BaseModel):
class RunsListData(BaseModel): class RunsListData(BaseModel):
runs: list[RunListItemData] runs: list[RunListItemData]
safety_state: ScrapeSafetyStateData
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@ -227,6 +228,30 @@ class RunSummaryData(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
class ScrapeSafetyCountersData(BaseModel):
consecutive_blocked_runs: int = 0
consecutive_network_runs: int = 0
cooldown_entry_count: int = 0
blocked_start_count: int = 0
last_blocked_failure_count: int = 0
last_network_failure_count: int = 0
last_evaluated_run_id: int | None = None
model_config = ConfigDict(extra="forbid")
class ScrapeSafetyStateData(BaseModel):
cooldown_active: bool
cooldown_reason: str | None = None
cooldown_reason_label: str | None = None
cooldown_until: datetime | None = None
cooldown_remaining_seconds: int = 0
recommended_action: str | None = None
counters: ScrapeSafetyCountersData = Field(default_factory=ScrapeSafetyCountersData)
model_config = ConfigDict(extra="forbid")
class RunAttemptLogData(BaseModel): class RunAttemptLogData(BaseModel):
attempt: int attempt: int
cstart: int cstart: int
@ -293,6 +318,7 @@ class RunDetailData(BaseModel):
run: RunListItemData run: RunListItemData
summary: RunSummaryData summary: RunSummaryData
scholar_results: list[RunScholarResultData] scholar_results: list[RunScholarResultData]
safety_state: ScrapeSafetyStateData
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@ -314,6 +340,7 @@ class ManualRunData(BaseModel):
new_publication_count: int new_publication_count: int
reused_existing_run: bool reused_existing_run: bool
idempotency_key: str | None = None idempotency_key: str | None = None
safety_state: ScrapeSafetyStateData
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@ -429,11 +456,26 @@ class AdminResetPasswordRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
class SettingsPolicyData(BaseModel):
min_run_interval_minutes: int
min_request_delay_seconds: int
automation_allowed: bool
manual_run_allowed: bool
blocked_failure_threshold: int
network_failure_threshold: int
cooldown_blocked_seconds: int
cooldown_network_seconds: int
model_config = ConfigDict(extra="forbid")
class SettingsData(BaseModel): class SettingsData(BaseModel):
auto_run_enabled: bool auto_run_enabled: bool
run_interval_minutes: int run_interval_minutes: int
request_delay_seconds: int request_delay_seconds: int
nav_visible_pages: list[str] nav_visible_pages: list[str]
policy: SettingsPolicyData
safety_state: ScrapeSafetyStateData
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")

View file

@ -105,6 +105,13 @@ class UserSetting(Base):
'\'["dashboard","scholars","publications","settings","style-guide","runs","users"]\'::jsonb' '\'["dashboard","scholars","publications","settings","style-guide","runs","users"]\'::jsonb'
), ),
) )
scrape_safety_state: Mapped[dict] = mapped_column(
JSONB,
nullable=False,
server_default=text("'{}'::jsonb"),
)
scrape_cooldown_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
scrape_cooldown_reason: Mapped[str | None] = mapped_column(String(64))
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )

View file

@ -22,6 +22,8 @@ from app.db.models import (
ScholarPublication, ScholarPublication,
) )
from app.services import continuation_queue as queue_service from app.services import continuation_queue as queue_service
from app.services import run_safety as run_safety_service
from app.services import user_settings as user_settings_service
from app.services.scholar_parser import ( from app.services.scholar_parser import (
ParseState, ParseState,
ParsedProfilePage, ParsedProfilePage,
@ -29,6 +31,7 @@ from app.services.scholar_parser import (
parse_profile_page, parse_profile_page,
) )
from app.services.scholar_source import FetchResult, ScholarSource from app.services.scholar_source import FetchResult, ScholarSource
from app.settings import settings
TITLE_ALNUM_RE = re.compile(r"[^a-z0-9]+") TITLE_ALNUM_RE = re.compile(r"[^a-z0-9]+")
WORD_RE = re.compile(r"[a-z0-9]+") WORD_RE = re.compile(r"[a-z0-9]+")
@ -88,6 +91,20 @@ class RunAlreadyInProgressError(RuntimeError):
"""Raised when a run lock for a user is already held by another process.""" """Raised when a run lock for a user is already held by another process."""
class RunBlockedBySafetyPolicyError(RuntimeError):
def __init__(
self,
*,
code: str,
message: str,
safety_state: dict[str, Any],
) -> None:
super().__init__(message)
self.code = code
self.message = message
self.safety_state = safety_state
def _int_or_default(value: Any, default: int = 0) -> int: def _int_or_default(value: Any, default: int = 0) -> int:
try: try:
return int(value) return int(value)
@ -134,6 +151,61 @@ class ScholarIngestionService:
alert_network_failure_threshold: int = 2, alert_network_failure_threshold: int = 2,
alert_retry_scheduled_threshold: int = 3, alert_retry_scheduled_threshold: int = 3,
) -> RunExecutionSummary: ) -> RunExecutionSummary:
user_settings = await user_settings_service.get_or_create_settings(
db_session,
user_id=user_id,
)
now_utc = datetime.now(timezone.utc)
previous_safety_state = run_safety_service.get_safety_event_context(
user_settings,
now_utc=now_utc,
)
if run_safety_service.clear_expired_cooldown(
user_settings,
now_utc=now_utc,
):
await db_session.commit()
await db_session.refresh(user_settings)
logger.info(
"ingestion.safety_cooldown_cleared",
extra={
"event": "ingestion.safety_cooldown_cleared",
"user_id": user_id,
"reason": previous_safety_state.get("cooldown_reason"),
"cooldown_until": previous_safety_state.get("cooldown_until"),
"metric_name": "ingestion_safety_cooldown_cleared_total",
"metric_value": 1,
},
)
now_utc = datetime.now(timezone.utc)
if run_safety_service.is_cooldown_active(user_settings, now_utc=now_utc):
safety_state = run_safety_service.register_cooldown_blocked_start(
user_settings,
now_utc=now_utc,
)
await db_session.commit()
logger.warning(
"ingestion.safety_policy_blocked_run_start",
extra={
"event": "ingestion.safety_policy_blocked_run_start",
"user_id": user_id,
"trigger_type": trigger_type.value,
"reason": safety_state.get("cooldown_reason"),
"cooldown_until": safety_state.get("cooldown_until"),
"cooldown_remaining_seconds": safety_state.get("cooldown_remaining_seconds"),
"blocked_start_count": (
(safety_state.get("counters") or {}).get("blocked_start_count")
),
"metric_name": "ingestion_safety_run_start_blocked_total",
"metric_value": 1,
},
)
raise RunBlockedBySafetyPolicyError(
code="scrape_cooldown_active",
message="Scrape safety cooldown is active; run start is temporarily blocked.",
safety_state=safety_state,
)
lock_acquired = await self._try_acquire_user_lock( lock_acquired = await self._try_acquire_user_lock(
db_session, db_session,
user_id=user_id, user_id=user_id,
@ -505,6 +577,8 @@ class ScholarIngestionService:
"crawl_run_id": run.id, "crawl_run_id": run.id,
"blocked_failure_count": blocked_failure_count, "blocked_failure_count": blocked_failure_count,
"threshold": bounded_blocked_threshold, "threshold": bounded_blocked_threshold,
"metric_name": "ingestion_blocked_failure_threshold_exceeded_total",
"metric_value": 1,
}, },
) )
if alert_flags["network_failure_threshold_exceeded"]: if alert_flags["network_failure_threshold_exceeded"]:
@ -516,6 +590,8 @@ class ScholarIngestionService:
"crawl_run_id": run.id, "crawl_run_id": run.id,
"network_failure_count": network_failure_count, "network_failure_count": network_failure_count,
"threshold": bounded_network_threshold, "threshold": bounded_network_threshold,
"metric_name": "ingestion_network_failure_threshold_exceeded_total",
"metric_value": 1,
}, },
) )
if alert_flags["retry_scheduled_threshold_exceeded"]: if alert_flags["retry_scheduled_threshold_exceeded"]:
@ -527,6 +603,56 @@ class ScholarIngestionService:
"crawl_run_id": run.id, "crawl_run_id": run.id,
"retries_scheduled_count": retries_scheduled_count, "retries_scheduled_count": retries_scheduled_count,
"threshold": bounded_retry_threshold, "threshold": bounded_retry_threshold,
"metric_name": "ingestion_retry_scheduled_threshold_exceeded_total",
"metric_value": 1,
},
)
pre_apply_safety_state = run_safety_service.get_safety_event_context(
user_settings,
now_utc=datetime.now(timezone.utc),
)
safety_state, cooldown_trigger_reason = run_safety_service.apply_run_safety_outcome(
user_settings,
run_id=int(run.id),
blocked_failure_count=blocked_failure_count,
network_failure_count=network_failure_count,
blocked_failure_threshold=bounded_blocked_threshold,
network_failure_threshold=bounded_network_threshold,
blocked_cooldown_seconds=settings.ingestion_safety_cooldown_blocked_seconds,
network_cooldown_seconds=settings.ingestion_safety_cooldown_network_seconds,
now_utc=datetime.now(timezone.utc),
)
if cooldown_trigger_reason is not None:
logger.warning(
"ingestion.safety_cooldown_entered",
extra={
"event": "ingestion.safety_cooldown_entered",
"user_id": user_id,
"crawl_run_id": run.id,
"reason": cooldown_trigger_reason,
"blocked_failure_count": blocked_failure_count,
"network_failure_count": network_failure_count,
"blocked_failure_threshold": bounded_blocked_threshold,
"network_failure_threshold": bounded_network_threshold,
"cooldown_until": safety_state.get("cooldown_until"),
"cooldown_remaining_seconds": safety_state.get("cooldown_remaining_seconds"),
"safety_counters": safety_state.get("counters", {}),
"metric_name": "ingestion_safety_cooldown_entered_total",
"metric_value": 1,
},
)
elif pre_apply_safety_state.get("cooldown_active") and not safety_state.get("cooldown_active"):
logger.info(
"ingestion.safety_cooldown_cleared",
extra={
"event": "ingestion.safety_cooldown_cleared",
"user_id": user_id,
"crawl_run_id": run.id,
"reason": pre_apply_safety_state.get("cooldown_reason"),
"cooldown_until": pre_apply_safety_state.get("cooldown_until"),
"metric_name": "ingestion_safety_cooldown_cleared_total",
"metric_value": 1,
}, },
) )

239
app/services/run_safety.py Normal file
View file

@ -0,0 +1,239 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from app.db.models import UserSetting
COOLDOWN_REASON_BLOCKED_FAILURE_THRESHOLD = "blocked_failure_threshold_exceeded"
COOLDOWN_REASON_NETWORK_FAILURE_THRESHOLD = "network_failure_threshold_exceeded"
_COUNTER_CONSECUTIVE_BLOCKED_RUNS = "consecutive_blocked_runs"
_COUNTER_CONSECUTIVE_NETWORK_RUNS = "consecutive_network_runs"
_COUNTER_COOLDOWN_ENTRY_COUNT = "cooldown_entry_count"
_COUNTER_BLOCKED_START_COUNT = "blocked_start_count"
_COUNTER_LAST_BLOCKED_FAILURE_COUNT = "last_blocked_failure_count"
_COUNTER_LAST_NETWORK_FAILURE_COUNT = "last_network_failure_count"
_COUNTER_LAST_EVALUATED_RUN_ID = "last_evaluated_run_id"
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _safe_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _safe_optional_int(value: Any) -> int | None:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _normalize_datetime(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _state_dict(settings: UserSetting) -> dict[str, Any]:
state = settings.scrape_safety_state
if isinstance(state, dict):
return state
return {}
def _counters_from_state(settings: UserSetting) -> dict[str, Any]:
state = _state_dict(settings)
return {
_COUNTER_CONSECUTIVE_BLOCKED_RUNS: max(
0,
_safe_int(state.get(_COUNTER_CONSECUTIVE_BLOCKED_RUNS), 0),
),
_COUNTER_CONSECUTIVE_NETWORK_RUNS: max(
0,
_safe_int(state.get(_COUNTER_CONSECUTIVE_NETWORK_RUNS), 0),
),
_COUNTER_COOLDOWN_ENTRY_COUNT: max(
0,
_safe_int(state.get(_COUNTER_COOLDOWN_ENTRY_COUNT), 0),
),
_COUNTER_BLOCKED_START_COUNT: max(
0,
_safe_int(state.get(_COUNTER_BLOCKED_START_COUNT), 0),
),
_COUNTER_LAST_BLOCKED_FAILURE_COUNT: max(
0,
_safe_int(state.get(_COUNTER_LAST_BLOCKED_FAILURE_COUNT), 0),
),
_COUNTER_LAST_NETWORK_FAILURE_COUNT: max(
0,
_safe_int(state.get(_COUNTER_LAST_NETWORK_FAILURE_COUNT), 0),
),
_COUNTER_LAST_EVALUATED_RUN_ID: _safe_optional_int(
state.get(_COUNTER_LAST_EVALUATED_RUN_ID),
),
}
def _cooldown_reason_label(reason: str | None) -> str | None:
if reason == COOLDOWN_REASON_BLOCKED_FAILURE_THRESHOLD:
return "Blocked responses exceeded safety threshold"
if reason == COOLDOWN_REASON_NETWORK_FAILURE_THRESHOLD:
return "Network failures exceeded safety threshold"
return None
def _recommended_action(reason: str | None) -> str | None:
if reason == COOLDOWN_REASON_BLOCKED_FAILURE_THRESHOLD:
return (
"Google Scholar appears to be blocking requests. Wait for cooldown to expire, "
"increase request delay, and avoid repeated manual retries."
)
if reason == COOLDOWN_REASON_NETWORK_FAILURE_THRESHOLD:
return (
"Network failures crossed the threshold. Verify connectivity and retry after cooldown."
)
return None
def is_cooldown_active(
settings: UserSetting,
*,
now_utc: datetime | None = None,
) -> bool:
now = now_utc or _utcnow()
cooldown_until = _normalize_datetime(settings.scrape_cooldown_until)
if cooldown_until is None:
return False
return cooldown_until > now
def clear_expired_cooldown(
settings: UserSetting,
*,
now_utc: datetime | None = None,
) -> bool:
now = now_utc or _utcnow()
cooldown_until = _normalize_datetime(settings.scrape_cooldown_until)
if cooldown_until is None:
return False
if cooldown_until > now:
return False
settings.scrape_cooldown_until = None
settings.scrape_cooldown_reason = None
return True
def register_cooldown_blocked_start(
settings: UserSetting,
*,
now_utc: datetime | None = None,
) -> dict[str, Any]:
now = now_utc or _utcnow()
counters = _counters_from_state(settings)
counters[_COUNTER_BLOCKED_START_COUNT] = int(counters[_COUNTER_BLOCKED_START_COUNT]) + 1
settings.scrape_safety_state = counters
return get_safety_state_payload(settings, now_utc=now)
def apply_run_safety_outcome(
settings: UserSetting,
*,
run_id: int,
blocked_failure_count: int,
network_failure_count: int,
blocked_failure_threshold: int,
network_failure_threshold: int,
blocked_cooldown_seconds: int,
network_cooldown_seconds: int,
now_utc: datetime | None = None,
) -> tuple[dict[str, Any], str | None]:
now = now_utc or _utcnow()
counters = _counters_from_state(settings)
bounded_blocked_failures = max(0, int(blocked_failure_count))
bounded_network_failures = max(0, int(network_failure_count))
counters[_COUNTER_LAST_BLOCKED_FAILURE_COUNT] = bounded_blocked_failures
counters[_COUNTER_LAST_NETWORK_FAILURE_COUNT] = bounded_network_failures
counters[_COUNTER_LAST_EVALUATED_RUN_ID] = int(run_id)
counters[_COUNTER_CONSECUTIVE_BLOCKED_RUNS] = (
int(counters[_COUNTER_CONSECUTIVE_BLOCKED_RUNS]) + 1
if bounded_blocked_failures > 0
else 0
)
counters[_COUNTER_CONSECUTIVE_NETWORK_RUNS] = (
int(counters[_COUNTER_CONSECUTIVE_NETWORK_RUNS]) + 1
if bounded_network_failures > 0
else 0
)
reason: str | None = None
cooldown_seconds = 0
if bounded_blocked_failures >= max(1, int(blocked_failure_threshold)):
reason = COOLDOWN_REASON_BLOCKED_FAILURE_THRESHOLD
cooldown_seconds = max(60, int(blocked_cooldown_seconds))
elif bounded_network_failures >= max(1, int(network_failure_threshold)):
reason = COOLDOWN_REASON_NETWORK_FAILURE_THRESHOLD
cooldown_seconds = max(60, int(network_cooldown_seconds))
if reason is not None:
settings.scrape_cooldown_reason = reason
settings.scrape_cooldown_until = now + timedelta(seconds=cooldown_seconds)
counters[_COUNTER_COOLDOWN_ENTRY_COUNT] = int(counters[_COUNTER_COOLDOWN_ENTRY_COUNT]) + 1
else:
clear_expired_cooldown(settings, now_utc=now)
settings.scrape_safety_state = counters
return get_safety_state_payload(settings, now_utc=now), reason
def get_safety_state_payload(
settings: UserSetting,
*,
now_utc: datetime | None = None,
) -> dict[str, Any]:
now = now_utc or _utcnow()
cooldown_until = _normalize_datetime(settings.scrape_cooldown_until)
cooldown_active = bool(cooldown_until is not None and cooldown_until > now)
cooldown_remaining_seconds = 0
if cooldown_active and cooldown_until is not None:
cooldown_remaining_seconds = max(0, int((cooldown_until - now).total_seconds()))
reason = settings.scrape_cooldown_reason if cooldown_active else None
return {
"cooldown_active": cooldown_active,
"cooldown_reason": reason,
"cooldown_reason_label": _cooldown_reason_label(reason),
"cooldown_until": cooldown_until,
"cooldown_remaining_seconds": cooldown_remaining_seconds,
"recommended_action": _recommended_action(reason),
"counters": _counters_from_state(settings),
}
def get_safety_event_context(
settings: UserSetting,
*,
now_utc: datetime | None = None,
) -> dict[str, Any]:
payload = get_safety_state_payload(settings, now_utc=now_utc)
return {
"cooldown_active": bool(payload.get("cooldown_active")),
"cooldown_reason": payload.get("cooldown_reason"),
"cooldown_until": payload.get("cooldown_until"),
"cooldown_remaining_seconds": int(payload.get("cooldown_remaining_seconds") or 0),
"safety_counters": payload.get("counters", {}),
}

View file

@ -18,7 +18,11 @@ from app.db.models import (
) )
from app.db.session import get_session_factory from app.db.session import get_session_factory
from app.services import continuation_queue as queue_service from app.services import continuation_queue as queue_service
from app.services.ingestion import RunAlreadyInProgressError, ScholarIngestionService from app.services.ingestion import (
RunAlreadyInProgressError,
RunBlockedBySafetyPolicyError,
ScholarIngestionService,
)
from app.services.scholar_source import LiveScholarSource from app.services.scholar_source import LiveScholarSource
from app.settings import settings from app.settings import settings
@ -30,6 +34,8 @@ class _AutoRunCandidate:
user_id: int user_id: int
run_interval_minutes: int run_interval_minutes: int
request_delay_seconds: int request_delay_seconds: int
cooldown_until: datetime | None
cooldown_reason: str | None
class SchedulerService: class SchedulerService:
@ -134,6 +140,9 @@ class SchedulerService:
await self._run_candidate(candidate) await self._run_candidate(candidate)
async def _load_candidates(self) -> list[_AutoRunCandidate]: async def _load_candidates(self) -> list[_AutoRunCandidate]:
if not settings.ingestion_automation_allowed:
return []
session_factory = get_session_factory() session_factory = get_session_factory()
async with session_factory() as session: async with session_factory() as session:
result = await session.execute( result = await session.execute(
@ -141,6 +150,8 @@ class SchedulerService:
UserSetting.user_id, UserSetting.user_id,
UserSetting.run_interval_minutes, UserSetting.run_interval_minutes,
UserSetting.request_delay_seconds, UserSetting.request_delay_seconds,
UserSetting.scrape_cooldown_until,
UserSetting.scrape_cooldown_reason,
) )
.join(User, User.id == UserSetting.user_id) .join(User, User.id == UserSetting.user_id)
.where( .where(
@ -150,14 +161,35 @@ class SchedulerService:
.order_by(UserSetting.user_id.asc()) .order_by(UserSetting.user_id.asc())
) )
rows = result.all() rows = result.all()
return [ now_utc = datetime.now(timezone.utc)
candidates: list[_AutoRunCandidate] = []
for user_id, run_interval_minutes, request_delay_seconds, cooldown_until, cooldown_reason in rows:
if cooldown_until is not None and cooldown_until.tzinfo is None:
cooldown_until = cooldown_until.replace(tzinfo=timezone.utc)
if cooldown_until is not None and cooldown_until > now_utc:
logger.info(
"scheduler.run_skipped_safety_cooldown_precheck",
extra={
"event": "scheduler.run_skipped_safety_cooldown_precheck",
"user_id": int(user_id),
"reason": cooldown_reason,
"cooldown_until": cooldown_until,
"cooldown_remaining_seconds": int((cooldown_until - now_utc).total_seconds()),
"metric_name": "scheduler_run_skipped_safety_cooldown_total",
"metric_value": 1,
},
)
continue
candidates.append(
_AutoRunCandidate( _AutoRunCandidate(
user_id=int(user_id), user_id=int(user_id),
run_interval_minutes=int(run_interval_minutes), run_interval_minutes=int(run_interval_minutes),
request_delay_seconds=int(request_delay_seconds), request_delay_seconds=int(request_delay_seconds),
cooldown_until=cooldown_until,
cooldown_reason=(str(cooldown_reason).strip() if cooldown_reason else None),
) )
for user_id, run_interval_minutes, request_delay_seconds in rows )
] return candidates
async def _is_due(self, candidate: _AutoRunCandidate, *, now: datetime) -> bool: async def _is_due(self, candidate: _AutoRunCandidate, *, now: datetime) -> bool:
session_factory = get_session_factory() session_factory = get_session_factory()
@ -210,6 +242,21 @@ class SchedulerService:
}, },
) )
return return
except RunBlockedBySafetyPolicyError as exc:
await session.rollback()
logger.info(
"scheduler.run_skipped_safety_cooldown",
extra={
"event": "scheduler.run_skipped_safety_cooldown",
"user_id": candidate.user_id,
"reason": exc.safety_state.get("cooldown_reason"),
"cooldown_until": exc.safety_state.get("cooldown_until"),
"cooldown_remaining_seconds": exc.safety_state.get("cooldown_remaining_seconds"),
"metric_name": "scheduler_run_skipped_safety_cooldown_total",
"metric_value": 1,
},
)
return
except Exception: except Exception:
await session.rollback() await session.rollback()
logger.exception( logger.exception(
@ -354,6 +401,34 @@ class SchedulerService:
}, },
) )
return return
except RunBlockedBySafetyPolicyError as exc:
await session.rollback()
cooldown_remaining_seconds = max(
self._tick_seconds,
int(exc.safety_state.get("cooldown_remaining_seconds") or 0),
)
async with session_factory() as recovery_session:
await queue_service.reschedule_job(
recovery_session,
job_id=job.id,
delay_seconds=max(self._tick_seconds, cooldown_remaining_seconds),
reason="scrape_safety_cooldown",
error=str(exc.message),
)
await recovery_session.commit()
logger.info(
"scheduler.queue_item_deferred_safety_cooldown",
extra={
"event": "scheduler.queue_item_deferred_safety_cooldown",
"queue_item_id": job.id,
"user_id": job.user_id,
"reason": exc.safety_state.get("cooldown_reason"),
"cooldown_remaining_seconds": cooldown_remaining_seconds,
"metric_name": "scheduler_queue_item_deferred_safety_cooldown_total",
"metric_value": 1,
},
)
return
except Exception as exc: except Exception as exc:
await session.rollback() await session.rollback()
async with session_factory() as recovery_session: async with session_factory() as recovery_session:

View file

@ -33,25 +33,49 @@ REQUIRED_NAV_PAGES = (
NAV_PAGE_SETTINGS, NAV_PAGE_SETTINGS,
) )
DEFAULT_NAV_VISIBLE_PAGES = list(ALLOWED_NAV_PAGES) DEFAULT_NAV_VISIBLE_PAGES = list(ALLOWED_NAV_PAGES)
HARD_MIN_RUN_INTERVAL_MINUTES = 15
HARD_MIN_REQUEST_DELAY_SECONDS = 2
def parse_run_interval_minutes(value: str) -> int: def resolve_run_interval_minimum(configured_minimum: int | None) -> int:
try:
parsed = int(configured_minimum) if configured_minimum is not None else HARD_MIN_RUN_INTERVAL_MINUTES
except (TypeError, ValueError):
parsed = HARD_MIN_RUN_INTERVAL_MINUTES
return max(HARD_MIN_RUN_INTERVAL_MINUTES, parsed)
def resolve_request_delay_minimum(configured_minimum: int | None) -> int:
try:
parsed = int(configured_minimum) if configured_minimum is not None else HARD_MIN_REQUEST_DELAY_SECONDS
except (TypeError, ValueError):
parsed = HARD_MIN_REQUEST_DELAY_SECONDS
return max(HARD_MIN_REQUEST_DELAY_SECONDS, parsed)
def parse_run_interval_minutes(value: str, *, minimum: int = HARD_MIN_RUN_INTERVAL_MINUTES) -> int:
try: try:
parsed = int(value) parsed = int(value)
except ValueError as exc: except ValueError as exc:
raise UserSettingsServiceError("Check interval must be a whole number.") from exc raise UserSettingsServiceError("Check interval must be a whole number.") from exc
if parsed < 15: effective_minimum = resolve_run_interval_minimum(minimum)
raise UserSettingsServiceError("Check interval must be at least 15 minutes.") if parsed < effective_minimum:
raise UserSettingsServiceError(
f"Check interval must be at least {effective_minimum} minutes."
)
return parsed return parsed
def parse_request_delay_seconds(value: str) -> int: def parse_request_delay_seconds(value: str, *, minimum: int = HARD_MIN_REQUEST_DELAY_SECONDS) -> int:
try: try:
parsed = int(value) parsed = int(value)
except ValueError as exc: except ValueError as exc:
raise UserSettingsServiceError("Request delay must be a whole number.") from exc raise UserSettingsServiceError("Request delay must be a whole number.") from exc
if parsed < 2: effective_minimum = resolve_request_delay_minimum(minimum)
raise UserSettingsServiceError("Request delay must be at least 2 seconds.") if parsed < effective_minimum:
raise UserSettingsServiceError(
f"Request delay must be at least {effective_minimum} seconds."
)
return parsed return parsed

View file

@ -115,6 +115,22 @@ class Settings:
log_redact_fields: str = os.getenv("LOG_REDACT_FIELDS", "") log_redact_fields: str = os.getenv("LOG_REDACT_FIELDS", "")
scheduler_enabled: bool = _env_bool("SCHEDULER_ENABLED", True) scheduler_enabled: bool = _env_bool("SCHEDULER_ENABLED", True)
scheduler_tick_seconds: int = _env_int("SCHEDULER_TICK_SECONDS", 60) scheduler_tick_seconds: int = _env_int("SCHEDULER_TICK_SECONDS", 60)
ingestion_automation_allowed: bool = _env_bool(
"INGESTION_AUTOMATION_ALLOWED",
True,
)
ingestion_manual_run_allowed: bool = _env_bool(
"INGESTION_MANUAL_RUN_ALLOWED",
True,
)
ingestion_min_run_interval_minutes: int = _env_int(
"INGESTION_MIN_RUN_INTERVAL_MINUTES",
15,
)
ingestion_min_request_delay_seconds: int = _env_int(
"INGESTION_MIN_REQUEST_DELAY_SECONDS",
2,
)
ingestion_network_error_retries: int = _env_int("INGESTION_NETWORK_ERROR_RETRIES", 1) ingestion_network_error_retries: int = _env_int("INGESTION_NETWORK_ERROR_RETRIES", 1)
ingestion_retry_backoff_seconds: float = _env_float( ingestion_retry_backoff_seconds: float = _env_float(
"INGESTION_RETRY_BACKOFF_SECONDS", "INGESTION_RETRY_BACKOFF_SECONDS",
@ -137,6 +153,14 @@ class Settings:
"INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD", "INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD",
3, 3,
) )
ingestion_safety_cooldown_blocked_seconds: int = _env_int(
"INGESTION_SAFETY_COOLDOWN_BLOCKED_SECONDS",
1800,
)
ingestion_safety_cooldown_network_seconds: int = _env_int(
"INGESTION_SAFETY_COOLDOWN_NETWORK_SECONDS",
900,
)
ingestion_continuation_queue_enabled: bool = _env_bool( ingestion_continuation_queue_enabled: bool = _env_bool(
"INGESTION_CONTINUATION_QUEUE_ENABLED", "INGESTION_CONTINUATION_QUEUE_ENABLED",
True, True,

View file

@ -40,6 +40,10 @@ services:
LOG_REDACT_FIELDS: ${LOG_REDACT_FIELDS:-} LOG_REDACT_FIELDS: ${LOG_REDACT_FIELDS:-}
SCHEDULER_ENABLED: ${SCHEDULER_ENABLED:-1} SCHEDULER_ENABLED: ${SCHEDULER_ENABLED:-1}
SCHEDULER_TICK_SECONDS: ${SCHEDULER_TICK_SECONDS:-60} SCHEDULER_TICK_SECONDS: ${SCHEDULER_TICK_SECONDS:-60}
INGESTION_AUTOMATION_ALLOWED: ${INGESTION_AUTOMATION_ALLOWED:-1}
INGESTION_MANUAL_RUN_ALLOWED: ${INGESTION_MANUAL_RUN_ALLOWED:-1}
INGESTION_MIN_RUN_INTERVAL_MINUTES: ${INGESTION_MIN_RUN_INTERVAL_MINUTES:-15}
INGESTION_MIN_REQUEST_DELAY_SECONDS: ${INGESTION_MIN_REQUEST_DELAY_SECONDS:-2}
INGESTION_NETWORK_ERROR_RETRIES: ${INGESTION_NETWORK_ERROR_RETRIES:-1} INGESTION_NETWORK_ERROR_RETRIES: ${INGESTION_NETWORK_ERROR_RETRIES:-1}
INGESTION_RETRY_BACKOFF_SECONDS: ${INGESTION_RETRY_BACKOFF_SECONDS:-1.0} INGESTION_RETRY_BACKOFF_SECONDS: ${INGESTION_RETRY_BACKOFF_SECONDS:-1.0}
INGESTION_MAX_PAGES_PER_SCHOLAR: ${INGESTION_MAX_PAGES_PER_SCHOLAR:-30} INGESTION_MAX_PAGES_PER_SCHOLAR: ${INGESTION_MAX_PAGES_PER_SCHOLAR:-30}
@ -47,6 +51,8 @@ services:
INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD: ${INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD:-1} INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD: ${INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD:-1}
INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD: ${INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD:-2} INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD: ${INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD:-2}
INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD: ${INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD:-3} INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD: ${INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD:-3}
INGESTION_SAFETY_COOLDOWN_BLOCKED_SECONDS: ${INGESTION_SAFETY_COOLDOWN_BLOCKED_SECONDS:-1800}
INGESTION_SAFETY_COOLDOWN_NETWORK_SECONDS: ${INGESTION_SAFETY_COOLDOWN_NETWORK_SECONDS:-900}
INGESTION_CONTINUATION_QUEUE_ENABLED: ${INGESTION_CONTINUATION_QUEUE_ENABLED:-1} INGESTION_CONTINUATION_QUEUE_ENABLED: ${INGESTION_CONTINUATION_QUEUE_ENABLED:-1}
INGESTION_CONTINUATION_BASE_DELAY_SECONDS: ${INGESTION_CONTINUATION_BASE_DELAY_SECONDS:-120} INGESTION_CONTINUATION_BASE_DELAY_SECONDS: ${INGESTION_CONTINUATION_BASE_DELAY_SECONDS:-120}
INGESTION_CONTINUATION_MAX_DELAY_SECONDS: ${INGESTION_CONTINUATION_MAX_DELAY_SECONDS:-3600} INGESTION_CONTINUATION_MAX_DELAY_SECONDS: ${INGESTION_CONTINUATION_MAX_DELAY_SECONDS:-3600}

View file

@ -0,0 +1,74 @@
# Scrape Safety Runbook
This runbook covers operational handling for scrape safety cooldowns, threshold alerts, and blocked-IP events.
## Key Signals
Use structured log `event` fields (recommended with `LOG_FORMAT=json`):
- `ingestion.safety_policy_blocked_run_start`
- `ingestion.safety_cooldown_entered`
- `ingestion.safety_cooldown_cleared`
- `ingestion.alert_blocked_failure_threshold_exceeded`
- `ingestion.alert_network_failure_threshold_exceeded`
- `ingestion.alert_retry_scheduled_threshold_exceeded`
- `api.runs.manual_blocked_policy`
- `api.runs.manual_blocked_safety`
- `scheduler.run_skipped_safety_cooldown`
- `scheduler.run_skipped_safety_cooldown_precheck`
- `scheduler.queue_item_deferred_safety_cooldown`
Each event includes metric-style fields (`metric_name`, `metric_value`) for straightforward log-based alert rules.
## Recommended Alert Rules
- Cooldown enters:
- Trigger on `event=ingestion.safety_cooldown_entered`.
- Repeated start blocks:
- Trigger on high rate of `event=api.runs.manual_blocked_safety`.
- Threshold trips:
- Trigger on any of:
- `ingestion.alert_blocked_failure_threshold_exceeded`
- `ingestion.alert_network_failure_threshold_exceeded`
- `ingestion.alert_retry_scheduled_threshold_exceeded`
- Scheduler pressure:
- Trigger on sustained `scheduler.queue_item_deferred_safety_cooldown`.
## If Your IP Appears Blocked
Symptoms:
- cooldown reason `blocked_failure_threshold_exceeded`
- parse state `blocked_or_captcha`
- redirects toward Google account sign-in flows
Actions:
1. Stop manual retries immediately.
2. Let cooldown expire; do not spam retriggers.
3. Increase `INGESTION_MIN_REQUEST_DELAY_SECONDS` and user request delay values.
4. Reduce concurrency pressure (keep one scheduler instance).
5. Keep name-search disabled/WIP for now if login-gated responses persist.
6. Resume with a small monitored run and verify blocked rate drops.
Avoid:
- aggressive rapid retries
- rotating through risky scraping patterns that increase challenge rates
- bypass/captcha-solving workflows that may violate source platform rules
## Environment Controls
Policy floors and safety controls:
- `INGESTION_MIN_REQUEST_DELAY_SECONDS`
- `INGESTION_MIN_RUN_INTERVAL_MINUTES`
- `INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD`
- `INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD`
- `INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD`
- `INGESTION_SAFETY_COOLDOWN_BLOCKED_SECONDS`
- `INGESTION_SAFETY_COOLDOWN_NETWORK_SECONDS`
- `INGESTION_MANUAL_RUN_ALLOWED`
- `INGESTION_AUTOMATION_ALLOWED`
Apply stricter values first, then relax slowly only after sustained healthy runs.

View file

@ -3,7 +3,9 @@ import { computed } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue"; import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
import ScrapeSafetyBadge from "@/components/patterns/ScrapeSafetyBadge.vue";
import AppButton from "@/components/ui/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import { formatCooldownCountdown } from "@/features/safety";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useRunStatusStore } from "@/stores/run_status"; import { useRunStatusStore } from "@/stores/run_status";
import { useUserSettingsStore } from "@/stores/user_settings"; import { useUserSettingsStore } from "@/stores/user_settings";
@ -31,21 +33,34 @@ const links = computed(() => {
return userSettings.isPageVisible(item.id); return userSettings.isPageVisible(item.id);
}); });
}); });
const navRunStatus = computed(() => { const navSafetyText = computed(() => {
if (!userSettings.manualRunAllowed) {
return "Manual checks disabled";
}
if (runStatus.safetyState.cooldown_active) {
return `Cooldown: ${formatCooldownCountdown(runStatus.safetyState.cooldown_remaining_seconds)}`;
}
return "Safety ready";
});
const navRunStateStatus = computed(() => {
if (runStatus.isSubmitting && !runStatus.isLikelyRunning) {
return "starting";
}
if (runStatus.isLikelyRunning) { if (runStatus.isLikelyRunning) {
return "running"; return "running";
} }
return "idle"; return "idle";
}); });
const navRunText = computed(() => { const navRunStateLabel = computed(() =>
const label = navRunStatus.value navRunStateStatus.value
.replaceAll("_", " ") .split("_")
.split(" ")
.filter((segment) => segment.length > 0) .filter((segment) => segment.length > 0)
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join(" "); .join(" "),
return `State: ${label}`; );
}); const showSafetyRow = computed(
() => runStatus.safetyState.cooldown_active || !userSettings.manualRunAllowed,
);
async function onLogout(): Promise<void> { async function onLogout(): Promise<void> {
await auth.logout(); await auth.logout();
@ -71,12 +86,18 @@ async function onLogout(): Promise<void> {
</nav> </nav>
<div class="mt-auto grid gap-2 border-t border-stroke-subtle pt-3"> <div class="mt-auto grid gap-2 border-t border-stroke-subtle pt-3">
<div class="grid gap-1 rounded-lg border border-stroke-default bg-surface-card-muted px-2.5 py-2 text-xs text-secondary">
<div class="flex items-center justify-between gap-2">
<span class="truncate">State: {{ navRunStateLabel }}</span>
<RunStatusBadge :status="navRunStateStatus" />
</div>
<div <div
v-if="auth.isAdmin" v-if="showSafetyRow"
class="flex items-center justify-between gap-2 rounded-lg border border-stroke-default bg-surface-card-muted px-2.5 py-2 text-xs text-secondary" class="flex items-center justify-between gap-2 border-t border-stroke-subtle pt-1"
> >
<span class="truncate">{{ navRunText }}</span> <span class="truncate">{{ navSafetyText }}</span>
<RunStatusBadge :status="navRunStatus" /> <ScrapeSafetyBadge :state="runStatus.safetyState" />
</div>
</div> </div>
<AppButton variant="ghost" class="w-full justify-start" @click="onLogout">Logout</AppButton> <AppButton variant="ghost" class="w-full justify-start" @click="onLogout">Logout</AppButton>
</div> </div>

View file

@ -20,6 +20,9 @@ const toneClass = computed(() => {
if (props.status === "running") { if (props.status === "running") {
return "border-state-info-border bg-state-info-bg text-state-info-text"; return "border-state-info-border bg-state-info-bg text-state-info-text";
} }
if (props.status === "starting") {
return "border-state-info-border bg-state-info-bg text-state-info-text";
}
return "border-stroke-default bg-surface-card-muted text-ink-secondary"; return "border-stroke-default bg-surface-card-muted text-ink-secondary";
}); });
</script> </script>

View file

@ -0,0 +1,27 @@
<script setup lang="ts">
import { computed } from "vue";
import { type ScrapeSafetyState } from "@/features/safety";
const props = defineProps<{
state: ScrapeSafetyState;
}>();
const label = computed(() => (props.state.cooldown_active ? "Safety cooldown" : "Safety ready"));
const toneClass = computed(() => {
if (!props.state.cooldown_active) {
return "border-state-success-border bg-state-success-bg text-state-success-text";
}
if (props.state.cooldown_reason === "blocked_failure_threshold_exceeded") {
return "border-state-danger-border bg-state-danger-bg text-state-danger-text";
}
return "border-state-warning-border bg-state-warning-bg text-state-warning-text";
});
</script>
<template>
<span class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold" :class="toneClass">
{{ label }}
</span>
</template>

View file

@ -0,0 +1,102 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import AppAlert from "@/components/ui/AppAlert.vue";
import {
formatCooldownCountdown,
type ScrapeSafetyState,
} from "@/features/safety";
const props = withDefaults(
defineProps<{
safetyState: ScrapeSafetyState;
manualRunAllowed?: boolean;
}>(),
{
manualRunAllowed: true,
},
);
const now = ref(Date.now());
let timer: ReturnType<typeof setInterval> | null = null;
const cooldownUntilMs = computed(() => {
if (!props.safetyState.cooldown_until) {
return null;
}
const parsed = new Date(props.safetyState.cooldown_until).getTime();
if (Number.isNaN(parsed)) {
return null;
}
return parsed;
});
const cooldownRemainingSeconds = computed(() => {
if (!props.safetyState.cooldown_active) {
return 0;
}
if (cooldownUntilMs.value !== null) {
return Math.max(0, Math.floor((cooldownUntilMs.value - now.value) / 1000));
}
return Math.max(0, props.safetyState.cooldown_remaining_seconds || 0);
});
const isCooldownBlocked = computed(() => props.safetyState.cooldown_active && cooldownRemainingSeconds.value > 0);
const isPolicyBlocked = computed(() => !props.manualRunAllowed);
const isVisible = computed(() => isPolicyBlocked.value || isCooldownBlocked.value);
const tone = computed(() => {
if (isPolicyBlocked.value) {
return "warning" as const;
}
if (props.safetyState.cooldown_reason === "blocked_failure_threshold_exceeded") {
return "danger" as const;
}
return "warning" as const;
});
const title = computed(() => {
if (isPolicyBlocked.value) {
return "Manual checks disabled by server policy";
}
return props.safetyState.cooldown_reason_label || "Safety cooldown active";
});
const detailText = computed(() => {
if (isPolicyBlocked.value) {
return "This server currently disallows manual checks. Automatic checks may still run if enabled by policy.";
}
const countdown = formatCooldownCountdown(cooldownRemainingSeconds.value);
return `Manual and scheduled checks are paused for ${countdown}.`;
});
const actionText = computed(() => {
if (isPolicyBlocked.value) {
return "Ask an admin to enable manual checks in server environment policy.";
}
return props.safetyState.recommended_action;
});
onMounted(() => {
timer = setInterval(() => {
now.value = Date.now();
}, 1000);
});
onBeforeUnmount(() => {
if (timer !== null) {
clearInterval(timer);
timer = null;
}
});
</script>
<template>
<AppAlert v-if="isVisible" :tone="tone">
<template #title>{{ title }}</template>
<p>{{ detailText }}</p>
<p v-if="actionText" class="text-secondary">{{ actionText }}</p>
</AppAlert>
</template>

View file

@ -4,6 +4,7 @@ import {
type PublicationMode, type PublicationMode,
} from "@/features/publications"; } from "@/features/publications";
import { listQueueItems, listRuns, type RunListItem } from "@/features/runs"; import { listQueueItems, listRuns, type RunListItem } from "@/features/runs";
import { type ScrapeSafetyState } from "@/features/safety";
export interface QueueHealth { export interface QueueHealth {
queued: number; queued: number;
@ -19,6 +20,7 @@ export interface DashboardSnapshot {
recentRuns: RunListItem[]; recentRuns: RunListItem[];
recentPublications: PublicationItem[]; recentPublications: PublicationItem[];
queue: QueueHealth; queue: QueueHealth;
safetyState: ScrapeSafetyState;
} }
function countQueueStatuses(statuses: string[]): QueueHealth { function countQueueStatuses(statuses: string[]): QueueHealth {
@ -38,11 +40,12 @@ function countQueueStatuses(statuses: string[]): QueueHealth {
} }
export async function fetchDashboardSnapshot(): Promise<DashboardSnapshot> { export async function fetchDashboardSnapshot(): Promise<DashboardSnapshot> {
const [publications, runs, queueItems] = await Promise.all([ const [publications, runsPayload, queueItems] = await Promise.all([
listPublications({ mode: "new", limit: 20 }), listPublications({ mode: "new", limit: 20 }),
listRuns({ limit: 20 }), listRuns({ limit: 20 }),
listQueueItems(200), listQueueItems(200),
]); ]);
const runs = runsPayload.runs;
const queueHealth = countQueueStatuses(queueItems.map((item) => item.status)); const queueHealth = countQueueStatuses(queueItems.map((item) => item.status));
@ -54,5 +57,6 @@ export async function fetchDashboardSnapshot(): Promise<DashboardSnapshot> {
recentRuns: runs, recentRuns: runs,
recentPublications: publications.publications, recentPublications: publications.publications,
queue: queueHealth, queue: queueHealth,
safetyState: runsPayload.safety_state,
}; };
} }

View file

@ -1,4 +1,5 @@
import { apiRequest } from "@/lib/api/client"; import { apiRequest } from "@/lib/api/client";
import { type ScrapeSafetyState } from "@/features/safety";
export interface RunListItem { export interface RunListItem {
id: number; id: number;
@ -49,6 +50,7 @@ export interface RunDetail {
run: RunListItem; run: RunListItem;
summary: RunSummary; summary: RunSummary;
scholar_results: RunScholarResult[]; scholar_results: RunScholarResult[];
safety_state: ScrapeSafetyState;
} }
export interface QueueItem { export interface QueueItem {
@ -68,6 +70,7 @@ export interface QueueItem {
interface RunsListData { interface RunsListData {
runs: RunListItem[]; runs: RunListItem[];
safety_state: ScrapeSafetyState;
} }
interface QueueListData { interface QueueListData {
@ -79,7 +82,7 @@ export interface RunsListQuery {
limit?: number; limit?: number;
} }
export async function listRuns(query: RunsListQuery = {}): Promise<RunListItem[]> { export async function listRuns(query: RunsListQuery = {}): Promise<RunsListData> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (query.failedOnly) { if (query.failedOnly) {
params.set("failed_only", "true"); params.set("failed_only", "true");
@ -92,7 +95,7 @@ export async function listRuns(query: RunsListQuery = {}): Promise<RunListItem[]
const response = await apiRequest<RunsListData>(`/runs${suffix ? `?${suffix}` : ""}`, { const response = await apiRequest<RunsListData>(`/runs${suffix ? `?${suffix}` : ""}`, {
method: "GET", method: "GET",
}); });
return response.data.runs; return response.data;
} }
export async function getRunDetail(runId: number): Promise<RunDetail> { export async function getRunDetail(runId: number): Promise<RunDetail> {
@ -118,6 +121,7 @@ export async function triggerManualRun(): Promise<{
new_publication_count: number; new_publication_count: number;
reused_existing_run: boolean; reused_existing_run: boolean;
idempotency_key: string | null; idempotency_key: string | null;
safety_state: ScrapeSafetyState;
}> { }> {
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Idempotency-Key": generateIdempotencyKey(), "Idempotency-Key": generateIdempotencyKey(),
@ -133,6 +137,7 @@ export async function triggerManualRun(): Promise<{
new_publication_count: number; new_publication_count: number;
reused_existing_run: boolean; reused_existing_run: boolean;
idempotency_key: string | null; idempotency_key: string | null;
safety_state: ScrapeSafetyState;
}>("/runs/manual", { }>("/runs/manual", {
method: "POST", method: "POST",
headers, headers,

View file

@ -0,0 +1,112 @@
export interface ScrapeSafetyCounters {
consecutive_blocked_runs: number;
consecutive_network_runs: number;
cooldown_entry_count: number;
blocked_start_count: number;
last_blocked_failure_count: number;
last_network_failure_count: number;
last_evaluated_run_id: number | null;
}
export interface ScrapeSafetyState {
cooldown_active: boolean;
cooldown_reason: string | null;
cooldown_reason_label: string | null;
cooldown_until: string | null;
cooldown_remaining_seconds: number;
recommended_action: string | null;
counters: ScrapeSafetyCounters;
}
export function createDefaultSafetyCounters(): ScrapeSafetyCounters {
return {
consecutive_blocked_runs: 0,
consecutive_network_runs: 0,
cooldown_entry_count: 0,
blocked_start_count: 0,
last_blocked_failure_count: 0,
last_network_failure_count: 0,
last_evaluated_run_id: null,
};
}
export function createDefaultSafetyState(): ScrapeSafetyState {
return {
cooldown_active: false,
cooldown_reason: null,
cooldown_reason_label: null,
cooldown_until: null,
cooldown_remaining_seconds: 0,
recommended_action: null,
counters: createDefaultSafetyCounters(),
};
}
function parseNumber(value: unknown, fallback: number): number {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
}
function parseNullableString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
export function normalizeSafetyState(value: unknown): ScrapeSafetyState {
if (!value || typeof value !== "object") {
return createDefaultSafetyState();
}
const raw = value as Record<string, unknown>;
const rawCounters = raw.counters;
const counters = rawCounters && typeof rawCounters === "object"
? (rawCounters as Record<string, unknown>)
: {};
return {
cooldown_active: Boolean(raw.cooldown_active),
cooldown_reason: parseNullableString(raw.cooldown_reason),
cooldown_reason_label: parseNullableString(raw.cooldown_reason_label),
cooldown_until: parseNullableString(raw.cooldown_until),
cooldown_remaining_seconds: Math.max(0, parseNumber(raw.cooldown_remaining_seconds, 0)),
recommended_action: parseNullableString(raw.recommended_action),
counters: {
consecutive_blocked_runs: Math.max(0, parseNumber(counters.consecutive_blocked_runs, 0)),
consecutive_network_runs: Math.max(0, parseNumber(counters.consecutive_network_runs, 0)),
cooldown_entry_count: Math.max(0, parseNumber(counters.cooldown_entry_count, 0)),
blocked_start_count: Math.max(0, parseNumber(counters.blocked_start_count, 0)),
last_blocked_failure_count: Math.max(0, parseNumber(counters.last_blocked_failure_count, 0)),
last_network_failure_count: Math.max(0, parseNumber(counters.last_network_failure_count, 0)),
last_evaluated_run_id:
counters.last_evaluated_run_id === null
? null
: Math.max(0, parseNumber(counters.last_evaluated_run_id, 0)),
},
};
}
export function formatCooldownCountdown(seconds: number): string {
const bounded = Number.isFinite(seconds) ? Math.max(0, Math.floor(seconds)) : 0;
if (bounded <= 0) {
return "0s";
}
const hours = Math.floor(bounded / 3600);
const minutes = Math.floor((bounded % 3600) / 60);
const secs = bounded % 60;
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
if (minutes > 0) {
return `${minutes}m ${secs}s`;
}
return `${secs}s`;
}

View file

@ -1,10 +1,31 @@
import { apiRequest } from "@/lib/api/client"; import { apiRequest } from "@/lib/api/client";
import { type ScrapeSafetyState } from "@/features/safety";
export interface UserSettingsPolicy {
min_run_interval_minutes: number;
min_request_delay_seconds: number;
automation_allowed: boolean;
manual_run_allowed: boolean;
blocked_failure_threshold: number;
network_failure_threshold: number;
cooldown_blocked_seconds: number;
cooldown_network_seconds: number;
}
export interface UserSettings { export interface UserSettings {
auto_run_enabled: boolean; auto_run_enabled: boolean;
run_interval_minutes: number; run_interval_minutes: number;
request_delay_seconds: number; request_delay_seconds: number;
nav_visible_pages: string[]; nav_visible_pages: string[];
policy: UserSettingsPolicy;
safety_state: ScrapeSafetyState;
}
export interface UserSettingsUpdate {
auto_run_enabled: boolean;
run_interval_minutes: number;
request_delay_seconds: number;
nav_visible_pages: string[];
} }
export interface ChangePasswordPayload { export interface ChangePasswordPayload {
@ -18,7 +39,7 @@ export async function fetchSettings(): Promise<UserSettings> {
return response.data; return response.data;
} }
export async function updateSettings(payload: UserSettings): Promise<UserSettings> { export async function updateSettings(payload: UserSettingsUpdate): Promise<UserSettings> {
const response = await apiRequest<UserSettings>("/settings", { const response = await apiRequest<UserSettings>("/settings", {
method: "PUT", method: "PUT",
body: payload, body: payload,

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { fetchDashboardSnapshot, type DashboardSnapshot } from "@/features/dashboard"; import { fetchDashboardSnapshot, type DashboardSnapshot } from "@/features/dashboard";
import { ApiRequestError } from "@/lib/api/errors"; import { ApiRequestError } from "@/lib/api/errors";
@ -8,22 +8,51 @@ import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
import QueueHealthBadge from "@/components/patterns/QueueHealthBadge.vue"; import QueueHealthBadge from "@/components/patterns/QueueHealthBadge.vue";
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue"; import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue"; import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
import ScrapeSafetyBanner from "@/components/patterns/ScrapeSafetyBanner.vue";
import AppButton from "@/components/ui/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue"; import AppCard from "@/components/ui/AppCard.vue";
import AppEmptyState from "@/components/ui/AppEmptyState.vue"; import AppEmptyState from "@/components/ui/AppEmptyState.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue"; import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useRunStatusStore } from "@/stores/run_status"; import { useRunStatusStore } from "@/stores/run_status";
import { useUserSettingsStore } from "@/stores/user_settings";
const loading = ref(true); const loading = ref(true);
const errorMessage = ref<string | null>(null); const errorMessage = ref<string | null>(null);
const errorRequestId = ref<string | null>(null); const errorRequestId = ref<string | null>(null);
const successMessage = ref<string | null>(null); const successMessage = ref<string | null>(null);
const snapshot = ref<DashboardSnapshot | null>(null); const snapshot = ref<DashboardSnapshot | null>(null);
const refreshingAfterCompletion = ref(false);
const auth = useAuthStore(); const auth = useAuthStore();
const runStatus = useRunStatusStore(); const runStatus = useRunStatusStore();
const userSettings = useUserSettingsStore();
const isStartBlocked = computed(
() =>
runStatus.isRunActive ||
!userSettings.manualRunAllowed ||
runStatus.safetyState.cooldown_active,
);
const startCheckDisabledReason = computed(() => {
if (!userSettings.manualRunAllowed) {
return "Manual checks are disabled by server policy.";
}
if (runStatus.safetyState.cooldown_active) {
return runStatus.safetyState.cooldown_reason_label || "Safety cooldown is active.";
}
if (runStatus.isRunActive) {
return "A check is already in progress.";
}
return null;
});
const startCheckLabel = computed(() => { const startCheckLabel = computed(() => {
if (!userSettings.manualRunAllowed) {
return "Manual checks disabled";
}
if (runStatus.safetyState.cooldown_active) {
return "Safety cooldown";
}
if (runStatus.isLikelyRunning) { if (runStatus.isLikelyRunning) {
return "Check in progress"; return "Check in progress";
} }
@ -32,6 +61,9 @@ const startCheckLabel = computed(() => {
} }
return "Start check"; return "Start check";
}); });
const isStartCheckAnimating = computed(
() => runStatus.isSubmitting || runStatus.isLikelyRunning,
);
const displayedLatestRun = computed(() => { const displayedLatestRun = computed(() => {
const snapshotLatest = snapshot.value?.latestRun ?? null; const snapshotLatest = snapshot.value?.latestRun ?? null;
@ -95,6 +127,7 @@ async function loadSnapshot(): Promise<void> {
const dashboardSnapshot = await fetchDashboardSnapshot(); const dashboardSnapshot = await fetchDashboardSnapshot();
snapshot.value = dashboardSnapshot; snapshot.value = dashboardSnapshot;
runStatus.setLatestRun(dashboardSnapshot.latestRun); runStatus.setLatestRun(dashboardSnapshot.latestRun);
runStatus.setSafetyState(dashboardSnapshot.safetyState);
} catch (error) { } catch (error) {
snapshot.value = null; snapshot.value = null;
if (error instanceof ApiRequestError) { if (error instanceof ApiRequestError) {
@ -140,6 +173,29 @@ async function onTriggerRun(): Promise<void> {
onMounted(() => { onMounted(() => {
void loadSnapshot(); void loadSnapshot();
}); });
watch(
() => runStatus.latestRun,
(nextRun, previousRun) => {
if (refreshingAfterCompletion.value) {
return;
}
if (!nextRun || !previousRun) {
return;
}
if (nextRun.id !== previousRun.id) {
return;
}
if (previousRun.status !== "running" || nextRun.status === "running") {
return;
}
refreshingAfterCompletion.value = true;
void loadSnapshot().finally(() => {
refreshingAfterCompletion.value = false;
});
},
);
</script> </script>
<template> <template>
@ -156,6 +212,10 @@ onMounted(() => {
error-title="Dashboard request failed" error-title="Dashboard request failed"
@dismiss-success="successMessage = null" @dismiss-success="successMessage = null"
/> />
<ScrapeSafetyBanner
:safety-state="runStatus.safetyState"
:manual-run-allowed="userSettings.manualRunAllowed"
/>
<div class="h-0 min-h-0 flex-1 xl:overflow-hidden"> <div class="h-0 min-h-0 flex-1 xl:overflow-hidden">
<AsyncStateGate :loading="loading" :loading-lines="6" :show-empty="false"> <AsyncStateGate :loading="loading" :loading-lines="6" :show-empty="false">
@ -254,10 +314,20 @@ onMounted(() => {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<AppButton <AppButton
v-if="auth.isAdmin" v-if="auth.isAdmin"
:disabled="runStatus.isRunActive" :disabled="isStartBlocked"
:title="startCheckDisabledReason || undefined"
:class="isStartCheckAnimating ? 'shadow-[0_0_0_1px_var(--color-state-info-border)]' : ''"
@click="onTriggerRun" @click="onTriggerRun"
> >
<span class="inline-flex items-center gap-2">
<span v-if="isStartCheckAnimating" class="relative inline-flex h-2.5 w-2.5">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-60"
/>
<span class="relative inline-flex h-2.5 w-2.5 rounded-full bg-current" />
</span>
{{ startCheckLabel }} {{ startCheckLabel }}
</span>
</AppButton> </AppButton>
<RouterLink <RouterLink
v-if="auth.isAdmin" v-if="auth.isAdmin"

View file

@ -28,7 +28,7 @@ type PublicationSortKey = "title" | "scholar" | "year" | "citations" | "status"
const loading = ref(true); const loading = ref(true);
const publishingAll = ref(false); const publishingAll = ref(false);
const publishingSelected = ref(false); const publishingSelected = ref(false);
const mode = ref<PublicationMode>("new"); const mode = ref<PublicationMode>("all");
const selectedScholarFilter = ref(""); const selectedScholarFilter = ref("");
const searchQuery = ref(""); const searchQuery = ref("");
const sortKey = ref<PublicationSortKey>("first_seen"); const sortKey = ref<PublicationSortKey>("first_seen");

View file

@ -6,6 +6,7 @@ import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
import QueueHealthBadge from "@/components/patterns/QueueHealthBadge.vue"; import QueueHealthBadge from "@/components/patterns/QueueHealthBadge.vue";
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue"; import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue"; import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
import ScrapeSafetyBanner from "@/components/patterns/ScrapeSafetyBanner.vue";
import AppButton from "@/components/ui/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue"; import AppCard from "@/components/ui/AppCard.vue";
import AppEmptyState from "@/components/ui/AppEmptyState.vue"; import AppEmptyState from "@/components/ui/AppEmptyState.vue";
@ -22,6 +23,7 @@ import {
} from "@/features/runs"; } from "@/features/runs";
import { ApiRequestError } from "@/lib/api/errors"; import { ApiRequestError } from "@/lib/api/errors";
import { useRunStatusStore } from "@/stores/run_status"; import { useRunStatusStore } from "@/stores/run_status";
import { useUserSettingsStore } from "@/stores/user_settings";
const loading = ref(true); const loading = ref(true);
const runs = ref<RunListItem[]>([]); const runs = ref<RunListItem[]>([]);
@ -31,6 +33,7 @@ const errorRequestId = ref<string | null>(null);
const successMessage = ref<string | null>(null); const successMessage = ref<string | null>(null);
const activeQueueItemId = ref<number | null>(null); const activeQueueItemId = ref<number | null>(null);
const runStatus = useRunStatusStore(); const runStatus = useRunStatusStore();
const userSettings = useUserSettingsStore();
function formatDate(value: string | null): string { function formatDate(value: string | null): string {
if (!value) { if (!value) {
@ -59,7 +62,31 @@ function queueHealth() {
const queueCounts = computed(() => queueHealth()); const queueCounts = computed(() => queueHealth());
const activeRunId = computed(() => runStatus.latestRun?.status === "running" ? runStatus.latestRun.id : null); const activeRunId = computed(() => runStatus.latestRun?.status === "running" ? runStatus.latestRun.id : null);
const isStartBlocked = computed(
() =>
runStatus.isRunActive ||
!userSettings.manualRunAllowed ||
runStatus.safetyState.cooldown_active,
);
const startCheckDisabledReason = computed(() => {
if (!userSettings.manualRunAllowed) {
return "Manual checks are disabled by server policy.";
}
if (runStatus.safetyState.cooldown_active) {
return runStatus.safetyState.cooldown_reason_label || "Safety cooldown is active.";
}
if (runStatus.isRunActive) {
return "A check is already in progress.";
}
return null;
});
const runButtonLabel = computed(() => { const runButtonLabel = computed(() => {
if (!userSettings.manualRunAllowed) {
return "Manual checks disabled";
}
if (runStatus.safetyState.cooldown_active) {
return "Safety cooldown";
}
if (runStatus.isLikelyRunning) { if (runStatus.isLikelyRunning) {
return "Check in progress"; return "Check in progress";
} }
@ -75,10 +102,11 @@ async function loadData(): Promise<void> {
errorRequestId.value = null; errorRequestId.value = null;
try { try {
const [loadedRuns, loadedQueue] = await Promise.all([listRuns({ limit: 100 }), listQueueItems(200)]); const [loadedRunsPayload, loadedQueue] = await Promise.all([listRuns({ limit: 100 }), listQueueItems(200)]);
runs.value = loadedRuns; runs.value = loadedRunsPayload.runs;
queueItems.value = loadedQueue; queueItems.value = loadedQueue;
runStatus.setLatestRun(loadedRuns[0] ?? null); runStatus.setLatestRun(loadedRunsPayload.runs[0] ?? null);
runStatus.setSafetyState(loadedRunsPayload.safety_state);
} catch (error) { } catch (error) {
runs.value = []; runs.value = [];
queueItems.value = []; queueItems.value = [];
@ -174,8 +202,12 @@ onMounted(() => {
:dropped="queueCounts.dropped" :dropped="queueCounts.dropped"
/> />
</div> </div>
<ScrapeSafetyBanner
:safety-state="runStatus.safetyState"
:manual-run-allowed="userSettings.manualRunAllowed"
/>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<AppButton :disabled="runStatus.isRunActive" @click="onTriggerManualRun"> <AppButton :disabled="isStartBlocked" :title="startCheckDisabledReason || undefined" @click="onTriggerManualRun">
{{ runButtonLabel }} {{ runButtonLabel }}
</AppButton> </AppButton>
<AppButton variant="secondary" :disabled="loading" @click="loadData"> <AppButton variant="secondary" :disabled="loading" @click="loadData">

View file

@ -14,10 +14,12 @@ import {
changePassword, changePassword,
fetchSettings, fetchSettings,
type UserSettings, type UserSettings,
type UserSettingsUpdate,
updateSettings, updateSettings,
} from "@/features/settings"; } from "@/features/settings";
import { ApiRequestError } from "@/lib/api/errors"; import { ApiRequestError } from "@/lib/api/errors";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useRunStatusStore } from "@/stores/run_status";
import { normalizeUserNavVisiblePages, useUserSettingsStore } from "@/stores/user_settings"; import { normalizeUserNavVisiblePages, useUserSettingsStore } from "@/stores/user_settings";
interface NavPageOption { interface NavPageOption {
@ -78,6 +80,7 @@ const NAV_PAGE_OPTIONS: NavPageOption[] = [
const auth = useAuthStore(); const auth = useAuthStore();
const userSettings = useUserSettingsStore(); const userSettings = useUserSettingsStore();
const runStatus = useRunStatusStore();
const loading = ref(true); const loading = ref(true);
const saving = ref(false); const saving = ref(false);
@ -98,8 +101,14 @@ const successMessage = ref<string | null>(null);
const showIngestionModal = ref(false); const showIngestionModal = ref(false);
const showPasswordModal = ref(false); const showPasswordModal = ref(false);
const showNavigationModal = ref(false); const showNavigationModal = ref(false);
const MIN_CHECK_INTERVAL_MINUTES = 15; const minCheckIntervalMinutes = ref(15);
const MIN_REQUEST_DELAY_SECONDS = 2; const minRequestDelaySeconds = ref(2);
const automationAllowed = ref(true);
const manualRunAllowed = ref(true);
const blockedFailureThreshold = ref(1);
const networkFailureThreshold = ref(2);
const cooldownBlockedSeconds = ref(1800);
const cooldownNetworkSeconds = ref(900);
const visibleNavOptions = computed(() => const visibleNavOptions = computed(() =>
NAV_PAGE_OPTIONS.filter((option) => !option.adminOnly || auth.isAdmin), NAV_PAGE_OPTIONS.filter((option) => !option.adminOnly || auth.isAdmin),
@ -111,11 +120,35 @@ const visibleNavLabels = computed(() =>
); );
function hydrateSettings(settings: UserSettings): void { function hydrateSettings(settings: UserSettings): void {
autoRunEnabled.value = settings.auto_run_enabled; const parsedMinRunInterval = Number(settings.policy?.min_run_interval_minutes);
minCheckIntervalMinutes.value = Number.isFinite(parsedMinRunInterval)
? Math.max(15, parsedMinRunInterval)
: 15;
const parsedMinRequestDelay = Number(settings.policy?.min_request_delay_seconds);
minRequestDelaySeconds.value = Number.isFinite(parsedMinRequestDelay)
? Math.max(2, parsedMinRequestDelay)
: 2;
automationAllowed.value = Boolean(settings.policy?.automation_allowed ?? true);
manualRunAllowed.value = Boolean(settings.policy?.manual_run_allowed ?? true);
blockedFailureThreshold.value = Number.isFinite(settings.policy?.blocked_failure_threshold)
? Math.max(1, settings.policy.blocked_failure_threshold)
: 1;
networkFailureThreshold.value = Number.isFinite(settings.policy?.network_failure_threshold)
? Math.max(1, settings.policy.network_failure_threshold)
: 2;
cooldownBlockedSeconds.value = Number.isFinite(settings.policy?.cooldown_blocked_seconds)
? Math.max(60, settings.policy.cooldown_blocked_seconds)
: 1800;
cooldownNetworkSeconds.value = Number.isFinite(settings.policy?.cooldown_network_seconds)
? Math.max(60, settings.policy.cooldown_network_seconds)
: 900;
autoRunEnabled.value = Boolean(settings.auto_run_enabled) && automationAllowed.value;
runIntervalMinutes.value = String(settings.run_interval_minutes); runIntervalMinutes.value = String(settings.run_interval_minutes);
requestDelaySeconds.value = String(settings.request_delay_seconds); requestDelaySeconds.value = String(settings.request_delay_seconds);
navVisiblePages.value = normalizeUserNavVisiblePages(settings.nav_visible_pages); navVisiblePages.value = normalizeUserNavVisiblePages(settings.nav_visible_pages);
userSettings.applySettings(settings); userSettings.applySettings(settings);
runStatus.setSafetyState(settings.safety_state);
} }
function parseBoundedInteger(value: string, label: string, minimum: number): number { function parseBoundedInteger(value: string, label: string, minimum: number): number {
@ -176,17 +209,17 @@ async function onSaveSettings(): Promise<void> {
successMessage.value = null; successMessage.value = null;
try { try {
const payload: UserSettings = { const payload: UserSettingsUpdate = {
auto_run_enabled: autoRunEnabled.value, auto_run_enabled: autoRunEnabled.value,
run_interval_minutes: parseBoundedInteger( run_interval_minutes: parseBoundedInteger(
runIntervalMinutes.value, runIntervalMinutes.value,
"Check interval (minutes)", "Check interval (minutes)",
MIN_CHECK_INTERVAL_MINUTES, minCheckIntervalMinutes.value,
), ),
request_delay_seconds: parseBoundedInteger( request_delay_seconds: parseBoundedInteger(
requestDelaySeconds.value, requestDelaySeconds.value,
"Delay between requests (seconds)", "Delay between requests (seconds)",
MIN_REQUEST_DELAY_SECONDS, minRequestDelaySeconds.value,
), ),
nav_visible_pages: normalizeUserNavVisiblePages(navVisiblePages.value), nav_visible_pages: normalizeUserNavVisiblePages(navVisiblePages.value),
}; };
@ -275,22 +308,6 @@ onMounted(() => {
<p class="text-sm text-secondary"> <p class="text-sm text-secondary">
Configure the background checker that looks for new publications on your tracked profiles. Configure the background checker that looks for new publications on your tracked profiles.
</p> </p>
<dl class="grid gap-2 text-sm text-secondary">
<div class="flex items-center justify-between gap-2">
<dt>Automatic checks</dt>
<dd class="font-semibold text-ink-primary">
{{ autoRunEnabled ? "Enabled" : "Disabled" }}
</dd>
</div>
<div class="flex items-center justify-between gap-2">
<dt>Check interval</dt>
<dd class="font-semibold text-ink-primary">Every {{ runIntervalMinutes }} min</dd>
</div>
<div class="flex items-center justify-between gap-2">
<dt>Delay between requests</dt>
<dd class="font-semibold text-ink-primary">{{ requestDelaySeconds }} sec</dd>
</div>
</dl>
<AppButton variant="secondary" class="mt-auto self-start" @click="showIngestionModal = true"> <AppButton variant="secondary" class="mt-auto self-start" @click="showIngestionModal = true">
Edit checking rules Edit checking rules
</AppButton> </AppButton>
@ -330,15 +347,23 @@ onMounted(() => {
@close="showIngestionModal = false" @close="showIngestionModal = false"
> >
<form class="grid gap-3" @submit.prevent="onSaveSettings"> <form class="grid gap-3" @submit.prevent="onSaveSettings">
<AppCheckbox id="auto-run-enabled" v-model="autoRunEnabled" label="Enable automatic background checks" /> <AppCheckbox
id="auto-run-enabled"
v-model="autoRunEnabled"
:disabled="!automationAllowed"
label="Enable automatic background checks"
/>
<p v-if="!automationAllowed" class="text-xs text-secondary">
Automatic checks are disabled by server safety policy.
</p>
<label class="grid gap-2 text-sm font-medium text-ink-secondary"> <label class="grid gap-2 text-sm font-medium text-ink-secondary">
<span class="inline-flex items-center gap-1"> <span class="inline-flex items-center gap-1">
Check interval (minutes) Check interval (minutes)
<AppHelpHint text="How often Scholarr starts a background update check." /> <AppHelpHint text="How often Scholarr starts a background update check." />
</span> </span>
<AppInput id="run-interval" v-model="runIntervalMinutes" type="number" :min="MIN_CHECK_INTERVAL_MINUTES" /> <AppInput id="run-interval" v-model="runIntervalMinutes" type="number" :min="minCheckIntervalMinutes" />
<span class="text-xs text-secondary">Minimum: {{ MIN_CHECK_INTERVAL_MINUTES }} minutes.</span> <span class="text-xs text-secondary">Minimum: {{ minCheckIntervalMinutes }} minutes.</span>
</label> </label>
<label class="grid gap-2 text-sm font-medium text-ink-secondary"> <label class="grid gap-2 text-sm font-medium text-ink-secondary">
@ -350,11 +375,18 @@ onMounted(() => {
id="request-delay" id="request-delay"
v-model="requestDelaySeconds" v-model="requestDelaySeconds"
type="number" type="number"
:min="MIN_REQUEST_DELAY_SECONDS" :min="minRequestDelaySeconds"
/> />
<span class="text-xs text-secondary">Minimum: {{ MIN_REQUEST_DELAY_SECONDS }} seconds.</span> <span class="text-xs text-secondary">Minimum: {{ minRequestDelaySeconds }} seconds.</span>
</label> </label>
<div class="grid gap-1 rounded-lg border border-stroke-default bg-surface-card-muted px-3 py-2 text-xs text-secondary">
<p class="font-medium text-ink-primary">Server-enforced scrape safety policy</p>
<p>Blocked failures trigger cooldown at {{ blockedFailureThreshold }} failures.</p>
<p>Network failures trigger cooldown at {{ networkFailureThreshold }} failures.</p>
<p>Blocked cooldown: {{ cooldownBlockedSeconds }}s. Network cooldown: {{ cooldownNetworkSeconds }}s.</p>
</div>
<div class="mt-2 flex flex-wrap justify-end gap-2"> <div class="mt-2 flex flex-wrap justify-end gap-2">
<AppButton <AppButton
variant="ghost" variant="ghost"

View file

@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia"; import { createPinia, setActivePinia } from "pinia";
import { ApiRequestError } from "@/lib/api/errors"; import { ApiRequestError } from "@/lib/api/errors";
import { createDefaultSafetyState } from "@/features/safety";
vi.mock("@/features/runs", () => ({ vi.mock("@/features/runs", () => ({
listRuns: vi.fn(), listRuns: vi.fn(),
@ -40,6 +41,13 @@ function buildRun(overrides: Partial<{
}; };
} }
function buildRunsPayload(runs: ReturnType<typeof buildRun>[]) {
return {
runs,
safety_state: createDefaultSafetyState(),
};
}
describe("run status store", () => { describe("run status store", () => {
const mockedListRuns = vi.mocked(listRuns); const mockedListRuns = vi.mocked(listRuns);
const mockedTriggerManualRun = vi.mocked(triggerManualRun); const mockedTriggerManualRun = vi.mocked(triggerManualRun);
@ -57,7 +65,7 @@ describe("run status store", () => {
}); });
it("bootstraps from latest run and exposes idle start state", async () => { it("bootstraps from latest run and exposes idle start state", async () => {
mockedListRuns.mockResolvedValueOnce([buildRun({ id: 11, status: "success" })]); mockedListRuns.mockResolvedValueOnce(buildRunsPayload([buildRun({ id: 11, status: "success" })]));
const store = useRunStatusStore(); const store = useRunStatusStore();
await store.bootstrap(); await store.bootstrap();
@ -80,8 +88,11 @@ describe("run status store", () => {
new_publication_count: 0, new_publication_count: 0,
reused_existing_run: false, reused_existing_run: false,
idempotency_key: "abc", idempotency_key: "abc",
safety_state: createDefaultSafetyState(),
}); });
mockedListRuns.mockResolvedValueOnce([buildRun({ id: 25, status: "running", end_dt: null })]); mockedListRuns.mockResolvedValueOnce(
buildRunsPayload([buildRun({ id: 25, status: "running", end_dt: null })]),
);
const store = useRunStatusStore(); const store = useRunStatusStore();
const result = await store.startManualCheck(); const result = await store.startManualCheck();
@ -106,7 +117,9 @@ describe("run status store", () => {
requestId: "req_123", requestId: "req_123",
}), }),
); );
mockedListRuns.mockResolvedValueOnce([buildRun({ id: 42, status: "running", end_dt: null })]); mockedListRuns.mockResolvedValueOnce(
buildRunsPayload([buildRun({ id: 42, status: "running", end_dt: null })]),
);
const store = useRunStatusStore(); const store = useRunStatusStore();
const result = await store.startManualCheck(); const result = await store.startManualCheck();
@ -124,8 +137,8 @@ describe("run status store", () => {
it("polls while a run is active and stops when it completes", async () => { it("polls while a run is active and stops when it completes", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
mockedListRuns mockedListRuns
.mockResolvedValueOnce([buildRun({ id: 90, status: "running", end_dt: null })]) .mockResolvedValueOnce(buildRunsPayload([buildRun({ id: 90, status: "running", end_dt: null })]))
.mockResolvedValueOnce([buildRun({ id: 90, status: "success" })]); .mockResolvedValueOnce(buildRunsPayload([buildRun({ id: 90, status: "success" })]));
const store = useRunStatusStore(); const store = useRunStatusStore();
await store.syncLatest(); await store.syncLatest();
@ -139,6 +152,42 @@ describe("run status store", () => {
expect(store.isRunActive).toBe(false); expect(store.isRunActive).toBe(false);
}); });
it("stores cooldown safety state when manual start is blocked by policy cooldown", async () => {
mockedTriggerManualRun.mockRejectedValueOnce(
new ApiRequestError({
status: 429,
code: "scrape_cooldown_active",
message: "Scrape safety cooldown is active; run start is temporarily blocked.",
details: {
safety_state: {
cooldown_active: true,
cooldown_reason: "blocked_failure_threshold_exceeded",
cooldown_reason_label: "Blocked responses exceeded safety threshold",
cooldown_until: "2026-02-19T12:30:00Z",
cooldown_remaining_seconds: 600,
recommended_action: "Wait for cooldown to expire.",
counters: {
consecutive_blocked_runs: 1,
consecutive_network_runs: 0,
cooldown_entry_count: 1,
blocked_start_count: 2,
last_blocked_failure_count: 1,
last_network_failure_count: 0,
last_evaluated_run_id: 10,
},
},
},
}),
);
const store = useRunStatusStore();
const result = await store.startManualCheck();
expect(result.kind).toBe("error");
expect(store.safetyState.cooldown_active).toBe(true);
expect(store.canStart).toBe(false);
});
it("switches from starting to in-progress when trigger request remains open", async () => { it("switches from starting to in-progress when trigger request remains open", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
mockedTriggerManualRun.mockImplementation( mockedTriggerManualRun.mockImplementation(
@ -155,11 +204,12 @@ describe("run status store", () => {
new_publication_count: 0, new_publication_count: 0,
reused_existing_run: false, reused_existing_run: false,
idempotency_key: "x", idempotency_key: "x",
safety_state: createDefaultSafetyState(),
}); });
}, RUN_STATUS_STARTING_PHASE_MS + 500); }, RUN_STATUS_STARTING_PHASE_MS + 500);
}), }),
); );
mockedListRuns.mockResolvedValueOnce([]); mockedListRuns.mockResolvedValueOnce(buildRunsPayload([]));
const store = useRunStatusStore(); const store = useRunStatusStore();
const startPromise = store.startManualCheck(); const startPromise = store.startManualCheck();

View file

@ -1,6 +1,11 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { listRuns, triggerManualRun, type RunListItem } from "@/features/runs"; import { listRuns, triggerManualRun, type RunListItem } from "@/features/runs";
import {
createDefaultSafetyState,
normalizeSafetyState,
type ScrapeSafetyState,
} from "@/features/safety";
import { ApiRequestError } from "@/lib/api/errors"; import { ApiRequestError } from "@/lib/api/errors";
export const RUN_STATUS_POLL_INTERVAL_MS = 5000; export const RUN_STATUS_POLL_INTERVAL_MS = 5000;
@ -49,6 +54,19 @@ function extractRunIdFromDetails(details: unknown): number | null {
return parseRunId(runIdCandidate); return parseRunId(runIdCandidate);
} }
function extractSafetyStateFromDetails(details: unknown): ScrapeSafetyState | null {
if (!details || typeof details !== "object") {
return null;
}
const candidate = (details as Record<string, unknown>).safety_state;
if (!candidate || typeof candidate !== "object") {
return null;
}
return normalizeSafetyState(candidate);
}
function buildPlaceholderRunningRun(runId: number): RunListItem { function buildPlaceholderRunningRun(runId: number): RunListItem {
return { return {
id: runId, id: runId,
@ -66,6 +84,7 @@ function buildPlaceholderRunningRun(runId: number): RunListItem {
export const useRunStatusStore = defineStore("runStatus", { export const useRunStatusStore = defineStore("runStatus", {
state: () => ({ state: () => ({
latestRun: null as RunListItem | null, latestRun: null as RunListItem | null,
safetyState: createDefaultSafetyState() as ScrapeSafetyState,
isSubmitting: false, isSubmitting: false,
assumeRunningFromSubmission: false, assumeRunningFromSubmission: false,
isPolling: false, isPolling: false,
@ -81,7 +100,7 @@ export const useRunStatusStore = defineStore("runStatus", {
return state.latestRun?.status === "running" || state.assumeRunningFromSubmission; return state.latestRun?.status === "running" || state.assumeRunningFromSubmission;
}, },
canStart(): boolean { canStart(): boolean {
return !this.isRunActive; return !this.isRunActive && !this.safetyState.cooldown_active;
}, },
}, },
actions: { actions: {
@ -113,6 +132,9 @@ export const useRunStatusStore = defineStore("runStatus", {
} }
this.updatePolling(); this.updatePolling();
}, },
setSafetyState(value: unknown): void {
this.safetyState = normalizeSafetyState(value);
},
startPolling(): void { startPolling(): void {
if (pollTimer !== null) { if (pollTimer !== null) {
this.isPolling = true; this.isPolling = true;
@ -146,9 +168,10 @@ export const useRunStatusStore = defineStore("runStatus", {
syncPromise = (async () => { syncPromise = (async () => {
try { try {
const runs = await listRuns({ limit: 1 }); const payload = await listRuns({ limit: 1 });
const latest = runs[0] ?? null; const latest = payload.runs[0] ?? null;
this.latestRun = latest; this.latestRun = latest;
this.safetyState = normalizeSafetyState(payload.safety_state);
this.lastSyncAt = Date.now(); this.lastSyncAt = Date.now();
this.lastErrorMessage = null; this.lastErrorMessage = null;
this.lastErrorRequestId = null; this.lastErrorRequestId = null;
@ -184,6 +207,18 @@ export const useRunStatusStore = defineStore("runStatus", {
requestId: null, requestId: null,
}; };
} }
if (this.safetyState.cooldown_active) {
const message =
this.safetyState.recommended_action ||
"Scrape safety cooldown is active; run start is temporarily blocked.";
this.lastErrorMessage = message;
this.lastErrorRequestId = null;
return {
kind: "error",
message,
requestId: null,
};
}
this.isSubmitting = true; this.isSubmitting = true;
this.lastErrorMessage = null; this.lastErrorMessage = null;
@ -193,6 +228,7 @@ export const useRunStatusStore = defineStore("runStatus", {
try { try {
const result = await triggerManualRun(); const result = await triggerManualRun();
this.safetyState = normalizeSafetyState(result.safety_state);
await this.syncLatest(); await this.syncLatest();
if (!this.latestRun || this.latestRun.id !== result.run_id) { if (!this.latestRun || this.latestRun.id !== result.run_id) {
@ -223,6 +259,20 @@ export const useRunStatusStore = defineStore("runStatus", {
}; };
} }
if (error instanceof ApiRequestError && error.code === "scrape_cooldown_active") {
const safetyState = extractSafetyStateFromDetails(error.details);
if (safetyState) {
this.safetyState = safetyState;
}
this.lastErrorMessage = error.message;
this.lastErrorRequestId = error.requestId;
return {
kind: "error",
message: error.message,
requestId: error.requestId,
};
}
if (error instanceof ApiRequestError) { if (error instanceof ApiRequestError) {
this.lastErrorMessage = error.message; this.lastErrorMessage = error.message;
this.lastErrorRequestId = error.requestId; this.lastErrorRequestId = error.requestId;
@ -259,6 +309,7 @@ export const useRunStatusStore = defineStore("runStatus", {
this.lastErrorMessage = null; this.lastErrorMessage = null;
this.lastErrorRequestId = null; this.lastErrorRequestId = null;
this.lastSyncAt = null; this.lastSyncAt = null;
this.safetyState = createDefaultSafetyState();
}, },
}, },
}); });

View file

@ -1,6 +1,7 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { fetchSettings, type UserSettings } from "@/features/settings"; import { fetchSettings, type UserSettings } from "@/features/settings";
import { createDefaultSafetyState, normalizeSafetyState, type ScrapeSafetyState } from "@/features/safety";
export const REQUIRED_NAV_PAGES = ["dashboard", "scholars", "settings"] as const; export const REQUIRED_NAV_PAGES = ["dashboard", "scholars", "settings"] as const;
export const DEFAULT_NAV_VISIBLE_PAGES = [ export const DEFAULT_NAV_VISIBLE_PAGES = [
@ -51,6 +52,15 @@ function normalizeNavVisiblePages(value: unknown): string[] {
export const useUserSettingsStore = defineStore("userSettings", { export const useUserSettingsStore = defineStore("userSettings", {
state: () => ({ state: () => ({
navVisiblePages: [...DEFAULT_NAV_VISIBLE_PAGES] as string[], navVisiblePages: [...DEFAULT_NAV_VISIBLE_PAGES] as string[],
minRunIntervalMinutes: 15,
minRequestDelaySeconds: 2,
automationAllowed: true,
manualRunAllowed: true,
blockedFailureThreshold: 1,
networkFailureThreshold: 2,
cooldownBlockedSeconds: 1800,
cooldownNetworkSeconds: 900,
safetyState: createDefaultSafetyState() as ScrapeSafetyState,
}), }),
getters: { getters: {
visiblePageSet: (state) => new Set(state.navVisiblePages), visiblePageSet: (state) => new Set(state.navVisiblePages),
@ -61,9 +71,39 @@ export const useUserSettingsStore = defineStore("userSettings", {
}, },
applySettings(settings: UserSettings): void { applySettings(settings: UserSettings): void {
this.setNavVisiblePages(settings.nav_visible_pages); this.setNavVisiblePages(settings.nav_visible_pages);
this.minRunIntervalMinutes = Number.isFinite(settings.policy?.min_run_interval_minutes)
? Math.max(15, settings.policy.min_run_interval_minutes)
: 15;
this.minRequestDelaySeconds = Number.isFinite(settings.policy?.min_request_delay_seconds)
? Math.max(2, settings.policy.min_request_delay_seconds)
: 2;
this.automationAllowed = Boolean(settings.policy?.automation_allowed ?? true);
this.manualRunAllowed = Boolean(settings.policy?.manual_run_allowed ?? true);
this.blockedFailureThreshold = Number.isFinite(settings.policy?.blocked_failure_threshold)
? Math.max(1, settings.policy.blocked_failure_threshold)
: 1;
this.networkFailureThreshold = Number.isFinite(settings.policy?.network_failure_threshold)
? Math.max(1, settings.policy.network_failure_threshold)
: 1;
this.cooldownBlockedSeconds = Number.isFinite(settings.policy?.cooldown_blocked_seconds)
? Math.max(60, settings.policy.cooldown_blocked_seconds)
: 1800;
this.cooldownNetworkSeconds = Number.isFinite(settings.policy?.cooldown_network_seconds)
? Math.max(60, settings.policy.cooldown_network_seconds)
: 900;
this.safetyState = normalizeSafetyState(settings.safety_state);
}, },
reset(): void { reset(): void {
this.navVisiblePages = [...DEFAULT_NAV_VISIBLE_PAGES]; this.navVisiblePages = [...DEFAULT_NAV_VISIBLE_PAGES];
this.minRunIntervalMinutes = 15;
this.minRequestDelaySeconds = 2;
this.automationAllowed = true;
this.manualRunAllowed = true;
this.blockedFailureThreshold = 1;
this.networkFailureThreshold = 2;
this.cooldownBlockedSeconds = 1800;
this.cooldownNetworkSeconds = 900;
this.safetyState = createDefaultSafetyState();
}, },
isPageVisible(pageId: string): boolean { isPageVisible(pageId: string): boolean {
if (REQUIRED_NAV_PAGES_SET.has(pageId)) { if (REQUIRED_NAV_PAGES_SET.has(pageId)) {

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
import pytest import pytest
@ -9,10 +10,31 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.runtime_deps import get_scholar_source from app.api.runtime_deps import get_scholar_source
from app.main import app from app.main import app
from app.services import user_settings as user_settings_service
from app.services.scholar_source import FetchResult from app.services.scholar_source import FetchResult
from app.settings import settings from app.settings import settings
from tests.integration.helpers import insert_user, login_user from tests.integration.helpers import insert_user, login_user
REGRESSION_FIXTURE_DIR = Path("tests/fixtures/scholar/regression")
SAFETY_STATE_KEYS = {
"cooldown_active",
"cooldown_reason",
"cooldown_reason_label",
"cooldown_until",
"cooldown_remaining_seconds",
"recommended_action",
"counters",
}
SAFETY_COUNTER_KEYS = {
"consecutive_blocked_runs",
"consecutive_network_runs",
"cooldown_entry_count",
"blocked_start_count",
"last_blocked_failure_count",
"last_network_failure_count",
"last_evaluated_run_id",
}
def _api_bootstrap_csrf_headers(client: TestClient) -> dict[str, str]: def _api_bootstrap_csrf_headers(client: TestClient) -> dict[str, str]:
bootstrap_response = client.get("/api/v1/auth/csrf") bootstrap_response = client.get("/api/v1/auth/csrf")
@ -32,6 +54,17 @@ def _api_csrf_headers(client: TestClient) -> dict[str, str]:
return {"X-CSRF-Token": token} return {"X-CSRF-Token": token}
def _regression_fixture(name: str) -> str:
return (REGRESSION_FIXTURE_DIR / name).read_text(encoding="utf-8")
def _assert_safety_state_contract(payload: dict[str, object]) -> None:
assert set(payload.keys()) == SAFETY_STATE_KEYS
counters = payload["counters"]
assert isinstance(counters, dict)
assert set(counters.keys()) == SAFETY_COUNTER_KEYS
@pytest.mark.integration @pytest.mark.integration
@pytest.mark.db @pytest.mark.db
@pytest.mark.asyncio @pytest.mark.asyncio
@ -592,8 +625,35 @@ async def test_api_settings_get_and_update(db_session: AsyncSession) -> None:
get_response = client.get("/api/v1/settings") get_response = client.get("/api/v1/settings")
assert get_response.status_code == 200 assert get_response.status_code == 200
assert "request_delay_seconds" in get_response.json()["data"] settings_payload = get_response.json()["data"]
assert "nav_visible_pages" in get_response.json()["data"] assert "request_delay_seconds" in settings_payload
assert "nav_visible_pages" in settings_payload
assert settings_payload["policy"]["min_run_interval_minutes"] == user_settings_service.resolve_run_interval_minimum(
settings.ingestion_min_run_interval_minutes
)
assert settings_payload["policy"]["min_request_delay_seconds"] == user_settings_service.resolve_request_delay_minimum(
settings.ingestion_min_request_delay_seconds
)
assert settings_payload["policy"]["automation_allowed"] is settings.ingestion_automation_allowed
assert settings_payload["policy"]["manual_run_allowed"] is settings.ingestion_manual_run_allowed
assert settings_payload["policy"]["blocked_failure_threshold"] == max(
1,
int(settings.ingestion_alert_blocked_failure_threshold),
)
assert settings_payload["policy"]["network_failure_threshold"] == max(
1,
int(settings.ingestion_alert_network_failure_threshold),
)
assert settings_payload["policy"]["cooldown_blocked_seconds"] == max(
60,
int(settings.ingestion_safety_cooldown_blocked_seconds),
)
assert settings_payload["policy"]["cooldown_network_seconds"] == max(
60,
int(settings.ingestion_safety_cooldown_network_seconds),
)
_assert_safety_state_contract(settings_payload["safety_state"])
assert settings_payload["safety_state"]["cooldown_active"] is False
update_response = client.put( update_response = client.put(
"/api/v1/settings", "/api/v1/settings",
@ -617,6 +677,59 @@ async def test_api_settings_get_and_update(db_session: AsyncSession) -> None:
"settings", "settings",
"runs", "runs",
] ]
assert "policy" in updated
_assert_safety_state_contract(updated["safety_state"])
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.asyncio
async def test_api_settings_enforce_env_minimums(db_session: AsyncSession) -> None:
await insert_user(
db_session,
email="api-settings-policy@example.com",
password="api-password",
)
client = TestClient(app)
login_user(client, email="api-settings-policy@example.com", password="api-password")
headers = _api_csrf_headers(client)
previous_min_interval = settings.ingestion_min_run_interval_minutes
previous_min_delay = settings.ingestion_min_request_delay_seconds
object.__setattr__(settings, "ingestion_min_run_interval_minutes", 30)
object.__setattr__(settings, "ingestion_min_request_delay_seconds", 8)
try:
interval_response = client.put(
"/api/v1/settings",
json={
"auto_run_enabled": True,
"run_interval_minutes": 29,
"request_delay_seconds": 9,
"nav_visible_pages": ["dashboard", "scholars", "settings"],
},
headers=headers,
)
assert interval_response.status_code == 400
assert interval_response.json()["error"]["code"] == "invalid_settings"
assert interval_response.json()["error"]["message"] == "Check interval must be at least 30 minutes."
delay_response = client.put(
"/api/v1/settings",
json={
"auto_run_enabled": True,
"run_interval_minutes": 30,
"request_delay_seconds": 7,
"nav_visible_pages": ["dashboard", "scholars", "settings"],
},
headers=headers,
)
assert delay_response.status_code == 400
assert delay_response.json()["error"]["code"] == "invalid_settings"
assert delay_response.json()["error"]["message"] == "Request delay must be at least 8 seconds."
finally:
object.__setattr__(settings, "ingestion_min_run_interval_minutes", previous_min_interval)
object.__setattr__(settings, "ingestion_min_request_delay_seconds", previous_min_delay)
@pytest.mark.integration @pytest.mark.integration
@ -643,6 +756,7 @@ async def test_api_runs_manual_and_queue_actions(db_session: AsyncSession) -> No
assert run_payload["status"] in {"success", "partial_failure", "failed"} assert run_payload["status"] in {"success", "partial_failure", "failed"}
assert run_payload["reused_existing_run"] is False assert run_payload["reused_existing_run"] is False
assert run_payload["idempotency_key"] == "manual-run-0001" assert run_payload["idempotency_key"] == "manual-run-0001"
_assert_safety_state_contract(run_payload["safety_state"])
run_id = int(run_payload["run_id"]) run_id = int(run_payload["run_id"])
stored_key = await db_session.execute( stored_key = await db_session.execute(
@ -659,16 +773,19 @@ async def test_api_runs_manual_and_queue_actions(db_session: AsyncSession) -> No
replay_payload = replay_response.json()["data"] replay_payload = replay_response.json()["data"]
assert replay_payload["run_id"] == run_payload["run_id"] assert replay_payload["run_id"] == run_payload["run_id"]
assert replay_payload["reused_existing_run"] is True assert replay_payload["reused_existing_run"] is True
_assert_safety_state_contract(replay_payload["safety_state"])
runs_response = client.get("/api/v1/runs") runs_response = client.get("/api/v1/runs")
assert runs_response.status_code == 200 assert runs_response.status_code == 200
assert len(runs_response.json()["data"]["runs"]) >= 1 assert len(runs_response.json()["data"]["runs"]) >= 1
_assert_safety_state_contract(runs_response.json()["data"]["safety_state"])
run_detail_response = client.get(f"/api/v1/runs/{run_id}") run_detail_response = client.get(f"/api/v1/runs/{run_id}")
assert run_detail_response.status_code == 200 assert run_detail_response.status_code == 200
detail_payload = run_detail_response.json()["data"] detail_payload = run_detail_response.json()["data"]
assert "summary" in detail_payload assert "summary" in detail_payload
assert isinstance(detail_payload["scholar_results"], list) assert isinstance(detail_payload["scholar_results"], list)
_assert_safety_state_contract(detail_payload["safety_state"])
scholar_result = await db_session.execute( scholar_result = await db_session.execute(
text( text(
@ -750,6 +867,278 @@ async def test_api_runs_manual_and_queue_actions(db_session: AsyncSession) -> No
assert clear_response.json()["data"]["message"] == "Queue item cleared." assert clear_response.json()["data"]["message"] == "Queue item cleared."
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.asyncio
async def test_api_manual_run_can_be_disabled_by_policy(db_session: AsyncSession) -> None:
await insert_user(
db_session,
email="api-runs-policy@example.com",
password="api-password",
)
client = TestClient(app)
login_user(client, email="api-runs-policy@example.com", password="api-password")
headers = _api_csrf_headers(client)
previous_manual_allowed = settings.ingestion_manual_run_allowed
object.__setattr__(settings, "ingestion_manual_run_allowed", False)
try:
response = client.post("/api/v1/runs/manual", headers=headers)
assert response.status_code == 403
payload = response.json()
assert payload["error"]["code"] == "manual_runs_disabled"
assert payload["error"]["details"]["policy"]["manual_run_allowed"] is False
assert payload["error"]["details"]["safety_state"]["cooldown_active"] is False
finally:
object.__setattr__(settings, "ingestion_manual_run_allowed", previous_manual_allowed)
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.asyncio
async def test_api_manual_run_enforces_scrape_safety_cooldown(db_session: AsyncSession) -> None:
user_id = await insert_user(
db_session,
email="api-runs-safety@example.com",
password="api-password",
)
await db_session.execute(
text(
"""
INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
VALUES (:user_id, :scholar_id, :display_name, true)
"""
),
{
"user_id": user_id,
"scholar_id": "A1B2C3D4E5F6",
"display_name": "Safety Probe",
},
)
await db_session.commit()
blocked_fixture = _regression_fixture("profile_AAAAAAAAAAAA.html")
class BlockedScholarSource:
async def fetch_profile_page_html(
self,
scholar_id: str,
*,
cstart: int,
pagesize: int,
) -> FetchResult:
_ = (scholar_id, cstart, pagesize)
return FetchResult(
requested_url="https://scholar.google.com/citations?hl=en&user=A1B2C3D4E5F6",
status_code=200,
final_url=(
"https://accounts.google.com/v3/signin/identifier"
"?continue=https%3A%2F%2Fscholar.google.com%2Fcitations"
),
body=blocked_fixture,
error=None,
)
async def fetch_profile_html(self, scholar_id: str) -> FetchResult:
_ = scholar_id
return await self.fetch_profile_page_html(
"A1B2C3D4E5F6",
cstart=0,
pagesize=settings.ingestion_page_size,
)
app.dependency_overrides[get_scholar_source] = lambda: BlockedScholarSource()
previous_blocked_threshold = settings.ingestion_alert_blocked_failure_threshold
previous_blocked_cooldown_seconds = settings.ingestion_safety_cooldown_blocked_seconds
object.__setattr__(settings, "ingestion_alert_blocked_failure_threshold", 1)
object.__setattr__(settings, "ingestion_safety_cooldown_blocked_seconds", 600)
try:
client = TestClient(app)
login_user(client, email="api-runs-safety@example.com", password="api-password")
headers = _api_csrf_headers(client)
first_run_response = client.post(
"/api/v1/runs/manual",
headers={**headers, "Idempotency-Key": "safety-cooldown-run-1"},
)
assert first_run_response.status_code == 200
settings_response = client.get("/api/v1/settings")
assert settings_response.status_code == 200
safety_state = settings_response.json()["data"]["safety_state"]
_assert_safety_state_contract(safety_state)
assert safety_state["cooldown_active"] is True
assert safety_state["cooldown_reason"] == "blocked_failure_threshold_exceeded"
assert int(safety_state["cooldown_remaining_seconds"]) > 0
assert int(safety_state["counters"]["cooldown_entry_count"]) >= 1
assert int(safety_state["counters"]["last_blocked_failure_count"]) >= 1
blocked_start_response = client.post(
"/api/v1/runs/manual",
headers={**headers, "Idempotency-Key": "safety-cooldown-run-2"},
)
assert blocked_start_response.status_code == 429
blocked_payload = blocked_start_response.json()
assert blocked_payload["error"]["code"] == "scrape_cooldown_active"
blocked_state = blocked_payload["error"]["details"]["safety_state"]
_assert_safety_state_contract(blocked_state)
assert blocked_state["cooldown_active"] is True
assert blocked_state["cooldown_reason"] == "blocked_failure_threshold_exceeded"
assert int(blocked_state["counters"]["blocked_start_count"]) >= 1
runs_response = client.get("/api/v1/runs")
assert runs_response.status_code == 200
_assert_safety_state_contract(runs_response.json()["data"]["safety_state"])
assert runs_response.json()["data"]["safety_state"]["cooldown_active"] is True
finally:
object.__setattr__(settings, "ingestion_alert_blocked_failure_threshold", previous_blocked_threshold)
object.__setattr__(settings, "ingestion_safety_cooldown_blocked_seconds", previous_blocked_cooldown_seconds)
app.dependency_overrides.pop(get_scholar_source, None)
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.asyncio
async def test_api_manual_run_enforces_network_failure_safety_cooldown(
db_session: AsyncSession,
) -> None:
user_id = await insert_user(
db_session,
email="api-runs-safety-network@example.com",
password="api-password",
)
await db_session.execute(
text(
"""
INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
VALUES (:user_id, :scholar_id, :display_name, true)
"""
),
{
"user_id": user_id,
"scholar_id": "NNN111NNN111",
"display_name": "Network Safety Probe",
},
)
await db_session.commit()
class NetworkFailureScholarSource:
async def fetch_profile_page_html(
self,
scholar_id: str,
*,
cstart: int,
pagesize: int,
) -> FetchResult:
_ = (scholar_id, cstart, pagesize)
return FetchResult(
requested_url="https://scholar.google.com/citations?hl=en&user=NNN111NNN111",
status_code=None,
final_url=None,
body="",
error="timed out",
)
async def fetch_profile_html(self, scholar_id: str) -> FetchResult:
_ = scholar_id
return await self.fetch_profile_page_html(
"NNN111NNN111",
cstart=0,
pagesize=settings.ingestion_page_size,
)
app.dependency_overrides[get_scholar_source] = lambda: NetworkFailureScholarSource()
previous_network_threshold = settings.ingestion_alert_network_failure_threshold
previous_network_cooldown_seconds = settings.ingestion_safety_cooldown_network_seconds
previous_network_retries = settings.ingestion_network_error_retries
previous_retry_backoff = settings.ingestion_retry_backoff_seconds
object.__setattr__(settings, "ingestion_alert_network_failure_threshold", 1)
object.__setattr__(settings, "ingestion_safety_cooldown_network_seconds", 600)
object.__setattr__(settings, "ingestion_network_error_retries", 0)
object.__setattr__(settings, "ingestion_retry_backoff_seconds", 0.0)
try:
client = TestClient(app)
login_user(client, email="api-runs-safety-network@example.com", password="api-password")
headers = _api_csrf_headers(client)
first_run_response = client.post(
"/api/v1/runs/manual",
headers={**headers, "Idempotency-Key": "safety-network-cooldown-run-1"},
)
assert first_run_response.status_code == 200
settings_response = client.get("/api/v1/settings")
assert settings_response.status_code == 200
safety_state = settings_response.json()["data"]["safety_state"]
_assert_safety_state_contract(safety_state)
assert safety_state["cooldown_active"] is True
assert safety_state["cooldown_reason"] == "network_failure_threshold_exceeded"
assert int(safety_state["cooldown_remaining_seconds"]) > 0
assert int(safety_state["counters"]["last_network_failure_count"]) >= 1
blocked_start_response = client.post(
"/api/v1/runs/manual",
headers={**headers, "Idempotency-Key": "safety-network-cooldown-run-2"},
)
assert blocked_start_response.status_code == 429
blocked_payload = blocked_start_response.json()
assert blocked_payload["error"]["code"] == "scrape_cooldown_active"
blocked_state = blocked_payload["error"]["details"]["safety_state"]
_assert_safety_state_contract(blocked_state)
assert blocked_state["cooldown_active"] is True
assert blocked_state["cooldown_reason"] == "network_failure_threshold_exceeded"
assert int(blocked_state["counters"]["blocked_start_count"]) >= 1
finally:
object.__setattr__(settings, "ingestion_alert_network_failure_threshold", previous_network_threshold)
object.__setattr__(settings, "ingestion_safety_cooldown_network_seconds", previous_network_cooldown_seconds)
object.__setattr__(settings, "ingestion_network_error_retries", previous_network_retries)
object.__setattr__(settings, "ingestion_retry_backoff_seconds", previous_retry_backoff)
app.dependency_overrides.pop(get_scholar_source, None)
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.asyncio
async def test_api_settings_clears_expired_scrape_safety_cooldown(
db_session: AsyncSession,
) -> None:
user_id = await insert_user(
db_session,
email="api-settings-safety-expired@example.com",
password="api-password",
)
user_settings = await user_settings_service.get_or_create_settings(
db_session,
user_id=user_id,
)
user_settings.scrape_safety_state = {"blocked_start_count": 2}
user_settings.scrape_cooldown_reason = "blocked_failure_threshold_exceeded"
user_settings.scrape_cooldown_until = datetime.now(timezone.utc) - timedelta(seconds=15)
await db_session.commit()
client = TestClient(app)
login_user(client, email="api-settings-safety-expired@example.com", password="api-password")
settings_response = client.get("/api/v1/settings")
assert settings_response.status_code == 200
settings_safety_state = settings_response.json()["data"]["safety_state"]
_assert_safety_state_contract(settings_safety_state)
assert settings_safety_state["cooldown_active"] is False
assert settings_safety_state["cooldown_reason"] is None
assert int(settings_safety_state["counters"]["blocked_start_count"]) == 2
runs_response = client.get("/api/v1/runs")
assert runs_response.status_code == 200
runs_safety_state = runs_response.json()["data"]["safety_state"]
_assert_safety_state_contract(runs_safety_state)
assert runs_safety_state["cooldown_active"] is False
assert runs_safety_state["cooldown_reason"] is None
@pytest.mark.integration @pytest.mark.integration
@pytest.mark.db @pytest.mark.db
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -14,7 +14,7 @@ EXPECTED_TABLES = {
} }
EXPECTED_ENUMS = {"run_status", "run_trigger_type"} EXPECTED_ENUMS = {"run_status", "run_trigger_type"}
EXPECTED_REVISION = "20260219_0008" EXPECTED_REVISION = "20260219_0009"
@pytest.mark.integration @pytest.mark.integration
@ -199,3 +199,22 @@ async def test_user_settings_has_nav_visible_pages_column(db_session: AsyncSessi
) )
) )
assert result.scalar_one() == 1 assert result.scalar_one() == 1
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.migrations
@pytest.mark.asyncio
async def test_user_settings_has_scrape_safety_columns(db_session: AsyncSession) -> None:
result = await db_session.execute(
text(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'user_settings'
AND column_name IN ('scrape_safety_state', 'scrape_cooldown_until', 'scrape_cooldown_reason')
"""
)
)
columns = {row[0] for row in result}
assert columns == {"scrape_safety_state", "scrape_cooldown_until", "scrape_cooldown_reason"}

View file

@ -0,0 +1,107 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from app.db.models import UserSetting
from app.services import run_safety
def test_apply_run_safety_outcome_triggers_blocked_cooldown() -> None:
settings = UserSetting(user_id=1, scrape_safety_state={})
now = datetime(2026, 2, 19, 20, 0, tzinfo=timezone.utc)
safety_state, reason = run_safety.apply_run_safety_outcome(
settings,
run_id=12,
blocked_failure_count=2,
network_failure_count=0,
blocked_failure_threshold=1,
network_failure_threshold=2,
blocked_cooldown_seconds=600,
network_cooldown_seconds=300,
now_utc=now,
)
assert reason == run_safety.COOLDOWN_REASON_BLOCKED_FAILURE_THRESHOLD
assert safety_state["cooldown_active"] is True
assert safety_state["cooldown_reason"] == run_safety.COOLDOWN_REASON_BLOCKED_FAILURE_THRESHOLD
assert safety_state["cooldown_remaining_seconds"] == 600
assert safety_state["counters"]["cooldown_entry_count"] == 1
assert safety_state["counters"]["last_blocked_failure_count"] == 2
assert safety_state["counters"]["last_evaluated_run_id"] == 12
def test_clear_expired_cooldown() -> None:
now = datetime(2026, 2, 19, 20, 0, tzinfo=timezone.utc)
settings = UserSetting(
user_id=1,
scrape_safety_state={"blocked_start_count": 3},
scrape_cooldown_until=now - timedelta(seconds=5),
scrape_cooldown_reason=run_safety.COOLDOWN_REASON_NETWORK_FAILURE_THRESHOLD,
)
changed = run_safety.clear_expired_cooldown(settings, now_utc=now)
safety_state = run_safety.get_safety_state_payload(settings, now_utc=now)
assert changed is True
assert safety_state["cooldown_active"] is False
assert safety_state["cooldown_reason"] is None
assert safety_state["counters"]["blocked_start_count"] == 3
def test_apply_run_safety_outcome_triggers_network_cooldown() -> None:
settings = UserSetting(user_id=1, scrape_safety_state={})
now = datetime(2026, 2, 19, 20, 0, tzinfo=timezone.utc)
safety_state, reason = run_safety.apply_run_safety_outcome(
settings,
run_id=21,
blocked_failure_count=0,
network_failure_count=3,
blocked_failure_threshold=2,
network_failure_threshold=1,
blocked_cooldown_seconds=900,
network_cooldown_seconds=300,
now_utc=now,
)
assert reason == run_safety.COOLDOWN_REASON_NETWORK_FAILURE_THRESHOLD
assert safety_state["cooldown_active"] is True
assert safety_state["cooldown_reason"] == run_safety.COOLDOWN_REASON_NETWORK_FAILURE_THRESHOLD
assert safety_state["cooldown_remaining_seconds"] == 300
assert safety_state["counters"]["cooldown_entry_count"] == 1
assert safety_state["counters"]["last_network_failure_count"] == 3
assert safety_state["counters"]["last_evaluated_run_id"] == 21
def test_register_cooldown_blocked_start_increments_counter() -> None:
now = datetime(2026, 2, 19, 20, 0, tzinfo=timezone.utc)
settings = UserSetting(
user_id=1,
scrape_safety_state={"blocked_start_count": 1},
scrape_cooldown_until=now + timedelta(minutes=5),
scrape_cooldown_reason=run_safety.COOLDOWN_REASON_BLOCKED_FAILURE_THRESHOLD,
)
safety_state = run_safety.register_cooldown_blocked_start(settings, now_utc=now)
assert safety_state["cooldown_active"] is True
assert safety_state["cooldown_reason"] == run_safety.COOLDOWN_REASON_BLOCKED_FAILURE_THRESHOLD
assert safety_state["counters"]["blocked_start_count"] == 2
def test_get_safety_event_context_contains_structured_fields() -> None:
now = datetime(2026, 2, 19, 20, 0, tzinfo=timezone.utc)
settings = UserSetting(
user_id=1,
scrape_safety_state={"cooldown_entry_count": 4},
scrape_cooldown_until=now + timedelta(minutes=10),
scrape_cooldown_reason=run_safety.COOLDOWN_REASON_BLOCKED_FAILURE_THRESHOLD,
)
event_context = run_safety.get_safety_event_context(settings, now_utc=now)
assert event_context["cooldown_active"] is True
assert event_context["cooldown_reason"] == run_safety.COOLDOWN_REASON_BLOCKED_FAILURE_THRESHOLD
assert int(event_context["cooldown_remaining_seconds"]) == 600
assert event_context["safety_counters"]["cooldown_entry_count"] == 4

View file

@ -4,10 +4,14 @@ import pytest
from app.services.user_settings import ( from app.services.user_settings import (
DEFAULT_NAV_VISIBLE_PAGES, DEFAULT_NAV_VISIBLE_PAGES,
HARD_MIN_REQUEST_DELAY_SECONDS,
HARD_MIN_RUN_INTERVAL_MINUTES,
UserSettingsServiceError, UserSettingsServiceError,
parse_nav_visible_pages, parse_nav_visible_pages,
parse_request_delay_seconds, parse_request_delay_seconds,
parse_run_interval_minutes, parse_run_interval_minutes,
resolve_request_delay_minimum,
resolve_run_interval_minimum,
) )
@ -35,6 +39,27 @@ def test_parse_request_delay_seconds_rejects_below_minimum() -> None:
parse_request_delay_seconds("1") parse_request_delay_seconds("1")
def test_parse_run_interval_minutes_rejects_below_configured_minimum() -> None:
with pytest.raises(
UserSettingsServiceError,
match="Check interval must be at least 30 minutes.",
):
parse_run_interval_minutes("29", minimum=30)
def test_parse_request_delay_seconds_rejects_below_configured_minimum() -> None:
with pytest.raises(
UserSettingsServiceError,
match="Request delay must be at least 8 seconds.",
):
parse_request_delay_seconds("7", minimum=8)
def test_resolve_minimums_keep_hard_floors() -> None:
assert resolve_run_interval_minimum(1) == HARD_MIN_RUN_INTERVAL_MINUTES
assert resolve_request_delay_minimum(0) == HARD_MIN_REQUEST_DELAY_SECONDS
def test_parse_nav_visible_pages_accepts_valid_pages() -> None: def test_parse_nav_visible_pages_accepts_valid_pages() -> None:
parsed = parse_nav_visible_pages( parsed = parse_nav_visible_pages(
[ [