17 KiB
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 project’s PRD / requirements:
-
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
ActionResponseobjects must be centralized; individual actions should raiseGuideErrorsubclasses, 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.
- All non-trivial errors should be represented by a small set of typed exceptions rooted in a common base
-
Utilities Layer
-
Introduce a
utilspackage 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.
-
-
Direct GraphQL Operations
-
For some background tasks, it must be possible to operate directly against Raindrop’s GraphQL API instead of the browser:
- Example: fetch/update request status, add suppliers, update metadata, etc.
-
GraphQL interaction must be encapsulated in a dedicated
raindropservice 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).
-
-
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
SERVICEpersona or aservice_authsection).
-
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
detailsfor debugging.
- Machine-readable
-
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
GuideErrorand set a stablecode. - 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).
- Import the raw query strings from
-
Actions then call these operations instead of crafting GraphQL queries.
3.2 Config additions for GraphQL
In src/guide/app/core/config.py:
-
Extend
AppSettingswith: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
ActionResponsewith 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=SUCCESSanderror_*fields areNone. - When it fails,
status=ERRORanderror_code/message/detailsare populated from aGuideError.
4.2 FastAPI route error handling
In src/guide/app/api/routes/actions.py:
-
Ensure that
ActionResponseis the canonical shape returned for/actions/*. -
Either:
- Let
GuideErrorbubble up to the global FastAPI exception handlers, or - Catch
GuideErrorin the route, createActionResponse(status=ERROR, ...), and return that.
- Let
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):
-
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.
- Use
-
-
GraphQL-first for purely backend tasks
-
Background actions that don’t 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
DemoPersonafor context (or a service persona). - Use
raindrop.graphql.GraphQLClient+raindrop.operations.*.
- Get a
-
-
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 throughBrowserClient.
-
-
Error consistency
-
Whether the failure happens in CDP attachment, MFA login, or GraphQL:
- Lower-level code throws the appropriate
GuideErrorsubclass. - The route or job layer converts that into a consistent
ActionResponseor job status.
- Lower-level code throws the appropriate
-
6. Concrete TODOs for the Code Assistant
You can give your agent this checklist:
-
Add
src/guide/app/errors/{exceptions.py,handlers.py}and wire handlers intoapp.main’s FastAPI app. -
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.
-
Extend
core/config.pyandconfig/hosts.yamlas previously specified AND addraindrop_graphql_urltoAppSettings. -
Create
src/guide/app/raindrop/graphql.pyandsrc/guide/app/raindrop/operations/{intake.py,sourcing.py}using the patterns above. -
Create
src/guide/app/strings/graphql/{intake.py,sourcing.py}to store GraphQL query strings. -
Update
domain/models.pyto include error fields inActionResponseand ensure the routes use it consistently. -
Refactor existing (and future) code to:
- Use
GuideErrorsubclasses instead of generic exceptions where appropriate. - Avoid inline strings for UI selectors, demo text, or GraphQL queries.
- Use
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 PEP 604 unions (
str | None) and built-in generics (list[str],dict[str, str]); avoidtyping.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
Literalfor 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/guidegreen as a minimum sanity gate.