xitzin 0.2.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: xitzin
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: A Gemini Application Framework
5
5
  Keywords: gemini,protocol,framework,async,geminispace
6
6
  Author: Alan Velasco
@@ -20,15 +20,18 @@ Classifier: Typing :: Typed
20
20
  Requires-Dist: jinja2>=3.1.0
21
21
  Requires-Dist: nauyaca>=0.3.2
22
22
  Requires-Dist: rich>=14.2.0
23
+ Requires-Dist: structlog>=25.5.0
23
24
  Requires-Dist: typing-extensions>=4.15.0
24
25
  Requires-Dist: sqlmodel>=0.0.22 ; extra == 'sqlmodel'
26
+ Requires-Dist: croniter>=1.0.0 ; extra == 'tasks'
25
27
  Requires-Python: >=3.10
26
- Project-URL: Changelog, https://xitzin.readthedocs.io/changelog/
27
- Project-URL: Documentation, https://xitzin.readthedocs.io
28
28
  Project-URL: Homepage, https://github.com/alanbato/xitzin
29
- Project-URL: Issues, https://github.com/alanbato/xitzin/issues
29
+ Project-URL: Documentation, https://xitzin.readthedocs.io
30
30
  Project-URL: Repository, https://github.com/alanbato/xitzin.git
31
+ Project-URL: Issues, https://github.com/alanbato/xitzin/issues
32
+ Project-URL: Changelog, https://xitzin.readthedocs.io/changelog/
31
33
  Provides-Extra: sqlmodel
34
+ Provides-Extra: tasks
32
35
  Description-Content-Type: text/markdown
33
36
 
34
37
  # Xitzin
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xitzin"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "A Gemini Application Framework"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -26,6 +26,7 @@ dependencies = [
26
26
  "jinja2>=3.1.0",
27
27
  "nauyaca>=0.3.2",
28
28
  "rich>=14.2.0",
29
+ "structlog>=25.5.0",
29
30
  "typing-extensions>=4.15.0",
30
31
  ]
31
32
 
@@ -33,6 +34,9 @@ dependencies = [
33
34
  sqlmodel = [
34
35
  "sqlmodel>=0.0.22",
35
36
  ]
37
+ tasks = [
38
+ "croniter>=1.0.0",
39
+ ]
36
40
 
37
41
  [project.urls]
38
42
  Homepage = "https://github.com/alanbato/xitzin"
@@ -47,6 +51,7 @@ build-backend = "uv_build"
47
51
 
48
52
  [dependency-groups]
49
53
  dev = [
54
+ "croniter>=1.0.0",
50
55
  "pre-commit>=4.5.1",
51
56
  "pytest>=9.0.2",
52
57
  "pytest-asyncio>=0.24.0",
@@ -22,6 +22,7 @@ Example:
22
22
 
23
23
  from .application import Xitzin
24
24
  from .cgi import CGIConfig, CGIHandler, CGIScript
25
+ from .scgi import SCGIApp, SCGIConfig, SCGIHandler
25
26
  from .exceptions import (
26
27
  BadRequest,
27
28
  CertificateNotAuthorized,
@@ -38,9 +39,10 @@ from .exceptions import (
38
39
  SensitiveInputRequired,
39
40
  ServerUnavailable,
40
41
  SlowDown,
42
+ TaskConfigurationError,
41
43
  TemporaryFailure,
42
44
  )
43
- from .requests import Request
45
+ from .requests import Request, TitanRequest
44
46
  from .responses import Input, Link, Redirect, Response
45
47
 
46
48
  __all__ = [
@@ -48,6 +50,7 @@ __all__ = [
48
50
  "Xitzin",
49
51
  # Request/Response
50
52
  "Request",
53
+ "TitanRequest",
51
54
  "Response",
52
55
  "Input",
53
56
  "Redirect",
@@ -56,6 +59,10 @@ __all__ = [
56
59
  "CGIConfig",
57
60
  "CGIHandler",
58
61
  "CGIScript",
62
+ # SCGI support
63
+ "SCGIApp",
64
+ "SCGIConfig",
65
+ "SCGIHandler",
59
66
  # Exceptions
60
67
  "GeminiException",
61
68
  "InputRequired",
@@ -73,6 +80,7 @@ __all__ = [
73
80
  "CertificateRequired",
74
81
  "CertificateNotAuthorized",
75
82
  "CertificateNotValid",
83
+ "TaskConfigurationError",
76
84
  ]
77
85
 
78
86
  __version__ = "0.1.0"
@@ -11,15 +11,22 @@ from pathlib import Path
11
11
  from typing import TYPE_CHECKING, Any, Callable
12
12
 
13
13
  from nauyaca.protocol.request import GeminiRequest
14
+ from nauyaca.protocol.request import TitanRequest as NauyacaTitanRequest
14
15
  from nauyaca.protocol.response import GeminiResponse
15
16
  from nauyaca.protocol.status import StatusCode
16
17
 
17
- from .exceptions import GeminiException, NotFound
18
- from .requests import Request
18
+ from .exceptions import (
19
+ CertificateRequired,
20
+ GeminiException,
21
+ NotFound,
22
+ TaskConfigurationError,
23
+ )
24
+ from .requests import Request, TitanRequest
19
25
  from .responses import Input, Redirect, convert_response
20
- from .routing import MountedRoute, Route, Router
26
+ from .routing import MountedRoute, Route, Router, TitanRoute
21
27
 
22
28
  if TYPE_CHECKING:
29
+ from .tasks import BackgroundTask
23
30
  from .templating import TemplateEngine
24
31
 
25
32
 
@@ -84,6 +91,8 @@ class Xitzin:
84
91
  self._startup_handlers: list[Callable[[], Any]] = []
85
92
  self._shutdown_handlers: list[Callable[[], Any]] = []
86
93
  self._middleware: list[Callable[..., Any]] = []
94
+ self._tasks: list[BackgroundTask] = []
95
+ self._task_handles: list[asyncio.Task[Any]] = []
87
96
 
88
97
  if templates_dir:
89
98
  self._init_templates(Path(templates_dir))
@@ -224,6 +233,45 @@ class Xitzin:
224
233
 
225
234
  return decorator
226
235
 
236
+ def titan(
237
+ self,
238
+ path: str,
239
+ *,
240
+ name: str | None = None,
241
+ auth_tokens: list[str] | None = None,
242
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
243
+ """Register a Titan upload handler.
244
+
245
+ Titan is the upload companion protocol to Gemini. This decorator
246
+ registers a handler for Titan upload requests.
247
+
248
+ Args:
249
+ path: URL path pattern (e.g., "/upload/{filename}").
250
+ name: Optional route name. Defaults to function name.
251
+ auth_tokens: List of valid authentication tokens. If provided,
252
+ requests without a valid token are rejected with status 60.
253
+
254
+ Returns:
255
+ Decorator function.
256
+
257
+ Example:
258
+ @app.titan("/upload/{filename}", auth_tokens=["secret123"])
259
+ def upload(request: TitanRequest, content: bytes,
260
+ mime_type: str, token: str | None, filename: str):
261
+ if request.is_delete():
262
+ Path(f"./uploads/{filename}").unlink()
263
+ return "# Deleted"
264
+ Path(f"./uploads/{filename}").write_bytes(content)
265
+ return "# Upload successful"
266
+ """
267
+
268
+ def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
269
+ route = TitanRoute(path, handler, name=name, auth_tokens=auth_tokens)
270
+ self._router.add_titan_route(route)
271
+ return handler
272
+
273
+ return decorator
274
+
227
275
  def mount(
228
276
  self,
229
277
  path: str,
@@ -288,6 +336,69 @@ class Xitzin:
288
336
  handler = CGIHandler(script_dir, config=config)
289
337
  self.mount(path, handler, name=name)
290
338
 
339
+ def scgi(
340
+ self,
341
+ path: str,
342
+ host: str | None = None,
343
+ port: int | None = None,
344
+ socket_path: Path | str | None = None,
345
+ *,
346
+ name: str | None = None,
347
+ timeout: float = 30.0,
348
+ app_state_keys: list[str] | None = None,
349
+ ) -> None:
350
+ """Mount an SCGI backend at a path prefix.
351
+
352
+ This is a convenience method that creates an SCGIHandler or SCGIApp
353
+ and mounts it. Exactly one of (host+port) or socket_path must be provided.
354
+
355
+ Args:
356
+ path: Mount point prefix (e.g., "/dynamic").
357
+ host: SCGI server hostname (for TCP connection).
358
+ port: SCGI server port (for TCP connection).
359
+ socket_path: Path to Unix socket (for local connection).
360
+ name: Optional name for the mount.
361
+ timeout: Maximum response wait time in seconds.
362
+ app_state_keys: App state keys to pass as XITZIN_* env vars.
363
+
364
+ Raises:
365
+ ValueError: If neither or both connection types are specified.
366
+
367
+ Example:
368
+ # TCP connection
369
+ app.scgi("/dynamic", host="127.0.0.1", port=4000, timeout=30)
370
+
371
+ # Unix socket connection
372
+ app.scgi("/dynamic", socket_path="/tmp/scgi.sock", timeout=30)
373
+ """
374
+ from .scgi import SCGIApp, SCGIConfig, SCGIHandler
375
+
376
+ # Validate parameters
377
+ tcp_specified = host is not None or port is not None
378
+ unix_specified = socket_path is not None
379
+
380
+ if tcp_specified and unix_specified:
381
+ msg = "Cannot specify both TCP (host/port) and Unix socket (socket_path)"
382
+ raise ValueError(msg)
383
+ if not tcp_specified and not unix_specified:
384
+ msg = "Must specify either TCP (host and port) or Unix socket (socket_path)"
385
+ raise ValueError(msg)
386
+ if tcp_specified and (host is None or port is None):
387
+ msg = "Both host and port must be specified for TCP connection"
388
+ raise ValueError(msg)
389
+
390
+ config = SCGIConfig(
391
+ timeout=timeout,
392
+ app_state_keys=app_state_keys or [],
393
+ )
394
+
395
+ if tcp_specified:
396
+ handler = SCGIHandler(host, port, config=config) # type: ignore[arg-type]
397
+ else:
398
+ handler = SCGIApp(socket_path, config=config) # type: ignore[arg-type]
399
+
400
+ self.mount(path, handler, name=name)
401
+
291
402
  def on_startup(self, handler: Callable[[], Any]) -> Callable[[], Any]:
292
403
  """Register a startup event handler.
293
404
 
@@ -336,6 +447,75 @@ class Xitzin:
336
447
  self._middleware.append(handler)
337
448
  return handler
338
449
 
450
+ def task(
451
+ self,
452
+ *,
453
+ interval: str | int | float | None = None,
454
+ cron: str | None = None,
455
+ ) -> Callable[[Callable[[], Any]], Callable[[], Any]]:
456
+ """Register a background task.
457
+
458
+ Tasks run continuously while the server is running. They are started
459
+ after startup handlers and stopped before shutdown handlers.
460
+
461
+ Args:
462
+ interval: Run every N seconds (int) or duration string ("1h", "30m", "1d").
463
+ cron: Cron expression string ("0 * * * *" runs hourly).
464
+ Requires croniter: pip install 'xitzin[tasks]'
465
+
466
+ Exactly one of interval or cron must be provided.
467
+
468
+ Returns:
469
+ Decorator function.
470
+
471
+ Raises:
472
+ TaskConfigurationError: If neither or both parameters provided,
473
+ or if cron is used but croniter is not installed.
474
+
475
+ Example:
476
+ @app.task(interval="1h")
477
+ async def cleanup():
478
+ await app.state.db.cleanup_old_records()
479
+
480
+ @app.task(cron="0 2 * * *") # 2 AM daily
481
+ def backup():
482
+ backup_database()
483
+ """
484
+ from .tasks import BackgroundTask, parse_interval
485
+
486
+ # Validate parameters
487
+ if interval is None and cron is None:
488
+ raise TaskConfigurationError("Either 'interval' or 'cron' must be provided")
489
+ if interval is not None and cron is not None:
490
+ raise TaskConfigurationError(
491
+ "Only one of 'interval' or 'cron' can be provided, not both"
492
+ )
493
+
494
+ # Check croniter availability
495
+ if cron is not None:
496
+ try:
497
+ from croniter import croniter as _ # noqa: F401
498
+ except ImportError:
499
+ raise TaskConfigurationError(
500
+ "croniter is required for cron tasks. "
501
+ "Install with: pip install 'xitzin[tasks]'"
502
+ ) from None
503
+
504
+ def decorator(handler: Callable[[], Any]) -> Callable[[], Any]:
505
+ # Parse interval if provided
506
+ parsed_interval = parse_interval(interval) if interval else None
507
+
508
+ task = BackgroundTask(
509
+ handler=handler,
510
+ interval=parsed_interval,
511
+ cron=cron,
512
+ name=getattr(handler, "__name__", "<anonymous>"),
513
+ )
514
+ self._tasks.append(task)
515
+ return handler
516
+
517
+ return decorator
518
+
339
519
  async def _run_startup(self) -> None:
340
520
  """Run all startup handlers."""
341
521
  for handler in self._startup_handlers:
@@ -352,6 +532,26 @@ class Xitzin:
352
532
  else:
353
533
  handler()
354
534
 
535
+ async def _run_tasks(self) -> None:
536
+ """Start all registered background tasks."""
537
+ from .tasks import run_cron_task, run_interval_task
538
+
539
+ for task in self._tasks:
540
+ if task.interval is not None:
541
+ handle = asyncio.create_task(run_interval_task(task))
542
+ else: # task.cron is not None
543
+ handle = asyncio.create_task(run_cron_task(task))
544
+ self._task_handles.append(handle)
545
+
546
+ async def _stop_tasks(self) -> None:
547
+ """Stop all running background tasks."""
548
+ for handle in self._task_handles:
549
+ handle.cancel()
550
+ # Wait for all tasks to finish cancelling
551
+ if self._task_handles:
552
+ await asyncio.gather(*self._task_handles, return_exceptions=True)
553
+ self._task_handles.clear()
554
+
355
555
  async def _handle_request(self, raw_request: GeminiRequest) -> GeminiResponse:
356
556
  """Handle an incoming request.
357
557
 
@@ -408,24 +608,69 @@ class Xitzin:
408
608
 
409
609
  except GeminiException as e:
410
610
  return GeminiResponse(status=e.status_code, meta=e.message)
411
- except Exception as e:
611
+ except Exception:
412
612
  # Log the error and return a generic failure
413
613
  import traceback
414
614
 
415
615
  traceback.print_exc()
416
616
  return GeminiResponse(
417
617
  status=StatusCode.TEMPORARY_FAILURE,
418
- meta=f"Internal error: {type(e).__name__}",
618
+ meta="Internal server error",
619
+ )
620
+
621
+ async def _handle_titan_request(
622
+ self, raw_request: NauyacaTitanRequest
623
+ ) -> GeminiResponse:
624
+ """Handle an incoming Titan upload request.
625
+
626
+ This is the Titan request processing logic, separate from Gemini.
627
+ """
628
+ request = TitanRequest(raw_request, self)
629
+
630
+ try:
631
+ # Match Titan route
632
+ match = self._router.match_titan(request.path)
633
+ if match is None:
634
+ raise NotFound(f"No Titan route matches: {request.path}")
635
+
636
+ route, params = match
637
+
638
+ # Validate auth token if required
639
+ if route.auth_tokens is not None:
640
+ if not request.token or request.token not in route.auth_tokens:
641
+ raise CertificateRequired("Valid authentication token required")
642
+
643
+ # Build middleware chain
644
+ async def call_handler(req: TitanRequest) -> GeminiResponse:
645
+ result = await route.call_handler(req, params)
646
+ return convert_response(result, req)
647
+
648
+ # Apply middleware (in reverse order so first registered runs first)
649
+ handler = call_handler
650
+ for mw in reversed(self._middleware):
651
+ handler = self._wrap_middleware(mw, handler)
652
+
653
+ return await handler(request)
654
+
655
+ except GeminiException as e:
656
+ return GeminiResponse(status=e.status_code, meta=e.message)
657
+ except Exception:
658
+ import traceback
659
+
660
+ traceback.print_exc()
661
+ return GeminiResponse(
662
+ status=StatusCode.TEMPORARY_FAILURE,
663
+ meta="Internal server error",
419
664
  )
420
665
 
421
666
  def _wrap_middleware(
422
667
  self,
423
668
  middleware: Callable[..., Any],
424
- next_handler: Callable[[Request], Any],
425
- ) -> Callable[[Request], Any]:
669
+ next_handler: Callable[..., Any],
670
+ ) -> Callable[..., Any]:
426
671
  """Wrap a handler with middleware."""
427
672
 
428
- async def wrapped(request: Request) -> GeminiResponse:
673
+ async def wrapped(request: Any) -> GeminiResponse:
429
674
  if asyncio.iscoroutinefunction(middleware):
430
675
  return await middleware(request, next_handler)
431
676
  return middleware(request, next_handler)
@@ -462,6 +707,9 @@ class Xitzin:
462
707
  # Run startup handlers
463
708
  await self._run_startup()
464
709
 
710
+ # Start background tasks
711
+ await self._run_tasks()
712
+
465
713
  try:
466
714
  # Create PyOpenSSL context (accepts any self-signed client cert)
467
715
  if certfile and keyfile:
@@ -501,11 +749,30 @@ class Xitzin:
501
749
  async def handle(request: GeminiRequest) -> GeminiResponse:
502
750
  return await self._handle_request(request)
503
751
 
752
+ # Create Titan upload handler if Titan routes are registered
753
+ upload_handler = None
754
+ if self._router.has_titan_routes():
755
+ from nauyaca.server.handler import UploadHandler
756
+
757
+ class XitzinUploadHandler(UploadHandler):
758
+ """Wrapper to route Titan uploads to Xitzin handlers."""
759
+
760
+ def __init__(self, app: "Xitzin") -> None:
761
+ self._app = app
762
+
763
+ async def handle_upload(
764
+ self, request: NauyacaTitanRequest
765
+ ) -> GeminiResponse:
766
+ return await self._app._handle_titan_request(request)
767
+
768
+ upload_handler = XitzinUploadHandler(self)
769
+ print("[Xitzin] Titan upload support enabled")
770
+
504
771
  # Use TLSServerProtocol for manual TLS handling
505
772
  # (supports self-signed client certs)
506
773
  def create_protocol() -> TLSServerProtocol:
507
774
  return TLSServerProtocol(
508
- lambda: GeminiServerProtocol(handle, None), # type: ignore[arg-type]
775
+ lambda: GeminiServerProtocol(handle, None, upload_handler),
509
776
  ssl_context,
510
777
  )
511
778
 
@@ -523,6 +790,8 @@ class Xitzin:
523
790
  await server.serve_forever()
524
791
 
525
792
  finally:
793
+ # Stop background tasks
794
+ await self._stop_tasks()
526
795
  await self._run_shutdown()
527
796
 
528
797
  def run(
@@ -6,6 +6,7 @@ Gemini client certificate authentication.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import hmac
9
10
  from dataclasses import dataclass
10
11
  from functools import wraps
11
12
  from typing import TYPE_CHECKING, Any, Callable
@@ -89,6 +90,27 @@ def require_certificate(handler: Callable[..., Any]) -> Callable[..., Any]:
89
90
  return wrapper
90
91
 
91
92
 
93
+ def _timing_safe_fingerprint_check(fingerprint: str, allowed: list[str]) -> bool:
94
+ """Check if fingerprint matches any allowed value using timing-safe comparison.
95
+
96
+ This prevents timing attacks that could reveal valid fingerprints.
97
+
98
+ Args:
99
+ fingerprint: The fingerprint to check.
100
+ allowed: List of allowed fingerprints.
101
+
102
+ Returns:
103
+ True if fingerprint matches any allowed value.
104
+ """
105
+ # Encode once for comparison
106
+ fp_bytes = fingerprint.encode("utf-8")
107
+
108
+ for allowed_fp in allowed:
109
+ if hmac.compare_digest(fp_bytes, allowed_fp.encode("utf-8")):
110
+ return True
111
+ return False
112
+
113
+
92
114
  def require_fingerprint(
93
115
  *allowed_fingerprints: str,
94
116
  ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
@@ -97,6 +119,8 @@ def require_fingerprint(
97
119
  If the client certificate fingerprint is not in the allowed list,
98
120
  returns status 61 (certificate not authorized).
99
121
 
122
+ Uses timing-safe comparison to prevent fingerprint enumeration attacks.
123
+
100
124
  Args:
101
125
  *allowed_fingerprints: SHA-256 fingerprints that are allowed.
102
126
 
@@ -111,7 +135,7 @@ def require_fingerprint(
111
135
  def admin_panel(request: Request):
112
136
  return "# Admin Panel"
113
137
  """
114
- allowed_set = set(allowed_fingerprints)
138
+ allowed_list = list(allowed_fingerprints)
115
139
 
116
140
  def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
117
141
  @wraps(handler)
@@ -119,7 +143,9 @@ def require_fingerprint(
119
143
  if not request.client_cert_fingerprint:
120
144
  raise CertificateRequired("Client certificate required")
121
145
 
122
- if request.client_cert_fingerprint not in allowed_set:
146
+ if not _timing_safe_fingerprint_check(
147
+ request.client_cert_fingerprint, allowed_list
148
+ ):
123
149
  raise CertificateNotAuthorized("Certificate not authorized")
124
150
 
125
151
  return handler(request, *args, **kwargs)
@@ -26,10 +26,13 @@ from dataclasses import dataclass, field
26
26
  from pathlib import Path
27
27
  from typing import TYPE_CHECKING
28
28
 
29
+ import structlog
29
30
  from nauyaca.protocol.response import GeminiResponse
30
31
 
31
32
  from .exceptions import BadRequest, CGIError, NotFound
32
33
 
34
+ logger = structlog.get_logger("xitzin.cgi")
35
+
33
36
  if TYPE_CHECKING:
34
37
  from .requests import Request
35
38
 
@@ -56,7 +59,7 @@ class CGIConfig:
56
59
  max_header_size: int = 8192
57
60
  streaming: bool = False
58
61
  check_execute_permission: bool = True
59
- inherit_environment: bool = True
62
+ inherit_environment: bool = False
60
63
  app_state_keys: list[str] = field(default_factory=list)
61
64
 
62
65
 
@@ -379,10 +382,14 @@ class CGIHandler:
379
382
 
380
383
  # Check exit code
381
384
  if process.returncode != 0:
382
- error_msg = stderr.decode("utf-8", errors="replace")[:200]
383
- if error_msg:
384
- raise CGIError(
385
- f"CGI script exited with code {process.returncode}: {error_msg}"
385
+ # Log stderr server-side but don't expose to client
386
+ if stderr:
387
+ error_detail = stderr.decode("utf-8", errors="replace")[:500]
388
+ logger.error(
389
+ "cgi_script_failed",
390
+ script=str(script_path),
391
+ returncode=process.returncode,
392
+ stderr=error_detail,
386
393
  )
387
394
  raise CGIError(f"CGI script exited with code {process.returncode}")
388
395
 
@@ -420,7 +427,7 @@ class CGIScript:
420
427
  *,
421
428
  timeout: float = 30.0,
422
429
  check_execute_permission: bool = True,
423
- inherit_environment: bool = True,
430
+ inherit_environment: bool = False,
424
431
  app_state_keys: list[str] | None = None,
425
432
  ) -> None:
426
433
  """Create a single-script CGI handler.
@@ -530,10 +537,14 @@ class CGIScript:
530
537
  ) from None
531
538
 
532
539
  if process.returncode != 0:
533
- error_msg = stderr.decode("utf-8", errors="replace")[:200]
534
- if error_msg:
535
- raise CGIError(
536
- f"CGI script exited with code {process.returncode}: {error_msg}"
540
+ # Log stderr server-side but don't expose to client
541
+ if stderr:
542
+ error_detail = stderr.decode("utf-8", errors="replace")[:500]
543
+ logger.error(
544
+ "cgi_script_failed",
545
+ script=str(self.script_path),
546
+ returncode=process.returncode,
547
+ stderr=error_detail,
537
548
  )
538
549
  raise CGIError(f"CGI script exited with code {process.returncode}")
539
550
 
@@ -136,3 +136,21 @@ class CertificateNotValid(GeminiException):
136
136
 
137
137
  status_code = 62
138
138
  default_message = "Certificate not valid"
139
+
140
+
141
+ # Application configuration errors
142
+ class TaskConfigurationError(Exception):
143
+ """Raised when a background task is misconfigured.
144
+
145
+ This typically indicates mutually exclusive parameters were provided,
146
+ or a required optional dependency is missing.
147
+
148
+ Example:
149
+ @app.task() # Error: neither interval nor cron provided
150
+ def my_task():
151
+ pass
152
+
153
+ @app.task(interval="1h", cron="* * * * *") # Error: both provided
154
+ def my_task():
155
+ pass
156
+ """