Troubleshooting Guide
For: Developers and operators diagnosing issues with Axemere Gateway.
Table of Contents
- HTTP Status Codes and What They Mean
- Understanding Denial Reason Codes
- Health Check Steps
- Reading Gateway Logs
- Reading the Decision Trace
- Common Scenarios
- Request always returns 403 even with a valid API key
- Workload not found
- API key expired or revoked
- Rate limit keeps firing unexpectedly
- Streaming request hangs or returns empty
- Execution record shows zero tokens
- CP connection status shows "offline"
- Direct/proxy-mode request to Gemini or Perplexity returns a provider-side 400/401
- See also
HTTP Status Codes and What They Mean
400 Bad Request
The request body is missing required fields or is malformed.
Common causes:
| Symptom | Fix |
|---|---|
"missing schema" error | Add "schema": "mvgc.action_request.v2" to the request body |
"missing org_id" error | Add "org_id": "<your-org-id>" |
"missing workload_id" error | Add "workload_id": "<workload-id>" |
"missing action.type" error | Add "action": {"type": "ai.infer", ...} |
Check the error field in the JSON response body for the specific missing field.
401 Unauthorized
Authentication failed. In managed mode, every request requires a valid API key in the Authorization: Bearer <key> header.
Common causes:
| Symptom | Fix |
|---|---|
| Missing header | Add -H "Authorization: Bearer $API_KEY" |
| Wrong header format | Must be Bearer <key>, not Basic, Token, or raw key |
| Key does not exist | Verify the key was created for the correct org; list keys via the CP admin API |
| Key expired | Renew the key; check expires_at in CreateAPIKey response |
| Key revoked | Create a new key; revocation is permanent |
Confirm the key hash:
# Compute SHA-256 of the key (what the gateway stores): echo -n "$API_KEY" | sha256sum
Compare against what the CP has on record:
grpcurl -H "Authorization: Bearer $CP_ADMIN_TOKEN" \ -d '{"org_id": "'"$ORG_ID"'"}' \ $CP_GRPC_ADDR mvgc.v1.OrgService/ListAPIKeys | jq '.api_keys[] | {name, status, expires_at}'
202 Accepted — Approval Required
Not an error. A policy rule matched decision: require_approval. The response includes an approval_id. Poll for the decision:
curl -s "$GATEWAY_URL/v1/admin/approvals/$APPROVAL_ID" \ -H "MVGC-Admin-Token: $ADMIN_TOKEN" | jq '{status, decision}'
Once approved, re-submit the original request. See the approval workflows guide.
403 Forbidden
The request was received and authenticated, but the policy engine denied it. The response body always includes a decision_trace with reason_codes; read it before investigating further.
Common reason codes:
reason_codes value | Meaning |
|---|---|
workload_not_found | workload_id is not registered; register via PUT /v1/admin/workloads |
org_id_mismatch | The org_id in the body does not match the org associated with the API key |
target_not_allowed | The target host is not in the targets policy layer allowlist |
identity_denied | An identity layer rule explicitly denied the workload |
budget_usd_max_exceeded | Per-request cost cap exceeded; raise budget.usd_max or reduce max_tokens |
monthly_request_limit_exceeded | Org is over its monthly request limit and overage is disabled |
budget_exceeded | Per-project monthly budget cap reached |
delegation_scope_violation | Request action or target is outside the delegation token's allowed scope |
delegation_expired | Delegation token has expired |
delegation_depth_exceeded | Delegation chain exceeds the maximum depth of 3 |
quarantine_active | The workload is under active quarantine |
See Reading the Decision Trace for how to extract these fields.
429 Too Many Requests — Rate Limited
A rate_limit policy rule fired. The response includes a Retry-After header (in seconds).
# Check the rate limit and wait RETRY_AFTER=$(curl -sI -X POST "$GATEWAY_URL/v1/actions:execute" \ -H "Authorization: Bearer $API_KEY" \ ... | grep -i "retry-after" | awk '{print $2}') sleep "$RETRY_AFTER"
If the rate limit fires more aggressively than expected, check the requests_per_minute value in your policy bundle:
curl -s "$GATEWAY_URL/v1/admin/policies" \ -H "MVGC-Admin-Token: $ADMIN_TOKEN" | jq '.' | grep -A 5 "rate_limit"
500 Internal Server Error
Unexpected gateway error. Check gateway logs immediately:
# systemd sudo journalctl -u mvgc-gateway --since "5 minutes ago" | grep '"level":"ERROR"' # Docker Compose docker compose logs gateway --since 5m | grep '"level":"ERROR"'
Look for the request_id from your client (or the X-Request-ID response header) to correlate the log entry.
Understanding Denial Reason Codes
Every 403 response from the gateway includes a decision_trace field:
{ "decision": "deny", "reason": "target_not_allowed", "decision_trace": { "schema": "mvgc.decision_trace.v1", "decision": "deny", "reason_codes": ["target_not_allowed"], "evaluated_at": "2026-04-01T12:34:56Z", "attributes": { "policy_bundle_id": "default", "connector_id": "openai", "target_host": "api.openai.com", "workload_id": "wl-my-app" } } }
The reason_codes array maps to policy evaluation layers:
| Layer | Reason code prefix | What to check |
|---|---|---|
| identity | identity_denied, workload_not_found | rules[identity] in bundle; workload registration |
| delegation | delegation_* | Token expiry, scope, depth |
| targets | target_not_allowed | rules[targets] allowlist |
| budgets | budget_* | rules[budgets] caps; monthly usage |
| risk | risk_score_exceeded, quarantine_active | Risk thresholds, quarantine entries |
| org | org_id_mismatch, monthly_request_limit_exceeded | API key → org binding |
Health Check Steps
Gateway health
curl -s "$GATEWAY_URL/healthz" | jq .
Expected response:
{ "status": "ok", "version": "0.3.69", "node_id": "node-abc123", "cp_status": "connected", "connectors": [ {"id": "openai", "version": "1.0.0"}, {"id": "anthropic", "version": "1.0.0"}, {"id": "gemini", "version": "1.0.0"}, {"id": "azure_openai", "version": "1.0.0"}, {"id": "cohere", "version": "1.0.0"}, {"id": "generic_http", "version": "1.0.0"} ] }
Diagnose specific fields:
| Field | Value | What to check |
|---|---|---|
status | "degraded" | Check CP connectivity and Postgres connection |
cp_status | "offline" | See CP connection status shows "offline" |
connectors | missing entries | Connector not registered; check gateway startup logs |
Control Plane connectivity
For managed deployments, verify gRPC connectivity:
grpcurl -plaintext $CP_GRPC_ADDR list mvgc.v1.RegistryService
If this fails, check:
CP_GRPC_ADDRis reachable (firewall, VPC peering, DNS)MVGC_CP_TOKENon the gateway is valid- The gateway process is running: check
sudo systemctl status mvgc-gatewayordocker compose ps
Reading Gateway Logs
The gateway emits structured JSON logs (slog format). Each log line is a JSON object.
Finding a specific request
Every executed request generates a log entry tagged with request_id. If you passed a request_id in the body, search for it directly. If not, use the record_id from the response:
# systemd sudo journalctl -u mvgc-gateway --since "10 minutes ago" | grep '"record_id":"rec-abc123"' # Docker Compose docker compose logs gateway --since 10m | grep '"record_id":"rec-abc123"' # Log file grep '"record_id":"rec-abc123"' /var/log/mvgc/gateway.log
Log fields reference
| Field | Description |
|---|---|
level | INFO, WARN, ERROR |
msg | Human-readable log message |
record_id | Execution record ID |
request_id | Client-supplied or auto-generated request ID |
org_id | Organization ID |
workload_id | Workload ID |
decision | Policy decision: allow, deny, rate_limit, require_approval, quarantine |
connector_id | Which connector handled the request |
target_host | Upstream host |
cost_usd | Final cost (after response) |
tokens_in / tokens_out | Actual token counts |
err | Error detail (present on errors) |
Reading the Decision Trace
Every response from POST /v1/actions:execute includes a decision_trace object, regardless of whether the decision was allow or deny. For allowed requests:
curl -s -X POST "$GATEWAY_URL/v1/actions:execute" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{...}' | jq '{ decision: .decision, reason: .reason, trace: .decision_trace }'
For execution records (after the fact):
curl -s "$GATEWAY_URL/v1/records/$RECORD_ID" \ -H "Authorization: Bearer $API_KEY" | jq '.decision_trace'
The attributes map in decision_trace contains the values that were active at evaluation time; useful for verifying that the correct policy bundle, credential, and connector were selected.
Common Scenarios
Request always returns 403 even with a valid API key
- Check the decision trace: the
reason_codesarray pinpoints the failing layer. - Verify workload registration:
curl -s "$GATEWAY_URL/v1/admin/workloads" -H "MVGC-Admin-Token: $ADMIN_TOKEN" | jq '.workloads[] | .workload_id' - Verify target allowlist: confirm
api.openai.com(or your target) appears in thetargetspolicy layer. - Check org_id: the
org_idin the request body must match the org of the API key. Managed gateway enforces this strictly.
Workload not found
# Register the workload: curl -s -X PUT "$GATEWAY_URL/v1/admin/workloads" \ -H "MVGC-Admin-Token: $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"workload_id": "wl-my-app", "org_id": "'"$ORG_ID"'", "name": "My App"}' # Confirm: curl -s "$GATEWAY_URL/v1/admin/workloads" \ -H "MVGC-Admin-Token: $ADMIN_TOKEN" | jq '.workloads[] | select(.workload_id == "wl-my-app")'
API key expired or revoked
Revocation is permanent; you must create a new key:
grpcurl -H "Authorization: Bearer $CP_ADMIN_TOKEN" \ -d '{"org_id": "'"$ORG_ID"'", "name": "replacement-key", "expires_at": "2027-01-01T00:00:00Z"}' \ $CP_GRPC_ADDR mvgc.v1.OrgService/CreateAPIKey
Rate limit keeps firing unexpectedly
The rate limiter uses a token bucket per (org_id, workload_id) pair. If you have multiple applications sharing a workload, their combined rate counts against the single bucket. Solutions:
- Register separate workloads per application.
- Increase
requests_per_minutein the policy bundle. - Add
caller_id-based sub-limits in the policy DSL.
Streaming request hangs or returns empty
For SSE streaming ("stream": true in action.params):
- Verify the provider supports streaming for the requested model.
- Confirm the client reads SSE chunks as they arrive (do not buffer the full response before parsing).
- Check that
Content-Type: text/event-streamappears in the response headers:curl -v -X POST "$GATEWAY_URL/v1/actions:execute" ... 2>&1 | grep "content-type" - Streaming is exempt from
MVGC_CONNECTOR_TIMEOUT; increasing that variable will not help streaming 502s. Streaming responses run until the client disconnects; the gateway does not impose an upper time limit. If you are seeing502 {"error":"upstream request failed"}mid-stream, the most common causes are:- The upstream provider closed the connection (check provider status / rate limits).
- A reverse proxy or load balancer in front of the gateway has its own timeout set
(e.g. Cloud Run request timeout, nginx
proxy_read_timeout). Increase that value.
Execution record shows zero tokens
The gateway reads token counts from the upstream provider's response body. If the connector does not recognize the response format, it falls back to zero. Verify:
- The correct connector is selected (check
decision_trace.attributes.connector_id). - The model name is spelled correctly (the connector parses
usage.prompt_tokens/usage.completion_tokensfor OpenAI-compatible responses).
CP connection status shows "offline"
The OfflineDetector marks the CP offline after 3 consecutive gRPC errors. It does not automatically recover until the gateway restarts or a successful call completes.
- Verify
MVGC_CP_ADDRisgcp.cp.axemere.ai:9090and port 9090 outbound is not blocked - Check gateway logs for gRPC error details: look for
"offline"in the log output - If the CP is reachable, restart the gateway to reset the detector:
- systemd:
sudo systemctl restart mvgc-gateway - Docker Compose:
docker compose restart gateway
- systemd:
Direct/proxy-mode request to Gemini or Perplexity returns a provider-side 400/401
In transparent proxy mode, the gateway does no path translation; whatever path your client sends after the /proxy/{provider}/ prefix is forwarded verbatim upstream. If that path or auth header doesn't match the provider's actual convention, the provider itself rejects the request with a 400 or 401 that gives no hint the real problem is "wrong endpoint." This looks identical to a credential problem, so check the path/header combo before assuming the stored credential is bad:
| Provider | Correct path | Auth header |
|---|---|---|
| Perplexity (OpenAI-compat) | /chat/completions: no /v1 prefix, unlike OpenAI and most other openai_compat providers | Authorization: Bearer |
| Gemini native | /v1beta/models/{model}:generateContent | x-goog-api-key (not Bearer) |
| Gemini OpenAI-compat | /v1beta/openai/chat/completions | Authorization: Bearer (does not accept x-goog-api-key) |
See Gemini SDK and Perplexity for full base-URL examples of each.
Confirm what actually went out: gateways on v0.65.7+ echo an X-MVGC-Upstream-Path response header showing the exact path the request was dispatched to upstream; compare it against the provider's documented path to confirm a mismatch instead of guessing. This header is currently available on dev deployments only; it has not yet been promoted to staging or production.
See also
- Admin API Reference
- Developer Integration Guide: request schema, response codes
- FAQ: policy, billing, and proxy questions
- Approval workflows
- Operations guide: log rotation, metrics, SIEM export