Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
66fd155
FEAT: Report the documents a storage listing cannot use
varunj-msft Nov 2, 2026
2d8c465
Report non-files and missing containers instead of skipping them
varunj-msft Nov 2, 2026
5dec828
Report an entry that can't be checked instead of failing the whole li…
varunj-msft Nov 2, 2026
507567b
Check the opened file before reading it and skip links deleted since …
varunj-msft Nov 3, 2026
a45ac23
Close a file that isn't regular before reporting it and report entrie…
varunj-msft Nov 3, 2026
db245dc
Test a file replaced just before it's opened and stop a blocked read …
varunj-msft Nov 4, 2026
d1bdd82
List presets, suites, and initializers through the checked listing
varunj-msft Nov 5, 2026
76dbf9c
Report a preset, suite, or initializer folder that can't be read as u…
varunj-msft Nov 5, 2026
7d9ac2f
Log why storage is unavailable, without its path or URL
varunj-msft Nov 7, 2026
031dc61
Merge origin/main
varunj-msft Nov 8, 2026
622bbb8
Report storage failures with one error and answer them with 503
varunj-msft Nov 9, 2026
6ee84f0
Merge origin/main
varunj-msft Nov 10, 2026
374c620
Merge main
varunj-msft Nov 11, 2026
7ac059c
Answer suite readiness storage failures through the shared storage ha…
varunj-msft Nov 11, 2026
e08b67a
Merge main
varunj-msft Nov 11, 2026
7fa4ca4
Expect storage errors from main's version-checked delete tests the wa…
varunj-msft Nov 11, 2026
c506195
Merge main
varunj-msft Nov 11, 2026
0e7a21f
Exempt the storage error handler from the keyword-only check
varunj-msft Nov 11, 2026
35199cc
Leave the error behind a storage failure out of the logs
varunj-msft Nov 12, 2026
efcdfa9
Test a blob upload that fails with a transport or authentication error
varunj-msft Nov 12, 2026
c9b795a
Merge main
varunj-msft Nov 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions pyrit/backend/middleware/error_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from pyrit.backend.models import FieldError, ProblemDetail
from pyrit.exceptions.analytics_exception import AnalyticsException
from pyrit.exceptions.dataset_exception import DatasetAPIException
from pyrit.registry.file_document_storage import DocumentStorageUnavailableError, DocumentUnreadableError

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -192,6 +193,38 @@ async def permission_error_handler( # pyrit-async-suffix-exempt
content=problem.model_dump(exclude_none=True),
)

@app.exception_handler(DocumentStorageUnavailableError)
async def storage_unavailable_handler( # pyrit-async-suffix-exempt
request: Request,
exc: DocumentStorageUnavailableError,
) -> JSONResponse: # pyrit-keyword-only-exempt Starlette exception handler (request, exc)
"""
Handle stored documents that can't be read or written as 503 Service Unavailable.

The log names the storage location, without credentials, and the reason. It leaves out the traceback,
since the error behind it can carry a SAS URL. The response never carries a path or URL.

Returns:
JSONResponse: RFC 7807 problem detail response with 503 status.
"""
logger.warning(f"{exc} ({request.method} {request.url.path})")
if isinstance(exc, DocumentUnreadableError):
detail = f"{exc.label} '{exc.name}' can't be read: {exc.reason}"
else:
detail = f"{exc.label} storage is unavailable"
problem = ProblemDetail(
type="/errors/storage-unavailable",
title="Service Unavailable",
status=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=detail,
instance=str(request.url.path),
)

return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content=problem.model_dump(exclude_none=True),
)

@app.exception_handler(NotImplementedError)
async def not_implemented_handler( # pyrit-async-suffix-exempt
request: Request,
Expand Down
23 changes: 1 addition & 22 deletions pyrit/backend/routes/initializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
DELETE /api/initializers/{name} — unregister an initializer
"""

from azure.core.exceptions import AzureError
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status

from pyrit.backend.middleware.auth import require_admin
Expand All @@ -35,19 +34,6 @@
router = APIRouter(prefix="/initializers", tags=["initializers"])


def _custom_storage_unavailable() -> HTTPException:
"""
Create a sanitized response for unavailable custom initializer storage.

Returns:
HTTPException: A service-unavailable response without SDK details.
"""
return HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Custom initializer storage is temporarily unavailable",
)


def _configured_initializers(request: Request) -> list[ConfiguredInitializerSetting]:
"""
Read the initializer list captured from ``.pyrit_conf`` at backend startup.
Expand Down Expand Up @@ -138,10 +124,7 @@ async def list_custom_initializers(request: Request) -> CustomInitializerListRes
CustomInitializerListResponse: The configured source and stored scripts.
"""
_check_custom_initializers_allowed(request)
try:
return await get_initializer_service().list_custom_initializers_async()
except AzureError as exc:
raise _custom_storage_unavailable() from exc
return await get_initializer_service().list_custom_initializers_async()


@router.get(
Expand Down Expand Up @@ -205,8 +188,6 @@ async def register_initializer( # pyrit-async-suffix-exempt

try:
return await service.register_initializer_async(name=body.name, script_content=body.script_content)
except AzureError as exc:
raise _custom_storage_unavailable() from exc
except ValueError as e:
detail = str(e)
if "already registered" in detail:
Expand Down Expand Up @@ -243,8 +224,6 @@ async def unregister_initializer( # pyrit-async-suffix-exempt

try:
await service.unregister_initializer_async(initializer_name=initializer_name)
except AzureError as exc:
raise _custom_storage_unavailable() from exc
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
Expand Down
29 changes: 1 addition & 28 deletions pyrit/backend/routes/scenario_presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@

from typing import Annotated

from azure.core.exceptions import AzureError
from fastapi import APIRouter, Depends, HTTPException, Query, status

from pyrit.backend.middleware.auth import current_user_name, require_admin
Expand All @@ -38,19 +37,6 @@
router = APIRouter(prefix="/scenario-presets", tags=["scenario-presets"])


def _storage_unavailable() -> HTTPException:
"""
Create a sanitized response for unavailable preset storage.

Returns:
HTTPException: A service-unavailable response without SDK details.
"""
return HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Scenario preset storage is temporarily unavailable",
)


def _not_found(name: str) -> HTTPException:
"""
Create a not-found response for a missing preset.
Expand Down Expand Up @@ -79,8 +65,6 @@ async def _load_preset_or_404_async(name: str) -> ScenarioPresetResponse:
"""
try:
preset = await get_scenario_preset_service().get_preset_async(name=name)
except AzureError as exc:
raise _storage_unavailable() from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None

Expand All @@ -106,10 +90,7 @@ async def list_scenario_presets( # pyrit-async-suffix-exempt
Returns:
ScenarioPresetListResponse: The configured source and the stored presets.
"""
try:
return await get_scenario_preset_service().list_presets_async(include_estimates=include_estimates)
except AzureError as exc:
raise _storage_unavailable() from exc
return await get_scenario_preset_service().list_presets_async(include_estimates=include_estimates)


@router.get(
Expand Down Expand Up @@ -177,8 +158,6 @@ async def create_scenario_preset( # pyrit-async-suffix-exempt
status_code=status.HTTP_409_CONFLICT,
detail=f"Scenario preset '{preset.name}' already exists",
) from exc
except AzureError as exc:
raise _storage_unavailable() from exc


@router.put(
Expand Down Expand Up @@ -239,8 +218,6 @@ async def update_scenario_preset( # pyrit-async-suffix-exempt
status_code=status.HTTP_409_CONFLICT,
detail=f"Scenario preset '{name}' changed since it was read; re-read it and retry",
) from exc
except AzureError as exc:
raise _storage_unavailable() from exc


@router.delete(
Expand Down Expand Up @@ -278,8 +255,6 @@ async def delete_scenario_preset( # pyrit-async-suffix-exempt
status_code=status.HTTP_409_CONFLICT,
detail=f"Scenario preset '{name}' changed since it was read; re-read it and retry",
) from None
except (AzureError, TimeoutError) as exc:
raise _storage_unavailable() from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None

Expand Down Expand Up @@ -329,8 +304,6 @@ async def resolve_scenario_preset( # pyrit-async-suffix-exempt
status_code=status.HTTP_409_CONFLICT,
detail=f"Scenario preset '{name}' changed since it was read; re-read it and retry",
) from None
except AzureError as exc:
raise _storage_unavailable() from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None

Expand Down
31 changes: 1 addition & 30 deletions pyrit/backend/routes/scenario_suites.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from typing import Annotated
from uuid import UUID

from azure.core.exceptions import AzureError
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status

from pyrit.backend.middleware.auth import current_user_name, require_admin
Expand Down Expand Up @@ -37,19 +36,6 @@
router = APIRouter(prefix="/scenario-suites", tags=["scenario-suites"])


def _storage_unavailable() -> HTTPException:
"""
Create a sanitized response for unavailable suite storage.

Returns:
HTTPException: A service-unavailable response without SDK details.
"""
return HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Scenario suite storage is temporarily unavailable",
)


def _not_found(name: str) -> HTTPException:
"""
Create a not-found response for a missing suite.
Expand All @@ -72,8 +58,6 @@ async def _load_suite_or_404_async(name: str) -> ScenarioSuiteResponse:
"""
try:
suite = await get_scenario_suite_service().get_suite_async(name=name)
except AzureError as exc:
raise _storage_unavailable() from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None

Expand All @@ -90,10 +74,7 @@ async def list_scenario_suites() -> ScenarioSuiteListResponse: # pyrit-async-su
Returns:
ScenarioSuiteListResponse: Suites from both origins with current entry statuses.
"""
try:
return await get_scenario_suite_service().list_suites_async()
except AzureError as exc:
raise _storage_unavailable() from exc
return await get_scenario_suite_service().list_suites_async()


@router.post("/{name}/readiness", response_model=ScenarioReadiness)
Expand All @@ -116,8 +97,6 @@ async def get_scenario_suite_readiness_async(
raise HTTPException(status_code=409, detail=str(exc)) from None
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from None
except AzureError as exc:
raise _storage_unavailable() from exc


@router.get(
Expand Down Expand Up @@ -211,8 +190,6 @@ async def launch_scenario_suite( # pyrit-async-suffix-exempt
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None
except ScenarioSuiteExecutionCapacityError as exc:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(exc)) from None
except AzureError as exc:
raise _storage_unavailable() from exc
except RuntimeError as exc:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from None

Expand Down Expand Up @@ -269,8 +246,6 @@ async def create_scenario_suite( # pyrit-async-suffix-exempt
status_code=status.HTTP_409_CONFLICT,
detail=f"Scenario suite '{suite.name}' already exists",
) from exc
except AzureError as exc:
raise _storage_unavailable() from exc


@router.put(
Expand Down Expand Up @@ -319,8 +294,6 @@ async def update_scenario_suite( # pyrit-async-suffix-exempt
status_code=status.HTTP_409_CONFLICT,
detail=f"Scenario suite '{name}' changed since it was read; re-read it and retry",
) from exc
except AzureError as exc:
raise _storage_unavailable() from exc


@router.delete(
Expand Down Expand Up @@ -350,8 +323,6 @@ async def delete_scenario_suite( # pyrit-async-suffix-exempt
status_code=status.HTTP_409_CONFLICT,
detail=f"Scenario suite '{name}' changed since it was read; re-read it and retry",
) from None
except (AzureError, TimeoutError) as exc:
raise _storage_unavailable() from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None

Expand Down
12 changes: 4 additions & 8 deletions pyrit/backend/services/scenario_suite_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@
from functools import lru_cache
from typing import Literal

from azure.core.exceptions import AzureError

from pyrit.backend.models import (
ScenarioSuiteEntryStatus,
ScenarioSuiteListResponse,
Expand All @@ -25,6 +23,7 @@
ScenarioSuiteRegistryEntry,
)
from pyrit.registry import ScenarioPresetRegistry, ScenarioSuiteRegistry
from pyrit.registry.file_document_storage import DocumentStorageUnavailableError

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -217,12 +216,9 @@ def _resolve_preset_projection(
)

stored_version = registry.get_preset_version(name=preset_name)
except (AzureError, OSError):
logger.warning(
"Could not project scenario preset '%s' because preset storage is unavailable.",
preset_name,
exc_info=True,
)
except DocumentStorageUnavailableError as error:
# The message leaves out credentials; the error behind it, which a traceback would show, may not.
logger.warning(f"Could not project scenario preset '{preset_name}': {error}")
return _PresetProjection(
origin=None,
version=None,
Expand Down
11 changes: 5 additions & 6 deletions pyrit/registry/components/initializer_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,7 @@ def register_from_content(self, *, name: str, script_content: str) -> str:
ValueError: If the source cannot be compiled, does not
define a concrete ``PyRITInitializer`` subclass, or
*name* collides with an existing entry.
DocumentStorageUnavailableError: If the script storage can't be written.
"""
self._ensure_discovered()

Expand All @@ -429,11 +430,7 @@ def register_from_content(self, *, name: str, script_content: str) -> str:
raise ValueError(f"Initializer '{name}' is already registered. Unregister it first to replace it.")

discovered = self._load_custom_initializer_class(name=name, script_content=script_content)
storage = self._get_custom_storage()
try:
storage.save_script(name=name, content=script_content)
except OSError as error:
raise ValueError(f"Failed to write initializer script: {error}") from error
self._get_custom_storage().save_script(name=name, content=script_content)

self.register_class(discovered, name=name)
logger.info(f"Registered custom initializer: {name} ({discovered.__name__})")
Expand Down Expand Up @@ -484,10 +481,12 @@ def unregister_and_cleanup(self, name: str) -> None:
Raises:
KeyError: If no stored custom initializer has the requested name.
ValueError: If the name refers to a built-in initializer.
DocumentStorageUnavailableError: If the script storage can't be used.
"""
self._ensure_discovered()
storage = self._get_custom_storage()
stored_names = storage.list_scripts().keys()
# A script that can't be read or decoded is still deleted, so the API can clean it up.
stored_names = sorted(storage.list_script_names())
if name not in stored_names:
if name in self._builtin_names:
raise ValueError(f"Cannot remove built-in initializer '{name}'.")
Expand Down
10 changes: 10 additions & 0 deletions pyrit/registry/custom_initializer_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@ def list_scripts(self) -> dict[str, str]:
logger.warning(f"Skipping stored initializer '{name}': it is not valid UTF-8 text.")
return scripts

def list_script_names(self) -> set[str]:
"""
List the names of every stored script, including ones that can't be read or decoded.

Returns:
set[str]: The registry names of the stored scripts.
"""
listing = self._list_documents_with_issues()
return set(listing.documents) | {unreadable.name for unreadable in listing.unreadable}

def save_script(self, *, name: str, content: str) -> None:
"""Persist one custom initializer script."""
self._save_document(name=name, content=content.encode("utf-8"))
Expand Down
Loading
Loading