Repository navigation
Troubleshooting
Failure modes visible in the code, with the text you will actually see.
RuntimeError: Refusing to start: DISABLE_AUTH=true with a production-like
configuration (APP_ENV=production, secrets_configured=True).
DISABLE_AUTH is a development-only escape hatch and the startup guard turns
it into a loud deploy failure rather than a silent fleet-wide security hole. A
deployment counts as production-like when APP_ENV names prod or staging
or when REPORTMATE_PASSPHRASE / API_INTERNAL_SECRET is set. Set
APP_ENV=development to use it locally, or unset DISABLE_AUTH.
[STARTUP] Migrations failed: ...
Startup logs this and continues. The app will serve reads against whatever
schema exists, so a healthy container does not mean a migrated schema — check
for this line after every deploy. /api/v1/health/ready reports whether the
database is reachable, not whether it is at head.
{"error":"Unauthorized","detail":"Authentication required. Supply X-API-Key (per-client), X-Client-Passphrase (clients), X-Internal-Secret (internal). See /api/v1/auth/config.","status_code":401}
No credential was presented. When OIDC is on, the list starts with
Authorization: Bearer (OIDC sign-in). Only /api/v1/health,
/api/v1/health/live, /api/v1/health/ready, and /api/v1/auth/config are
anonymous; the last tells a client which credentials the deployment accepts.
{"error":"Internal server error","detail":"..."}
with [ERR] REPORTMATE_PASSPHRASE not configured but client attempted passphrase auth in the log means the caller sent a passphrase and the server
has none configured. This is a 500 because it is a server misconfiguration,
not a bad credential.
{"error":"Forbidden","detail":"This credential lacks the required 'admin' scope.","status_code":403}
The scope is derived from the request, not the route: any path containing
/admin needs admin, POST needs ingest, PUT/PATCH/DELETE need
admin, everything else needs read. Mint a key with the scope you need —
key scopes cannot be edited, only replaced.
On the settings endpoints you may instead see:
{"error":"Forbidden","detail":"This endpoint requires internal service authentication","status_code":403}
PUT /api/v1/settings and GET /api/v1/settings/inventory/discover require
the authentication method to be internal_secret. A fleet passphrase holds
the admin scope and is still refused here on purpose — the fleet credential
must not be able to rewrite org settings or enumerate inventory values.
{"error":"Too many requests","detail":"Rate limit exceeded: 3000 per minute per address","status_code":429}
per address means the address bucket, and behind a proxy that almost always
means X-Forwarded-For is not reaching the app — every device then shares one
bucket keyed on the ingress address. Fix the proxy first; raising
RATE_LIMIT_IP_PER_MINUTE only moves the cliff.
per device means one machine is genuinely check-in looping. Throttled ingest
POSTs are recorded as throttle / rate_limited in
GET /api/v1/events/failures, so you can see which serial.
Note that the limiter counts in process memory per replica, so the effective fleet-wide ceiling is the configured value times the replica count.
The database is likely fine and the pool is not. Endpoints run in Starlette's
threadpool and block waiting for a connection; with the pool ceiling below the
threadpool size, a burst of slow bulk requests holds every connection and
queues everything else past the dashboard's own abort. Raise DB_POOL_MAX
toward the threadpool size (default 40) rather than assuming a slow query.
A query that genuinely runs long is cut off by the per-connection
statement_timeout of 120 seconds; the socket timeout is 130 so the server,
not the socket, is the arbiter.
This is what GET /api/v1/events/failures exists for. Query it by serial:
curl -H "X-API-Key: $KEY" \
"https://api.example.com/api/v1/events/failures?serial=WS-0001&outcome=all&hours=168"
Common reasons and what each means:
| Reason | What happened |
|---|---|
missing_credentials |
The agent has no passphrase or API key configured. |
invalid_passphrase / invalid_api_key
|
The agent has the wrong one, or you retired a passphrase entry too early. |
sentinel_serial |
The machine's BIOS reports a placeholder serial such as To Be Filled By O.E.M.
|
serial_equals_hostname |
The agent sent its hostname where the hardware serial belongs. |
upload_aborted / empty_body
|
The upload died in flight. If the outcome is retried, the client's retry landed and nothing is wrong. |
malformed_json |
Read the transport note in detail — a received well below declared is a truncated upload, not a serializer bug. |
rate_limited |
See above. |
usage_write_failed |
The check-in succeeded but its usage rows were lost. |
usage_client_too_old |
The check-in was stored, but the agent build is older than the usage floor, so its usage rows were dropped. Update the agent. |
Also check outcome. The default view is rejected — turned away with
nothing since. retried rows are dropped uploads whose retry landed and are
not a problem; accepted rows are check-ins that got in but carried a client
defect worth watching.
[ROTATION] Deprecated passphrase (position 2 of 2) accepted from ... (IP: ...).
This client has not picked up the current passphrase; do not retire that
entry until these stop.
REPORTMATE_PASSPHRASE is a comma-separated list; the first entry is current
and later ones are deprecated-but-accepted. Watching this warning fall silent
is how you know the fleet has finished rolling over and the old entry is safe
to remove.
The dashboard sums the precomputed cimian_errors / cimian_warnings /
munki_errors / munki_warnings columns on installs; /installs/full
reads the JSONB. Ingest keeps them in step, and the clear-errors admin
endpoint recomputes them in the same statement it rewrites the JSONB. If they
have drifted, or the classifier has changed since the rows were stored, run
POST /api/v1/admin/installs/reclassify — not an edit to the JSONB.
Two other gaps look like this. A device an inventory reports as in storage
is left out of the dashboard's counts but still appears in /installs/full.
And a module row whose device row is gone appears in neither, because every
fleet query joins back to devices; GET /api/v1/admin/orphans reports those
and what they would have added to the tiles.
Check installs.collectedAt on /api/v1/installs/full. The installs module
only refreshes after a managed-software session that did work, so it can be
older than the device's lastSeen. A run's events are replaced by the next
run's, so the error goes when the next installs module arrives.
A run that failed only because the device was offline does not show as an
error at all: transient network failures are suppressed at ingest and kept
under transientProblems. See Ingest-Pipeline.
Read responses are cached in process for 15–120 seconds depending on the endpoint, and the ingest path deliberately does not invalidate them. With several replicas, two consecutive requests can be served results a TTL apart. Admin, settings, and inventory-state writes do invalidate, so those are visible on the next read.
{"error":"Internal server error","detail":"Internal server error. Reference: 9f2c41ab7d03","reference":"9f2c41ab7d03","status_code":500}
Server-error detail is masked because it can carry SQL, connection strings, or
stack fragments. The reference matches the X-Request-ID response header and
the server log line — grep the container logs for it. If the 500 was on the
ingest path, it is also recorded in ingest_failures as server_error /
internal_error with the same reference in its detail.
{"url":null,"accessToken":null,"error":"EVENTS_CONNECTION not configured"}
or "error": "WebPubSub SDK not installed". Both are returned with HTTP 200
so the dashboard falls back to polling instead of erroring. Set
EVENTS_CONNECTION if you want the live stream.
The Dockerfile copies an explicit file list. A new top-level module that is
imported but not copied builds green and fails at startup.
tests/test_dockerfile.py catches this — if it is failing, add the COPY
line.
AGPL-3.0 — a commercial license is available. reportmate.app