Menu ⌃

Errors & request IDs

Every API error carries a stable reason code and the request ID of the call that failed. Branch on the reason in code. Quote the request ID when you contact support.

What an error looks like

Errors use the Connect protocol: an HTTP status, a JSON body with a code and a human-readable message, and a google.rpc.ErrorInfo detail. The detail's domain is always metalhost.net, a fixed identifier, not a URL.

HTTP/2 401
x-request-id: 8ae81966-ffe7-4533-a5e4-ae5414427193
content-type: application/json

{
  "code": "unauthenticated",
  "message": "Authentication is required. Sign in, or send an API key as a Bearer token.",
  "details": [{
    "type": "google.rpc.ErrorInfo",
    "value": "Cg9VTkFVVEhF…",
    "debug": {
      "reason": "UNAUTHENTICATED",
      "domain": "metalhost.net",
      "metadata": { "request_id": "8ae81966-ffe7-4533-a5e4-ae5414427193" }
    }
  }]
}
  • reason: one of the codes below. Stable across releases.
  • metadata.request_id: the same value as the X-Request-ID response header, which is on every response, successful or not.
  • message: written for people. It may change at any time, so never match on it.

Reason codes

The aes.errors.v1.ErrorReason enum in the API reference lists these. ErrorInfo carries them without the ERROR_REASON_ prefix.

ReasonMeaning
INVALID_ARGUMENTThe request is not valid. ErrorInfo metadata may name the field.
UNAUTHENTICATEDMissing, expired or revoked credentials.
PERMISSION_DENIEDThe caller's role or key scope does not allow this action.
RESOURCE_NOT_FOUNDThe resource does not exist, or was deleted.
ALREADY_EXISTSA resource with that name already exists.
PRECONDITION_FAILEDNot possible in the resource's current state.
CONFLICTThe resource changed while the request ran. Re-read and retry with the new version.
RESOURCE_BUSYAnother operation is running on this resource. Retry when it finishes.
RESOURCE_EXHAUSTEDA limit was reached. Retry later.
QUOTA_EXCEEDEDA project quota limit. Request an increase.
TRUST_TIER_LIMITNew-account limit until the first wallet top-up settles.
CAPACITY_UNAVAILABLENot enough capacity for this size right now.
BILLING_INSUFFICIENT_BALANCEAdd funds to the wallet. Resources are prepaid.
BILLING_SUSPENDEDThe billing account is suspended.
BILLING_NOT_CONFIGUREDBilling is not set up for this project.
ACCOUNT_SUSPENDEDThe account is suspended. Contact support.
RATE_LIMITEDToo many requests. Back off and retry.
UNAVAILABLETemporarily unavailable. Retry with backoff.
DATACENTER_UNREACHABLEThe datacenter could not be reached. Retry with backoff.
DEADLINE_EXCEEDEDThe request took too long. Retry.
CANCELEDThe request was canceled.
NOT_IMPLEMENTEDThe action is not available yet.
INTERNALA fault on our side. Retry, or open a ticket with the request ID.

Treat an unknown reason as you would its HTTP status: new codes may be added in later releases.

Failed operations

Long-running mutations return an operations/… resource (see Operations & idempotency). When one ends FAILED or CANCELLED, its failure_reason holds a code from the same list, for example CAPACITY_UNAVAILABLE. The original request succeeded, so it has no API error; read the operation instead.

Go SDK

SDK v1.3.0 errors are *connect.Error values. Read the ErrorInfo detail like this:

import (
    "errors"

    "connectrpc.com/connect"
    "google.golang.org/genproto/googleapis/rpc/errdetails"
)

// errorInfo returns the Metalhost reason and request ID of an API error.
func errorInfo(err error) (reason, requestID string) {
    var ce *connect.Error
    if !errors.As(err, &ce) {
        return "", ""
    }
    for _, d := range ce.Details() {
        v, derr := d.Value()
        if derr != nil {
            continue
        }
        if info, ok := v.(*errdetails.ErrorInfo); ok && info.GetDomain() == "metalhost.net" {
            return info.GetReason(), info.GetMetadata()["request_id"]
        }
    }
    return "", ""
}

_, err := client.CreateVirtualMachine(ctx, req)
switch reason, requestID := errorInfo(err); reason {
case "":
    // transport failure, or no Metalhost detail
case "QUOTA_EXCEEDED", "TRUST_TIER_LIMIT":
    // raise the limit, then retry
case "CAPACITY_UNAVAILABLE":
    // try a smaller size
default:
    log.Printf("%s (request %s): %v", reason, requestID, err)
}

curl

curl -sS -X POST "$METALHOST_ENDPOINT/aes.compute.v1.ComputeService/GetVirtualMachine" \
  -H "Authorization: Bearer $METALHOST_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"projects/my-app/virtual-machines/web-01"}' |
  jq -r '.details[]? | select(.type == "google.rpc.ErrorInfo") | .debug | "\(.reason) \(.metadata.request_id)"'

CLI

CLI v1.3.0 prints the reason, a hint for common reasons, and the request ID on stderr. Its exit codes follow the error class.

$ metalhost vm create …
Error: You've reached a quota limit. Request an increase from the Quotas page. (QUOTA_EXCEEDED)
  A quota limit was reached — check `metalhost quota list` or request an increase with `metalhost quota request`.
  Request ID: 7f6c2a1e-… (include it if you contact support)

Asking support about an error

Include the request ID in your ticket. When you open a ticket from an error in the dashboard, the error is attached to the ticket automatically. API callers can pass the same details in CreateTicketRequest.error_reference: the error_request_id, reason, the failed procedure and, for operations, the operation_name.

What's next