Files
guide/docs/spec.md
2025-11-22 00:54:25 +00:00

17 KiB
Raw Blame History

Absolutely — this is a great moment to “lock in” your error + utilities + GraphQL story while the project is still small.

Below is an updated spec you can paste straight into your code assistant. It extends your current project state to cover:

  • Centralized error handling
  • Shared utilities
  • Direct GraphQL operations for background tasks

Spec Update: Errors, Utilities, and Direct GraphQL Operations

0. Current Layout (for context)

You currently have:

.
├── config
│   └── hosts.yaml
└── src
    └── guide
        ├── app
        │   ├── actions
        │   ├── api
        │   ├── browser
        │   ├── core
        │   ├── domain
        │   ├── strings
        │   ├── __init__.py
        │   ├── main.py
        ├── __init__.py
        └── main.py

Plus the more detailed subpackages already defined in the prior spec (auth, personas, etc.).


1. High-Level Requirements to Append to Project State

Append the following to the projects PRD / requirements:

  1. Centralized Error Model

    • All non-trivial errors should be represented by a small set of typed exceptions rooted in a common base GuideError.
    • Error mapping to HTTP responses and ActionResponse objects must be centralized; individual actions should raise GuideError subclasses, not build HTTP responses themselves.
    • Error codes must be stable, string-based enums (e.g., BROWSER_CONNECT_FAILED, GRAPHQL_ERROR, AUTH_MFA_FAILED) to make it easy for clients and MCP tools to reason about failures.
  2. Utilities Layer

    • Introduce a utils package for common cross-cutting concerns:

      • Correlation ID generation.
      • Timed execution & simple metrics.
      • Retry/backoff helpers for flaky operations (CDP attachment, GraphQL, MFA fetch).
      • Small helpers for safe env access, JSON handling, etc.
    • Actions, auth, browser, and GraphQL code must use these utilities rather than re-implementing ad-hoc logic.

  3. Direct GraphQL Operations

    • For some background tasks, it must be possible to operate directly against Raindrops GraphQL API instead of the browser:

      • Example: fetch/update request status, add suppliers, update metadata, etc.
    • GraphQL interaction must be encapsulated in a dedicated raindrop service layer:

      • A low-level GraphQL client abstraction.
      • Domain-specific operations (intake, sourcing, etc.) calling that client.
    • No raw HTTP or inline GraphQL queries are allowed in actions; they must go through this service layer (and queries/operation names must live in strings).

  4. Persona-Aware Backend Operations

    • GraphQL operations should be able to run as a persona when needed (e.g., Supplier vs Buyer), using persona-specific auth (tokens/cookies/headers).
    • If GraphQL uses a service account instead of persona-based auth, that must still be expressed explicitly in the persona or configuration (e.g., a special SERVICE persona or a service_auth section).
  5. Consistent Error Surfacing

    • Both browser-based actions and GraphQL-based background tasks must surface errors in a consistent structure:

      • Machine-readable error_code.
      • Human-readable message.
      • Optional details for debugging.
    • FastAPI routes must convert exceptions to this structure consistently.


2. New Packages & Files to Add

2.1 errors package

Create:

src/guide/app/errors/
  __init__.py
  exceptions.py
  handlers.py

exceptions.py

Define a common base and key subclasses:

from typing import Any, Optional

class GuideError(Exception):
    code: str = "UNKNOWN_ERROR"

    def __init__(self, message: str, *, details: Optional[dict[str, Any]] = None):
        super().__init__(message)
        self.message = message
        self.details = details or {}

class ConfigError(GuideError):
    code = "CONFIG_ERROR"

class BrowserConnectionError(GuideError):
    code = "BROWSER_CONNECT_FAILED"

class PersonaError(GuideError):
    code = "PERSONA_ERROR"

class AuthError(GuideError):
    code = "AUTH_ERROR"

class MfaError(GuideError):
    code = "AUTH_MFA_FAILED"

class ActionExecutionError(GuideError):
    code = "ACTION_EXECUTION_FAILED"

class GraphQLTransportError(GuideError):
    code = "GRAPHQL_TRANSPORT_ERROR"

class GraphQLOperationError(GuideError):
    code = "GRAPHQL_OPERATION_ERROR"

Requirements:

  • Any domain-specific error should subclass GuideError and set a stable code.
  • Lower layers (browser, auth, GraphQL, actions) raise these exceptions; they never construct FastAPI responses themselves.

handlers.py

Implement functions to integrate with FastAPI:

from fastapi import Request
from fastapi.responses import JSONResponse

from .exceptions import GuideError

def guide_error_handler(request: Request, exc: GuideError) -> JSONResponse:
    return JSONResponse(
        status_code=400,   # can refine based on exc.code later
        content={
            "status": "error",
            "error_code": exc.code,
            "message": exc.message,
            "details": exc.details or {},
        },
    )

def unhandled_error_handler(request: Request, exc: Exception) -> JSONResponse:
    return JSONResponse(
        status_code=500,
        content={
            "status": "error",
            "error_code": "UNHANDLED_EXCEPTION",
            "message": "An unexpected error occurred",
        },
    )

Then, in src/guide/app/main.py, register these handlers with the FastAPI app.


2.2 utils package

Create:

src/guide/app/utils/
  __init__.py
  ids.py
  timing.py
  retry.py
  env.py

ids.py

  • Provide correlation ID / job ID helpers:
import uuid

def new_correlation_id() -> str:
    return str(uuid.uuid4())

def new_job_id(prefix: str = "job") -> str:
    return f"{prefix}-{uuid.uuid4()}"

timing.py

  • Provide a decorator/context manager for timing:
import time
from collections.abc import Callable
from functools import wraps
from typing import TypeVar

T = TypeVar("T")

def timed(operation: str, logger) -> Callable[[Callable[..., T]], Callable[..., T]]:
    def decorator(fn: Callable[..., T]) -> Callable[..., T]:
        @wraps(fn)
        def wrapper(*args, **kwargs) -> T:
            start = time.perf_counter()
            try:
                return fn(*args, **kwargs)
            finally:
                duration_ms = (time.perf_counter() - start) * 1000.0
                logger.info("operation=%s duration_ms=%.2f", operation, duration_ms)
        return wrapper
    return decorator

retry.py

  • Provide a simple, configurable retry helper for flaky operations (CDP attach, GraphQL, MFA fetch):
import time
from collections.abc import Callable
from typing import TypeVar

T = TypeVar("T")

def retry(
    fn: Callable[[], T],
    *,
    retries: int = 3,
    delay_seconds: float = 0.5,
    backoff_factor: float = 2.0,
    on_error: Callable[[Exception, int], None] | None = None,
) -> T:
    attempt = 0
    current_delay = delay_seconds
    while True:
        try:
            return fn()
        except Exception as exc:
            attempt += 1
            if attempt > retries:
                raise
            if on_error:
                on_error(exc, attempt)
            time.sleep(current_delay)
            current_delay *= backoff_factor

Actions, browser client, and GraphQL client can use this instead of custom loops.

env.py

  • Safe access to env vars (and central place to throw ConfigError):
import os
from ..errors import ConfigError

def require_env(name: str) -> str:
    value = os.getenv(name)
    if not value:
        raise ConfigError(f"Missing required environment variable {name}")
    return value

3. GraphQL Integration (Direct API Operations)

3.1 New raindrop service package

Create:

src/guide/app/raindrop/
  __init__.py
  graphql.py
  operations/
    __init__.py
    intake.py
    sourcing.py

graphql.py low-level client

Responsibilities:

  • Build and send GraphQL HTTP requests.
  • Attach appropriate auth (persona-based token, service token, etc.).
  • Parse responses and raise typed GraphQL* errors if needed.

Key design points:

  • Endpoint URL and any static headers come from core.config / env, not hardcoded.
  • Query strings and operation names live in strings (see 3.3).

Example shape:

from typing import Any
import httpx

from ..core.config import AppSettings
from ..errors import GraphQLTransportError, GraphQLOperationError
from ..personas.models import DemoPersona

class GraphQLClient:
    def __init__(self, settings: AppSettings):
        self._settings = settings

    async def execute(
        self,
        *,
        query: str,
        variables: dict[str, Any] | None,
        persona: DemoPersona | None,
        operation_name: str | None = None,
    ) -> dict[str, Any]:
        url = self._settings.raindrop_graphql_url  # from config/env
        headers = self._build_headers(persona)
        async with httpx.AsyncClient(timeout=10.0) as client:
            try:
                resp = await client.post(
                    url,
                    json={
                        "query": query,
                        "variables": variables or {},
                        "operationName": operation_name,
                    },
                    headers=headers,
                )
            except httpx.HTTPError as exc:
                raise GraphQLTransportError(
                    f"Transport error calling GraphQL: {exc}"
                ) from exc

        if resp.status_code >= 400:
            raise GraphQLTransportError(
                f"HTTP {resp.status_code} from GraphQL",
                details={"status_code": resp.status_code, "body": resp.text},
            )

        data = resp.json()
        if "errors" in data and data["errors"]:
            raise GraphQLOperationError(
                "GraphQL operation failed",
                details={"errors": data["errors"]},
            )
        return data.get("data", {})

    def _build_headers(self, persona: DemoPersona | None) -> dict[str, str]:
        # TODO: adapt based on persona; for now placeholder
        headers: dict[str, str] = {"Content-Type": "application/json"}
        # Option A: service account
        # Option B: persona-specific token from config/env
        return headers

You can add retries here using utils.retry.retry if desired.

operations/intake.py & operations/sourcing.py

  • Provide higher-level functions like:

    async def get_intake_request(client: GraphQLClient, persona: DemoPersona, request_id: str) -> IntakeRequest: ...
    async def create_sourcing_event(...): ...
    
  • They:

    • Import the raw query strings from strings.
    • Call GraphQLClient.execute.
    • Map the result into typed domain models (or simple dicts for now).
  • Actions then call these operations instead of crafting GraphQL queries.


3.2 Config additions for GraphQL

In src/guide/app/core/config.py:

  • Extend AppSettings with:

    class AppSettings(BaseSettings):
        # existing fields...
        raindrop_graphql_url: str  # e.g. https://tenant.raindropapp.com/graphql
    
  • This value should be set via env or a config file; never hardcoded.


3.3 strings for GraphQL queries

Add a new folder:

src/guide/app/strings/graphql/
  __init__.py
  intake.py
  sourcing.py

Each file contains string constants for queries/mutations and (optionally) operation names, for example:

# intake.py
GET_INTAKE_REQUEST = """
query GetIntakeRequest($id: ID!) {
  intakeRequest(id: $id) {
    id
    title
    status
    # ...
  }
}
"""

CREATE_INTAKE_REQUEST = """
mutation CreateIntakeRequest($input: IntakeRequestInput!) {
  createIntakeRequest(input: $input) {
    id
    # ...
  }
}
"""

Rules:

  • No inline GraphQL strings in actions or service code.
  • All GraphQL query strings and operation names live in strings/graphql/*.

4. Error Handling Integration Across Layers

4.1 Domain models & ActionResponse

In src/guide/app/domain/models.py:

  • Extend ActionResponse with error fields:
class ActionStatus(str, Enum):
    SUCCESS = "success"
    ERROR = "error"

class ActionResponse(BaseModel):
    status: ActionStatus
    action_id: str
    correlation_id: str
    result: dict[str, Any] | None = None
    error_code: str | None = None
    message: str | None = None
    details: dict[str, Any] | None = None
  • When an action completes successfully, status=SUCCESS and error_* fields are None.
  • When it fails, status=ERROR and error_code/message/details are populated from a GuideError.

4.2 FastAPI route error handling

In src/guide/app/api/routes/actions.py:

  • Ensure that ActionResponse is the canonical shape returned for /actions/*.

  • Either:

    • Let GuideError bubble up to the global FastAPI exception handlers, or
    • Catch GuideError in the route, create ActionResponse(status=ERROR, ...), and return that.

Pick one approach and stick with it; do not mix ad-hoc JSONResponses and ActionResponse everywhere.


5. How Actions Decide Between Browser vs GraphQL

Add these architectural guidelines (comments/docs for the assistant):

  1. Browser-first for interactive flows

    • Actions that are meant to be visible during demos (live UI clicking, form filling) should use the browser layer (CDP/Playwright).

    • They:

      • Use BrowserClient.get_demo_page(...).
      • Expect persona login to be handled by auth.session.ensure_persona.
  2. GraphQL-first for purely backend tasks

    • Background actions that dont need UI (e.g., polling statuses, “prepare data before demo”, mass-updating metadata) should call the GraphQL service instead of automating the UI.

    • These actions:

      • Get a DemoPersona for context (or a service persona).
      • Use raindrop.graphql.GraphQLClient + raindrop.operations.*.
  3. Hybrid actions

    • Some scenarios may need both:

      • Use GraphQL to create or prepare data.
      • Use browser actions to show the result in the demo.
    • Hybrid actions must still route GraphQL calls through raindrop.operations, and browser calls through BrowserClient.

  4. Error consistency

    • Whether the failure happens in CDP attachment, MFA login, or GraphQL:

      • Lower-level code throws the appropriate GuideError subclass.
      • The route or job layer converts that into a consistent ActionResponse or job status.

6. Concrete TODOs for the Code Assistant

You can give your agent this checklist:

  1. Add src/guide/app/errors/{exceptions.py,handlers.py} and wire handlers into app.mains FastAPI app.

  2. Add src/guide/app/utils/{ids.py,timing.py,retry.py,env.py} and update:

    • Logging to include correlation IDs where appropriate.
    • Any flaky CDP attach logic or future GraphQL calls to optionally use retry.
  3. Extend core/config.py and config/hosts.yaml as previously specified AND add raindrop_graphql_url to AppSettings.

  4. Create src/guide/app/raindrop/graphql.py and src/guide/app/raindrop/operations/{intake.py,sourcing.py} using the patterns above.

  5. Create src/guide/app/strings/graphql/{intake.py,sourcing.py} to store GraphQL query strings.

  6. Update domain/models.py to include error fields in ActionResponse and ensure the routes use it consistently.

  7. Refactor existing (and future) code to:

    • Use GuideError subclasses instead of generic exceptions where appropriate.
    • Avoid inline strings for UI selectors, demo text, or GraphQL queries.

You can drop this whole spec into your agent as the “next iteration instructions” and let it build out the errors, utils, and raindrop layers in a way that stays consistent with your existing modular architecture.


3. Engineering & Code Style Standards (add-on)

These apply to all new work in this repo.

Python & typing

  • Target Python 3.12+; no __future__ imports.
  • Use PEP604 unions (str | None) and built-in generics (list[str], dict[str, str]); avoid typing.List/Dict.
  • Do not use typing.Any; model shapes explicitly (e.g., dict[str, str], TypedDict, Protocol, or data classes/Pydantic models).
  • Avoid # type: ignore; prefer refactoring to satisfy type checkers.
  • Use Literal for constrained status/enum-like strings.

Imports and organization

  • Import order: stdlib → third-party → internal.
  • No wildcard imports. Keep line length ≤120 chars.
  • Keep domain packages segmented (e.g., app/actions/<domain>/, app/strings/<domain>/); add submodules rather than expanding single files.

Pydantic & settings

  • Use Pydantic v2 and pydantic-settings; no v1 compat shims.
  • All configurable values flow through AppSettings; never hardcode hosts, ports, or URLs in business logic.

Actions and strings

  • Actions must import selectors/text from app.strings; no inline UI strings/selectors.
  • Split actions once they exceed ~150 LoC to keep files focused.

Error handling and logging

  • Prefer structured errors ({"error": "CODE", "message": "...", "correlation_id": "..."}).
  • Log correlation IDs on entry/exit for actions and browser connections when available.

Testing & validation

  • Bias toward unit/contract tests; when testing Playwright-dependent logic, mock the browser rather than driving real CDP in CI.
  • Keep python -m compileall src/guide green as a minimum sanity gate.