Troubleshooting¶
Common errors across every surface, with pointers to the deeper fix. If the symptom isn't here, kubectl logs / the bot logs are your friend — almost every failure path emits a structured JSON log line with enough context to bisect.
Authentication & authorization¶
401 Missing bearer token / 401 Invalid token on POST /chat¶
The HTTP front door (orrery_core.serving.server) requires a valid JWT bearer token whenever AUTH_ENABLED=true. Common causes:
- No Authorization header. Send
Authorization: Bearer <jwt>./healthzand/readyzare intentionally exempt. - Audience / issuer mismatch. The token's
audandissclaims must matchJWT_AUDIENCE/JWT_ISSUERbyte-for-byte. The server returns a generic 401 to avoid leaking which check failed — setLOG_LEVEL=DEBUGto see the underlying PyJWT message. - Token expired.
expis mandatory; the default clock-skew leeway is 30s. NTP-drift is the right fix, not raisingJWT_LEEWAY_SECONDS. - Stale JWKS cache (RS256). Public keys are cached for 10 minutes per pod. If the IdP just rotated, restart the pod or wait for the next refresh.
JWT_SECRETmismatch (HS256). The verifying secret must match what your gateway uses to sign. If you set it viasecretsVolume, confirmORRERY_SECRETS_DIR/JWT_SECRETexists in the pod (kubectl exec ... -- ls /var/run/secrets/orrery).
Full setup recipes: Security & auth.
JWT_SECRET is required when JWT_ALGORITHM=HS256 at startup¶
The chart was rendered with auth.enabled=true but no secret reached the pod. Either:
- Set
JWT_SECRETunder the chart'ssecrets:block (Helm-managed), or - Add it to the
existingSecretyou reference, or - Enable
secretsVolumeand putJWT_SECRETin the mounted Secret.
create_app() validates the JWT config eagerly so you catch this at boot rather than on the first request.
"User has admin role in the JWT but is denied"¶
Three things to check, in order:
- The
JWT_ROLE_CLAIMenv var matches the claim your IdP actually puts roles in (Auth0 typically namespaces it ashttps://YOUR_API/roles). - The role value is one of
admin/operator/viewer— or one of the aliases (orrery-admin,orrery_operator). Custom names need an explicitRolePolicy. - The HTTP front door re-stamps
_authon everyPOST /chat, andAuthPlugin.before_agent_callbackre-appliesset_user_roleon every turn — so the role tracks the verified token live. If you're not going through/chat(e.g. you're inside the Slack or Google Chat handler), the role is whatever the transport set at session creation; restart the thread to pick up env-var changes.
401 Unauthorized: Invalid ID token (Google Chat)¶
GOOGLE_CHAT_AUDIENCEmust match the HTTP endpoint URL byte-for-byte, including the trailing slash. Google signs the JWT audience with the exact string you paste in the Chat API Configuration tab.- If your logs show a
service-NNN@gcp-sa-gsuiteaddons.iam.gserviceaccount.comidentity, add it toGOOGLE_CHAT_IDENTITIES. - Full details: Google Chat troubleshooting.
401 Token has wrong audience (Google Chat)¶
For HTTP-endpoint Chat apps, the audience is always the endpoint URL, never the project number. See Google Chat setup step 2.
403 Forbidden: ACCESS_TOKEN_SCOPE_INSUFFICIENT (Google Chat async replies)¶
- The outbound credential is missing the
chat.botscope. - Fix: set
GOOGLE_CHAT_SERVICE_ACCOUNT_FILEto a service-account JSON key. User ADC fromgcloud auth application-default logincannot supply this scope — it's restricted to app authentication. - Full details: Google Chat authentication.
Error 400: invalid_scope when running gcloud auth application-default login¶
chat.bot is restricted and cannot be granted to user credentials. Don't try to work around this — use a service account key instead. See above.
"I set user_role: admin in the ADK Dev UI but I'm still denied"¶
You forgot the _role_set_by_server: true lock flag. Without it, ensure_default_role() resets user_role back to viewer on every turn. Full walk-through: Testing RBAC across surfaces.
"I changed SLACK_ADMIN_USERS / GOOGLE_CHAT_ADMIN_EMAILS but I'm still viewer"¶
The role is resolved once per thread, at session creation. Start a new thread — the existing one has the old role baked in.
"Access denied — but I expected a confirmation prompt"¶
RBAC runs before the confirmation gate by design (ADR-001 § Plugin execution order). Escalate the user's role first.
LLM provider errors¶
google.api_core.exceptions.PermissionDenied¶
Vertex AI calls need gcloud auth application-default login and the project ID set:
GOOGLE_GENAI_USE_VERTEXAI=FALSE and GOOGLE_API_KEY=…. 429 Resource exhausted / quota errors¶
Hot loop in the remediation subgraph? Check MAX_REMEDIATION_ITERATIONS (defaults to 3), enforced by verify_route in agents/orrery-assistant/orrery_assistant/remediation.py. For Gemini, enable context caching (CONTEXT_CACHE_MIN_LENGTH) — it reduces input tokens per call dramatically for tool-heavy agents.
LLM costs spike unexpectedly¶
Check the orrery_llm_tokens_total Prometheus counter and the cache hit rate. Common cause: caching is disabled (Gemini-only feature) or CONTEXT_CACHE_MIN_LENGTH is set too high to ever trigger. See Deployment → LLM costs.
Sessions & storage¶
Sessions not persisting across restarts¶
DATABASE_URLisn't being read. The startup logs should printUsing PostgreSQL session store: postgresql+asyncpg://...[REDACTED]@.... If they sayUsing in-memory session store, the env var isn't wired (check the Secret is mounted viaenvFromin K8s).- In-memory sessions are per-process and lost on restart. Use PostgreSQL for anything with >1 replica (SQLite is not supported).
DatabaseUnavailableError: PostgreSQL session store unavailable at startup¶
DATABASE_URL is set but the database can't be reached or used, so the process fails fast by design rather than silently degrading to an in-memory store (which would split sessions across replicas and lose them on restart). Check that the DB host/port is reachable from the pod (NetworkPolicy, wrong namespace, DB not up yet) and that the Postgres driver is installed (uv sync --extra postgres). The pod will keep CrashLoopBackOff-ing until Postgres is ready — which is the intended behavior. For local dev only, set ORRERY_DB_ALLOW_INMEMORY_FALLBACK=1 to fall back to in-memory instead of crashing.
Session not found (Google Chat)¶
The bot uses auto_create_session=True, so this shouldn't normally fire. If it does, confirm DATABASE_URL is valid (or unset, for the in-memory store). Note a misconfigured DATABASE_URL now fails fast at startup rather than silently running in-memory — see above.
Deployment¶
Pods crash-loop on startup¶
Usually one of: - Missing GOOGLE_API_KEY / ANTHROPIC_API_KEY — the LLM call fails and readiness times out. - DATABASE_URL points to a host the pod can't reach. Test from a debug pod: kubectl run -it --rm psql --image=postgres:16 -- psql $DATABASE_URL. - Missing Postgres driver — rebuild the image with uv sync --extra postgres (the provided Dockerfile does this by default).
Readiness probe flaps¶
The startup probe allows up to 60 seconds (12 × 5s). Slow cold starts usually come from LLM warm-up calls or blocking client initialization. Full guidance: Deployment → Readiness probe flaps.
make run-dev fails with "address already in use"¶
Both make run-dev (ADK Dev UI) and docker compose --profile demo up -d bind :8000. Run one or the other — docker compose down clears the demo.
Confirmation flow¶
Guarded tool runs without asking for confirmation¶
- Is the agent running under
default_plugins()?GuardrailsPluginhandles RBAC, but confirmation is wired at the agent level viabefore_tool_callback=require_confirmation(). If you're building a new agent, see Adding a new agent → Wiring. - Is the tool actually decorated?
@confirm("reason")and@destructive("reason")both attach the metadata the callback reads.
Confirmation loops / agent keeps asking¶
The confirmation key is args-hash + invocation-id. If the LLM retries with slightly different arguments, the key changes and the gate fires again. This is by design — it prevents "yes" being reused across different destructive operations.
Observability¶
No data on the Prometheus /metrics endpoint¶
MetricsPlugin registers the collector but does not auto-bind the HTTP server. The Slack and Google Chat bots call metrics_plugin.start_server() in their FastAPI lifespan; the persistent runner does it when ENABLE_METRICS_SERVER=true. For a custom integration, call it yourself — see Metrics Quick Start.
Circuit breaker always open for a specific tool¶
Check orrery_circuit_breaker_state{tool="<name>"} == 1 — the breaker opens after 5 failures in a row (default) and stays open for 60 seconds. If the underlying system is genuinely down, you'll see it flip back to half-open on the next probe.
Still stuck?¶
- Structured logs: every agent emits JSON to stdout.
docker logs -f orrery-assistant | jqis the fastest way to see what's happening. - Audit trail:
AuditPluginwrites one line per tool call with RBAC decisions, args (redacted), and latency. - File an issue: github.com/BAHALLA/orrery/issues.