1515from pydantic import BaseModel , Field , field_validator
1616
1717from src .api .security import RequireAuth
18+ from src .core .validation import is_safe_identifier
1819from src .knowledge .db import active_mirror_context
1920from src .knowledge .mirror import (
21+ CorruptMirrorError ,
2022 MirrorInfo ,
2123 create_mirror ,
2224 delete_mirror ,
2325 get_mirror ,
2426 get_mirror_db_path ,
25- is_safe_identifier ,
2627 list_mirrors ,
2728 refresh_mirror ,
2829)
@@ -93,6 +94,16 @@ class RefreshMirrorRequest(BaseModel):
9394 default = None , description = "Specific communities to refresh, or null for all"
9495 )
9596
97+ @field_validator ("community_ids" )
98+ @classmethod
99+ def validate_community_ids (cls , v : list [str ] | None ) -> list [str ] | None :
100+ if v is None :
101+ return v
102+ for cid in v :
103+ if not is_safe_identifier (cid ):
104+ raise ValueError (f"Invalid community ID: { cid !r} " )
105+ return list (dict .fromkeys (v ))
106+
96107
97108SyncType = Literal ["github" , "papers" , "docstrings" , "mailman" , "faq" , "beps" , "all" ]
98109
@@ -135,6 +146,12 @@ async def create_mirror_endpoint(
135146 )
136147 except ValueError as e :
137148 raise HTTPException (status_code = status .HTTP_400_BAD_REQUEST , detail = str (e ))
149+ except OSError as e :
150+ logger .error ("Failed to create mirror: %s" , e , exc_info = True )
151+ raise HTTPException (
152+ status_code = status .HTTP_500_INTERNAL_SERVER_ERROR ,
153+ detail = "Failed to create mirror due to a server filesystem error." ,
154+ ) from e
138155
139156 logger .info (
140157 "Mirror created: %s (communities=%s, owner=%s)" ,
@@ -161,7 +178,18 @@ async def get_mirror_endpoint(
161178 _auth : RequireAuth ,
162179) -> MirrorResponse :
163180 """Get metadata for a specific mirror."""
164- info = get_mirror (mirror_id )
181+ try :
182+ info = get_mirror (mirror_id )
183+ except ValueError :
184+ raise HTTPException (
185+ status_code = status .HTTP_400_BAD_REQUEST ,
186+ detail = f"Invalid mirror ID format: '{ mirror_id } '" ,
187+ )
188+ except CorruptMirrorError :
189+ raise HTTPException (
190+ status_code = status .HTTP_500_INTERNAL_SERVER_ERROR ,
191+ detail = f"Mirror '{ mirror_id } ' has corrupt metadata. Delete and recreate it." ,
192+ )
165193 if not info :
166194 raise HTTPException (
167195 status_code = status .HTTP_404_NOT_FOUND ,
@@ -205,6 +233,11 @@ async def refresh_mirror_endpoint(
205233 info = refresh_mirror (mirror_id , community_ids = body .community_ids )
206234 except ValueError as e :
207235 raise HTTPException (status_code = status .HTTP_400_BAD_REQUEST , detail = str (e ))
236+ except CorruptMirrorError :
237+ raise HTTPException (
238+ status_code = status .HTTP_500_INTERNAL_SERVER_ERROR ,
239+ detail = f"Mirror '{ mirror_id } ' has corrupt metadata. Delete and recreate it." ,
240+ )
208241
209242 logger .info ("Mirror refreshed via API: %s" , mirror_id )
210243 return MirrorResponse .from_info (info )
@@ -222,7 +255,18 @@ async def sync_mirror_endpoint(
222255 databases instead of production. Supports sync types: github, papers,
223256 docstrings, mailman, faq, beps, or all.
224257 """
225- info = get_mirror (mirror_id )
258+ try :
259+ info = get_mirror (mirror_id )
260+ except ValueError :
261+ raise HTTPException (
262+ status_code = status .HTTP_400_BAD_REQUEST ,
263+ detail = f"Invalid mirror ID format: '{ mirror_id } '" ,
264+ )
265+ except CorruptMirrorError :
266+ raise HTTPException (
267+ status_code = status .HTTP_500_INTERNAL_SERVER_ERROR ,
268+ detail = f"Mirror '{ mirror_id } ' has corrupt metadata. Delete and recreate it." ,
269+ )
226270 if not info :
227271 raise HTTPException (
228272 status_code = status .HTTP_404_NOT_FOUND ,
@@ -235,8 +279,9 @@ async def sync_mirror_endpoint(
235279 )
236280
237281 # Run sync in a thread with the mirror context explicitly copied.
238- # asyncio.to_thread copies ContextVars on Python 3.12+ but not 3.11,
239- # so we capture the context and run within it for compatibility.
282+ # We use run_in_executor with an explicit context copy instead of
283+ # asyncio.to_thread because to_thread only copies ContextVars
284+ # automatically on Python 3.12+.
240285 from src .api .scheduler import run_sync_now
241286
242287 ctx = contextvars .copy_context ()
@@ -246,14 +291,21 @@ def _run_sync_in_mirror() -> dict[str, int]:
246291 return run_sync_now (body .sync_type )
247292
248293 try :
249- results = await asyncio .get_event_loop ().run_in_executor (None , ctx .run , _run_sync_in_mirror )
294+ loop = asyncio .get_running_loop ()
295+ results = await loop .run_in_executor (None , ctx .run , _run_sync_in_mirror )
250296 total = sum (results .values ())
251297 return MirrorSyncResponse (
252298 message = f"Sync completed: { total } items synced into mirror { mirror_id } " ,
253299 items_synced = results ,
254300 )
255301 except ValueError as e :
256302 raise HTTPException (status_code = status .HTTP_400_BAD_REQUEST , detail = str (e )) from e
303+ except OSError as e :
304+ logger .error ("Mirror sync I/O error for %s: %s" , mirror_id , e , exc_info = True )
305+ raise HTTPException (
306+ status_code = status .HTTP_500_INTERNAL_SERVER_ERROR ,
307+ detail = f"Sync failed due to a filesystem error: { e } " ,
308+ ) from e
257309 except Exception as e :
258310 logger .error ("Mirror sync failed for %s: %s" , mirror_id , e , exc_info = True )
259311 raise HTTPException (
@@ -274,7 +326,18 @@ async def download_mirror_db(
274326 """
275327 from fastapi .responses import FileResponse
276328
277- info = get_mirror (mirror_id )
329+ try :
330+ info = get_mirror (mirror_id )
331+ except ValueError :
332+ raise HTTPException (
333+ status_code = status .HTTP_400_BAD_REQUEST ,
334+ detail = f"Invalid mirror ID format: '{ mirror_id } '" ,
335+ )
336+ except CorruptMirrorError :
337+ raise HTTPException (
338+ status_code = status .HTTP_500_INTERNAL_SERVER_ERROR ,
339+ detail = f"Mirror '{ mirror_id } ' has corrupt metadata. Delete and recreate it." ,
340+ )
278341 if not info :
279342 raise HTTPException (
280343 status_code = status .HTTP_404_NOT_FOUND ,
0 commit comments