API Gateway Troubleshooting
GoogleOfficialPublished by Google
- Skill
- Tool integration
- Cloud and DevOps
- Apache-2.0
- Low risk
- Has scripts
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
This skill provides a structured, evidence-based workflow for troubleshooting Google Cloud API Gateway. It helps identify the root cause of 4xx and 5xx errors, deployment failures, and configuration issues by analyzing logs, checking project configurations, and validating OpenAPI specifications. The skill emphasizes safe, read-only diagnostic practices and requires explicit user approval before applying any changes to the infrastructure.
What it does
- Identifies the specific hop (gateway or backend) where a request is failing.
- Analyzes gateway logs to pinpoint authentication, quota, or routing errors.
- Validates OpenAPI specifications using an offline linter to prevent deployment errors.
- Provides step-by-step guidance for resolving IAM, service account, and configuration issues.
- Offers a clear escalation path for issues originating within the Google Cloud platform.
- Enforces safe operational habits by requiring approval for all configuration changes.
When to use it
- When API Gateway returns 4xx or 5xx errors, including 429 quota errors or CORS failures.
- When deployment commands like api-configs create or gateways update fail.
- When investigating issues with custom domains, load balancers, or TLS configurations.
- When you need to verify if a failure is caused by the gateway, the backend, or the caller's credentials.
Advantages
- Reduces downtime by systematically isolating the source of API failures.
- Prevents accidental configuration changes through a strict approval-based workflow.
- Ensures security by mandating the redaction of sensitive credentials from logs and output.
- Provides consistent, documented troubleshooting steps aligned with official Google Cloud practices.
Usage examples
- “My API Gateway is returning 500 errors for all requests. Can you help?”
- The agent will collect the project ID and gateway details, then use the provided log query to identify if the error is a gateway-side issue or a backend failure.
- “I am getting a 403 error when calling my API through the gateway.”
- The agent will determine if the 403 is coming from the gateway (auth/API key) or the backend (IAM/invoker role) and guide you through the appropriate fix.
Install
Download the skill as a ZIP:
https://mp-staging.autogineer.com/agent-skills/google/api-gateway-troubleshooting/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip api-gateway-troubleshooting.zip -d .agents/skills
Documentation by Google
API Gateway Troubleshooting
Use this skill to find out why requests to a deployed Google Cloud API Gateway fail and how to fix them. Work only from three sources of evidence:
Public Google Cloud documentation, linked from this skill.
The user's own project, read with the user's own credentials.
Files and output that the user provides, such as an OpenAPI document, an error response, or a pasted log entry.
Scope
This skill covers only Google Cloud API Gateway. If the question is about Apigee, Cloud Endpoints, or GKE Gateway API, reply that this skill covers only Google Cloud API Gateway, and don't run its steps or commands.
Usage Attribution
MUST READ: Before executing any gcloud, REST API, or MCP command, you
MUST read and follow the instructions in
Usage Attribution.
Ground rules
| Rule | Why |
|---|---|
Stay read-only until the user approves a change. While you diagnose, run only list, describe, get-iam-policy, and logging read commands, plus nslookup and curl GET requests to a path that's safe to call. | Gateways serve production traffic, and the quota metrics and limits of the most recently created API config apply to every gateway of the API. |
Before any change, show the exact command, what it changes, and how to undo it, then wait for an explicit yes. Changes include IAM grants, api-configs create, gateways update, Cloud Run ingress changes, and gcloud services enable. | The user owns the project and the change. |
Never fix a 401 or 403 by making a backend public (--allow-unauthenticated, --no-invoker-iam-check, allUsers) or by granting a basic role such as Editor. | Grant the gateway's service account the invoker role on the one backend instead, which keeps least privilege. |
Keep every log read bounded: filter on the gateway, set --freshness or a timestamp window, and set --limit. | Unbounded reads are slow and pull in caller data that the diagnosis doesn't need. |
| Redact API keys, bearer tokens, and JWTs before you show log entries, commands, or files. | Request logs and pasted output can contain credentials. |
| Base every limit, default, and error meaning on a linked public doc or on output from the user's project. If neither explains the problem, say so and escalate. | Guessing about undocumented behavior leads to wrong fixes. |
Use the user's existing gcloud login. Never ask for passwords, API key values, or service account key files. | Credentials don't belong in a chat transcript. |
| For Preview features (streaming, MCP, model routing), check the release notes before answering. | Preview behavior changes between releases. |
Prerequisites
Google Cloud CLI. Use a recent
gcloud(the commands here were checked with version 591.0.0); see Install the gcloud CLI. Don't install or update software without the user's approval. Ifgcloudisn't available or the user says not to run commands, work in evidence mode: give the user the read-only commands to run, and diagnose from the output, response bodies, and log entries they paste.Authentication. Use the user's existing
gcloud auth loginsession; this skill doesn't need application-default credentials.Project. Ask which project hosts the gateway and pass
--project={project_id}on every command. Don't rungcloud config set, because it changes the user's environment. This skill troubleshoots existing gateways and never creates projects.Billing. The project needs an active billing account even when usage stays in the free tier; see API Gateway pricing.
APIs. API Gateway requires
apigateway.googleapis.com,servicemanagement.googleapis.com, andservicecontrol.googleapis.com. A gateway can keep responding while one of them is missing, and its logs then stay empty, so check withgcloud services list --enabled --project={project_id}. Enabling a missing API is a change that needs approval.IAM roles for diagnosis (read-only):
roles/apigateway.viewer,roles/logging.viewer,roles/serviceusage.serviceUsageViewer, androles/iam.serviceAccountViewer(to check the gateway's service account). For Cloud Run backends, addroles/run.viewer; to trace a custom domain through its load balancer, addroles/compute.viewer; to check an API key's project,roles/serviceusage.serviceUsageVieweris also needed there.IAM roles for fixes (only after approval):
roles/apigateway.admin;roles/iam.serviceAccountUseron the gateway's service account, because creating or updating an API config or a gateway requiresiam.serviceAccounts.actAs;roles/run.adminto change a Cloud Run service's IAM policy or ingress;roles/serviceusage.serviceUsageAdminto enable an API or a managed service, plusroles/servicemanagement.serviceConsumeron the API's managed service to enable it in another project; androles/iam.serviceAccountAdminto re-enable a disabled service account.Region. Gateways are regional. Use the region that
gateways listreports. When a fix needs a new gateway, create it in the original gateway's region, or inus-central1if the user has no preference; see API Gateway deployment model and Cloud locations.
Workflow
Copy this checklist into your notes and tick it off as you go:
- [ ] 1. Context collected
- [ ] 2. Gateway identified by defaultHostname
- [ ] 3. Expected or transient states ruled out
- [ ] 4. Gateway log entry read (bounded query)
- [ ] 5. One branch chosen from the classification table
- [ ] 6. Root cause stated with redacted evidence and a doc link
- [ ] 7. Fix proposed, approved, and applied (or handed to the user)
- [ ] 8. Fix verified
- [ ] 9. Escalated, if the evidence points to Google Cloud1. Collect context
Extract these from the conversation first, and ask only for what's missing:
| Needed | Why |
|---|---|
{project_id} of the gateway | Every command is scoped to it. |
Failing URL (host and path) or {gateway_id} and {region} | Identifies the gateway. |
| HTTP method, status code, and response body | Separates gateway errors from backend errors. |
| Approximate time of the failure, in UTC | Bounds the log query. |
| What changed recently (new API config, IAM, backend deploy, DNS) | Recent changes are the first place to look. |
| Authentication used by the caller (API key, JWT, none) | Selects the auth branch. |
If the project or the host is unknown, stop and ask before you run any
gcloud command. Don't guess project IDs, and don't use the active gcloud
project instead.
2. Identify the gateway
List the project's gateways:
gcloud api-gateway gateways list --project={project_id} \
--format="table(name.basename():label=GATEWAY,name.segment(3):label=REGION,defaultHostname,state,apiConfig)"Match the request host against defaultHostname. Don't decide from the URL
suffix: a gateway's hostname can use either of two gateway.dev formats or,
for older streaming and model-routing gateways, a run.app format. Don't
guess the owning project from numbers in the hostname either; list the
gateways in the project the user names. A custom domain points at a load
balancer whose serverless NEG targets the gateway. See
references/triage-and-logs.md for the
hostname formats and how to trace a custom domain.
If no gateway in the project matches, confirm the product before going on (see Scope).
Then read the gateway's details, including its API config and streaming mode:
gcloud api-gateway gateways describe {gateway_id} \
--location={region} --project={project_id}3. Rule out expected and transient states
Gateway
stateisn'tACTIVE:CREATINGorUPDATINGmeans an operation is still running, so wait and check again.FAILEDmeans creation failed; read the error of the failed operation.DELETINGmeans the gateway is being removed.API config just created: creating a complex API config can take up to ten minutes, and only one config can be created for an API at a time.
First requests after an idle period: API Gateway has cold-start latency.
Google Cloud incident: check the Service Health dashboard.
4. Read the gateway log entry
Use a bounded query that prints only the fields you need. Replace
{status_code} with the status the caller saw:
gcloud logging read 'resource.type="apigateway.googleapis.com/Gateway" AND resource.labels.gateway_id="{gateway_id}" AND resource.labels.location="{region}" AND httpRequest.status={status_code}' \
--project={project_id} --freshness=1h --limit=20 \
--format="json(timestamp,httpRequest.status,httpRequest.latency,httpRequest.requestUrl,jsonPayload.responseDetails,jsonPayload.apiConfig,jsonPayload.apiMethod)"If you know the failure time, replace --freshness with a
timestamp>="{start_utc}" AND timestamp<="{end_utc}" filter that spans about
30 minutes around it. Read httpRequest.status, httpRequest.requestUrl,
jsonPayload.responseDetails, jsonPayload.apiConfig, and
jsonPayload.apiMethod. Keep the --format field list, because a full entry
can contain the caller's credentials, and redact credentials before you quote
any field, including a key query parameter in httpRequest.requestUrl.
To compare API configs, compare only the apis/{api_id}/configs/{config_id}
part: the same config can appear as a full resource name
(//apigateway.googleapis.com/projects/...) and with either the project
number or the project ID.
jsonPayload.responseDetails decides the branch: via_upstream means the
backend produced the response; any other value means the gateway produced it.
The troubleshooting page explains via_upstream and
service_control_quota_error, and the model routing page explains the
model_router_* values that model-routing gateways log. Quote any other value
as-is, and don't explain how the gateway works internally.
5. Classify and follow one branch
Pick the first row that matches, and load only the reference file it names.
| What you see | Likely cause | Reference |
|---|---|---|
401 or 403 with via_upstream | The backend rejected the gateway: invoker role, audience, or deleted service account | auth-errors.md |
A 4xx produced by the gateway whose body names a credential problem (for example UNAUTHENTICATED) | The API key or JWT was rejected; quote the body | auth-errors.md |
500 with no gateway log entry, after log exclusions and a wrong scope are ruled out | The gateway's service account may be deleted or disabled | auth-errors.md |
500 with service_control_quota_error | Quota metric missing from the newest API config | config-deployment-errors.md |
429 with service_control_quota_error | The caller exceeded the API's quota | limits-and-escalation.md |
404, 502, 503, or 504, an oversized request, or a timeout | Routing, backend errors, size limits, or the deadline. The docs don't name the status codes for size or deadline failures, so confirm with responseDetails and the body. | backend-routing-and-timeouts.md |
| CORS preflight fails or latency is high | CORS pass-through or slow backend | backend-routing-and-timeouts.md |
api-configs create fails with an error about the OpenAPI document, such as an unsupported extension or an invalid path, parameter, server, or security requirement | A spec problem | Hand off to api-gateway-openapi-spec-authoring; if it isn't installed, see OpenAPI pre-flight check |
api-configs create fails with a service account, concurrency, or limit error, or gateways create or gateways update fails | Service account, concurrent creates, limits, or the backend deadline | config-deployment-errors.md |
400 "The request was malformed" from an old TLS client, TLS scan findings, custom domain, or no log entry at all | TLS enforcement, DNS, load balancer, or wrong host | networking-domains-tls.md |
| Errors on several gateways, or on an unchanged config, with healthy backends | Possible Google Cloud issue | limits-and-escalation.md |
If the request also needs log queries or backend inspection commands, use triage-and-logs.md.
6. State the root cause
Give the cause, the evidence (redacted, quoted verbatim from logs or command output), and the public doc section that explains it. If the evidence fits more than one cause, list at most three, say what would tell them apart, and collect that evidence before proposing a fix.
7. Propose the fix
Show the exact command, the resource it changes, its effect on live traffic, and how to undo it. Wait for approval, then run it, or hand it to the user if they prefer. Keep these rules in mind:
To change the OpenAPI document, create a new API config and deploy it with
gcloud api-gateway gateways update. Before you create the config, check quota metrics with theapi-gateway-backend-security-and-rate-limitingskill, because the newest API config's quota metrics apply to every gateway of the API.Before you create a new API config, check the OpenAPI document with the linter (see OpenAPI pre-flight check).
8. Verify
Read the logs again for new entries after the change, or repeat the failing
request. Repeat a request yourself only if it's safe to repeat, such as a
GET; otherwise ask the user to repeat it, because a repeated POST can
change data in their backend. After gateways update, wait until the
gateway's state is ACTIVE before you test.
9. Escalate when the evidence points to Google Cloud
Escalate to Cloud Customer Care when the
service account is active, the required APIs are enabled, and no log exclusion
or wrong scope explains it, but requests get 500 errors with no gateway log
entry; when several gateways fail at once without a config change; when errors
are intermittent on an unchanged config while the backend logs stay clean;
when control-plane operations keep failing with internal errors; or when
Service Health reports an incident. Draft the case text with the evidence
bundle in limits-and-escalation.md. The
user files the case.
OpenAPI pre-flight check
Run the linter on every OpenAPI file before creating an API config. Run it; don't read its source. If you don't have the file, give the user the command to run:
python3 scripts/lint_openapi_for_gateway.py {spec_path} [{spec_path} ...]It reads the files only, makes no network calls, and prints JSON findings that
each link to the public doc for the limitation. Exit code 0 means no errors,
1 means at least one ERROR finding, and 2 means a usage, parse, or
dependency error. The linter needs Python 3.8 or later. YAML input needs
PyYAML; ask the user before you install it with
pip install "PyYAML>=6.0,<7.0". JSON input doesn't. Fix the ERROR
findings, and then create the API config. If api-configs create still fails
on the OpenAPI document, hand off to api-gateway-openapi-spec-authoring, or
use the error message if that skill isn't installed. Show WARNING findings
to the user and explain them.
Output format
Report findings in this form:
**Problem:** <one sentence in the user's terms>
**Failed hop:** <gateway or backend, and the evidence for it>
**Evidence:** <redacted log fields or command output, verbatim>
**Root cause:** <one or two sentences> (<public doc link>)
**Fix:** <exact command or spec change; approval needed: yes/no>
**Verify:** <how to confirm the fix>
**Open questions:** <anything you couldn't confirm>Related skills
| Need | Skill |
|---|---|
| Write or change API key, JWT, backend authentication, or quota configuration | api-gateway-backend-security-and-rate-limiting |
Write, migrate, or fix an OpenAPI spec, including OpenAPI errors from api-configs create | api-gateway-openapi-spec-authoring |
MCP /mcp JSON-RPC errors on an MCP-enabled gateway | api-gateway-mcp-server |
| Deep IAM analysis, such as deny policies or role discovery | iam-helper-for-troubleshooting |
| Build a load balancer, Cloud Armor policy, or custom domain front end | google-cloud-global-frontend-configuration |
| Deploy or reconfigure a Cloud Run backend | cloud-run-basics |
Reference files
references/attribution.md: usage attribution rules for every command you run.
references/triage-and-logs.md: hostname formats, log queries, log fields, metrics, and backend inspection commands.
references/auth-errors.md:
401and403on either hop, API keys, JWTs, and the gateway service account.references/backend-routing-and-timeouts.md:
404, size limits, backend5xx,504and deadlines, CORS, and streaming.references/config-deployment-errors.md: API config and gateway create, update, and delete failures other than OpenAPI errors, and quota metric conflicts.
references/networking-domains-tls.md: custom domains, load balancers, TLS, and requests that never reach the gateway.
references/limits-and-escalation.md: product limits, quota increases, unsupported features, and the Cloud Customer Care evidence bundle.
scripts/lint_openapi_for_gateway.py: offline linter for documented OpenAPI limitations.
Files
- ATTRIBUTION.mdAdded by AutoGineer872 B
- LICENSELicence file added by AutoGineer11.1 KiB
- SKILL.md19.2 KiB
- references/attribution.md1.9 KiB
- references/auth-errors.md10.2 KiB
- references/backend-routing-and-timeouts.md7.4 KiB
- references/config-deployment-errors.md4.9 KiB
- references/limits-and-escalation.md4.4 KiB
- references/networking-domains-tls.md4.8 KiB
- references/triage-and-logs.md7.6 KiB
- scripts/lint_openapi_for_gateway.pyScript25.0 KiB
scripts/lint_openapi_for_gateway.py
#!/usr/bin/env python3
"""Lints OpenAPI documents for documented API Gateway limitations.
The linter works offline and only reads the files that it is given. It reports
the OpenAPI features that the public API Gateway documentation describes as
unsupported, rejected, or ignored, so that you can fix them before you create
an API config. Each finding links to the page that documents the limitation.
Pass all OpenAPI files of one API config together. JSON input needs only the
Python standard library. YAML input needs PyYAML.
"""
from __future__ import annotations
import argparse
import collections
from collections.abc import Iterator
import json
import os
import re
import sys
from typing import Any
from urllib import parse
try:
import yaml # pylint: disable=g-import-not-at-top
_HAS_YAML = True
except ImportError:
_HAS_YAML = False
_DOCS = 'https://docs.cloud.google.com/api-gateway/docs/'
# LIMITS and EXTENSIONS resolve to the page for the OpenAPI version of a file.
_PAGES = {
('LIMITS', False): _DOCS + 'openapi-limitations.md.txt',
('LIMITS', True): _DOCS + 'openapi-v3-limitations.md.txt',
('EXTENSIONS', False): _DOCS + 'oasv2-extensions.md.txt',
('EXTENSIONS', True): _DOCS + 'oasv3-extensions.md.txt',
}
_V3_LIMITS = _PAGES[('LIMITS', True)]
_BACKENDS = _DOCS + 'securing-backend-services.md.txt'
_OVERVIEW = _DOCS + 'openapi-overview.md.txt'
# Documented limits. The messages in _FINDINGS repeat them.
_MAX_YAML_ALIASES = 200
_MAX_DEADLINE_SECONDS = 3600
_MAX_NON_STREAMING_DEADLINE_SECONDS = 600
_OAS3_MAX_FILES = 50
# The documented limit is 10MB. The binary reading is the larger one, so the
# check never flags files that are within the limit under either reading.
_OAS3_MAX_BYTES = 10 * 1024 * 1024
# Rule ID, with an optional ":variant" suffix: (severity, page, message).
# fmt: off
_FINDINGS = {
'PARTIAL_SEGMENT_PATH_TEMPLATE': ('ERROR', 'LIMITS', (
'Make each parameter in path {path} an entire path segment, such as'
' /items/{{itemId}}, because API Gateway rejects partial segments.')),
'EXTERNAL_REF': ('ERROR', 'LIMITS', (
'Replace $ref {ref} with a local reference (#/...), because API Gateway'
' does not support references outside the provided OpenAPI document.')),
'EXTERNAL_REF:between_files': ('WARNING', 'LIMITS', (
'Prefer a local reference (#/...) over $ref {ref}, because the docs do'
' not say whether references between uploaded files are supported.')),
'HOST_CUSTOM_PORT': ('ERROR', 'LIMITS', (
'Remove the port from {field} {value}, because API Gateway does not'
' allow custom ports in {field}.')),
'API_KEY_IN_LOGICAL_OR': ('ERROR', 'LIMITS', (
'Use one requirement instead of these alternatives (logical OR),'
' because API Gateway does not support alternatives when one is an API'
' key.')),
'API_KEY_IN_LOGICAL_OR:oas2': ('WARNING', 'LIMITS', (
'Use one requirement instead of these alternatives (logical OR),'
' because API Gateway does not support alternatives when one is an API'
' key.')),
'OAUTH_LOGICAL_AND': ('ERROR', 'LIMITS', (
'Keep one of the OAuth2 schemes {names} or make them alternatives,'
' because API Gateway does not support a logical AND of OAuth2'
' schemes.')),
'OPTIONAL_SECURITY_WITH_OAUTH': ('ERROR', 'LIMITS', (
'Remove the empty requirement ({{}}) from this list, because API'
' Gateway supports optional security for API keys but not for OAuth.')),
'OPTIONAL_SECURITY_WITH_OAUTH:oas2': ('WARNING', 'LIMITS', (
'Remove the empty requirement ({{}}) from this list, because API'
' Gateway supports optional security for API keys but not for OAuth.')),
'UNDEFINED_SECURITY_SCHEME': ('ERROR', 'LIMITS', (
'Define security scheme {name} in {definitions} or remove it, because'
' API Gateway rejects a requirement without a matching definition.')),
'UNDEFINED_SECURITY_SCHEME:oas2': ('WARNING', 'LIMITS', (
'Define security scheme {name} in {definitions} or remove it. The docs'
' say that API Gateway rejects an undefined scheme in OpenAPI 3.x, and'
' they don\'t say what happens in OpenAPI 2.0.')),
'SCOPES_NOT_ENFORCED': ('INFO', 'LIMITS', (
'Enforce the scopes of {name} in your backend if you need them, because'
' API Gateway does not check or enforce scopes.')),
'YAML_ALIAS_LIMIT': ('ERROR', 'LIMITS', (
'Reduce the YAML alias nodes from {count} to 200 or fewer, because API'
' Gateway accepts at most 200 per document.')),
'DEADLINE_ABOVE_MAX': ('ERROR', 'EXTENSIONS', (
'Lower deadline {deadline} to 3600 seconds or less, because 3600'
' seconds is the maximum configurable deadline.')),
'DEADLINE_ABOVE_NON_STREAMING_MAX': ('WARNING', 'EXTENSIONS', (
'Lower deadline {deadline} to 600 seconds or less unless all gateways'
' for this config are streaming-enabled, because a non-streaming'
' gateway rejects it at gateway create or update.')),
'GOOGLEAPIS_BACKEND': ('ERROR', _BACKENDS, (
'Route this backend to your own service instead of {host}, because API'
' Gateway does not support proxying to other Google Cloud APIs.')),
'JWT_AUDIENCE_AND_DISABLE_AUTH': ('ERROR', 'EXTENSIONS', (
'Set only one of {audience} and {disable} on this backend.')),
'WRONG_VERSION_EXTENSION': ('WARNING', 'EXTENSIONS', (
'{fix}, because {extension} is documented only for OpenAPI'
' {other}.')),
'X_GOOGLE_ALLOW_IN_OAS3': ('ERROR', _V3_LIMITS, (
'Remove x-google-allow and define every path the gateway serves,'
' because API Gateway does not support x-google-allow for OpenAPI'
' 3.x.')),
'MULTIPLE_X_GOOGLE_ENDPOINT_SERVERS': ('ERROR', _V3_LIMITS, (
'Keep x-google-endpoint on only one of {servers}, because API Gateway'
' permits only one server with x-google-endpoint.')),
'INVALID_SERVER_URL': ('ERROR', _V3_LIMITS, (
'Change server URL {url!r} to a URL with a scheme or a base path that'
' begins with /, because API Gateway rejects other server URLs.')),
'OAS3_SIZE_OR_FILE_COUNT': ('ERROR', _V3_LIMITS, (
'Reduce the OpenAPI 3.x files ({count} files, {size} bytes) to at most'
' 50 files and 10 MB in total, because API Gateway enforces both.')),
'UNSUPPORTED_OPENAPI_VERSION': ('ERROR', _OVERVIEW, (
'Declare swagger: "2.0", openapi: 3.0.x, or openapi: 3.1.x (found'
' {found}), because API Gateway supports only these versions.')),
}
# fmt: on
_SEVERITY_RANK = {'ERROR': 0, 'WARNING': 1, 'INFO': 2}
_SUMMARY_KEYS = {'errors': 'ERROR', 'warnings': 'WARNING', 'info': 'INFO'}
# Extensions that only the extensions page of one OpenAPI version documents,
# mapped to the fix in a document of the other version, or None if the other
# version has no replacement.
_V2_ONLY_EXTENSIONS = {
'x-google-api-name': 'Use x-google-api-management.apiName instead',
'x-google-audiences': 'Use x-google-auth.audiences instead',
'x-google-endpoints': 'Use x-google-endpoint on a server object instead',
'x-google-issuer': 'Use x-google-auth.issuer instead',
'x-google-jwks_uri': 'Use x-google-auth.jwksUri instead',
'x-google-jwt-locations': 'Use x-google-auth.jwtLocations instead',
'x-google-management': 'Use x-google-api-management instead',
}
_V3_ONLY_EXTENSIONS = {
'x-google-api-management': (
'Use x-google-management, x-google-backend, and x-google-api-name'
),
'x-google-auth': (
'Use x-google-issuer, x-google-jwks_uri, x-google-audiences, and'
' x-google-jwt-locations instead'
),
'x-google-endpoint': 'Use a top-level x-google-endpoints list instead',
'x-google-mcp-tool': None,
'x-google-model-router': None,
'x-google-parameter': 'Use the {name=**} path template syntax instead',
}
_HTTP_METHODS = 'get put post delete options head patch trace'.split()
_PARAM_RE = re.compile(r'\{[^{}]*\}')
_EPILOG = """\
examples:
python3 scripts/lint_openapi_for_gateway.py openapi.yaml
python3 scripts/lint_openapi_for_gateway.py main.yaml extra.yaml --format text
stdout: {files, openapi_version, findings, summary}; each finding has rule,
severity, file, location, message, and doc. Diagnostics go to stderr.
exit codes:
0 no ERROR findings (WARNING and INFO findings are allowed)
1 at least one ERROR finding
2 usage, file, parse, or dependency error
"""
class InputError(Exception):
"""A file, parse, or dependency problem that ends the run with exit code 2."""
def _child(location: str, key: Any) -> str:
return f'{location}.{key}' if location else str(key)
def _items(node: Any) -> list[tuple[Any, Any]]:
return list(node.items()) if isinstance(node, dict) else []
def _list(node: Any) -> list[Any]:
return node if isinstance(node, list) else []
def _get(node: Any, *keys: str) -> Any:
"""Returns node[key1][key2]..., or None if a level is not a mapping."""
for key in keys:
node = node.get(key) if isinstance(node, dict) else None
return node
def _mappings(node: Any) -> Iterator[tuple[str, dict[Any, Any]]]:
"""Yields (location, mapping) for each mapping in node, in document order.
The walk is iterative and visits each object once, so YAML aliases and
recursive anchors cannot make it loop or repeat work.
Args:
node: A parsed document, or any part of one.
Yields:
A (location, mapping) tuple for each mapping, where location is the
dotted path of the mapping in the document.
"""
seen: set[int] = set()
stack: list[tuple[str, Any]] = [('', node)]
while stack:
location, current = stack.pop()
if not isinstance(current, (dict, list)) or id(current) in seen:
continue
seen.add(id(current))
if isinstance(current, dict):
yield location, current
children = [(_child(location, k), v) for k, v in current.items()]
else:
children = [(f'{location}[{i}]', v) for i, v in enumerate(current)]
stack.extend(reversed(children))
def _operations(doc: dict[str, Any]) -> Iterator[tuple[str, dict[str, Any]]]:
for path, item in _items(doc.get('paths')):
for method in _HTTP_METHODS:
if isinstance(_get(item, method), dict):
yield f'paths.{path}.{method}', item[method]
def _is_partial_segment(path: str) -> bool:
"""Returns whether a parameter of a path template is a partial segment.
The last segment can end with a custom method, such as /items/{id}:undelete.
Args:
path: A path template from the paths object, such as /items/{id}.
"""
if re.search(r'[{}]', _PARAM_RE.sub('', path)):
return True # Nested or unbalanced braces.
for match in _PARAM_RE.finditer(path):
starts_segment = path[: match.start()].endswith('/')
rest = path[match.end() :]
custom_method = rest[:1] == ':' and '/' not in rest
if not starts_segment or not (rest[:1] in ('', '/') or custom_method):
return True
return False
def _host(url: str) -> str:
"""Returns the lowercase host of a URL, which may omit its scheme."""
try:
host = parse.urlsplit(url if '://' in url else '//' + url).hostname
except ValueError:
return ''
return (host or '').rstrip('.')
def _has_port(url: str) -> bool:
try:
return parse.urlsplit(url).port is not None
except ValueError:
return False
def _version(doc: dict[str, Any]) -> str:
"""Returns '2.0', '3.0.x', '3.1.x', or 'unknown' for a parsed document."""
if str(doc.get('swagger')) == '2.0':
return '2.0'
match = re.fullmatch(r'3\.([01])(\.\d+)?', str(doc.get('openapi')))
return f'3.{match.group(1)}.x' if match else 'unknown'
def _model_router_backends(doc: dict[str, Any]) -> set[str]:
"""Returns the names of the backends that model routers send requests to."""
routers = _get(
doc, 'x-google-api-management', 'ai', 'models', 'routing', 'routers'
)
names = set()
for _, router in _items(routers):
names.add(_get(router, 'defaultModel', 'backend'))
names.update(_get(rule, 'backend') for rule in _list(_get(router, 'rules')))
return {name for name in names if isinstance(name, str)}
def load_spec(path: str) -> tuple[dict[str, Any], int, int | None]:
"""Returns the parsed document, its size, and its YAML alias count.
Args:
path: The path of a JSON or YAML OpenAPI file.
Returns:
A tuple of the parsed document, its size in bytes, and its YAML alias
count. The alias count is None for JSON input.
Raises:
InputError: If the file cannot be read or parsed, or if it is YAML and
PyYAML is not installed.
"""
try:
with open(path, 'rb') as f:
raw = f.read()
text = raw.decode('utf-8-sig')
except (OSError, UnicodeDecodeError) as exc:
raise InputError(f'Cannot read {path}: {exc}') from exc
aliases = None
try:
doc = json.loads(text)
except (ValueError, RecursionError) as exc:
if path.lower().endswith('.json'):
raise InputError(f'{path} is not valid JSON: {exc}') from exc
if not _HAS_YAML:
raise InputError(
f'{path} is not JSON, and reading YAML requires PyYAML. Install it'
' with: pip install "PyYAML>=6.0,<7.0"'
) from exc
try:
events = yaml.parse(text, Loader=yaml.SafeLoader)
aliases = sum(isinstance(event, yaml.AliasEvent) for event in events)
doc = yaml.safe_load(text)
except (yaml.YAMLError, RecursionError) as yaml_exc:
raise InputError(f'{path} is not valid YAML: {yaml_exc}') from yaml_exc
if not isinstance(doc, dict):
raise InputError(f'{path} must contain a JSON object or a YAML mapping.')
return doc, len(raw), aliases
class Linter:
"""Collects the findings for the OpenAPI files of one API config."""
def __init__(self, paths: list[str]):
self.findings: list[dict[str, Any]] = []
self._all_files = {os.path.abspath(path) for path in paths}
self._multi_file = len(paths) > 1
self._file: str | None = None
self._v3 = False
def _add(self, finding: str, location: str, /, **values: Any) -> None:
severity, page, message = _FINDINGS[finding]
self.findings.append({
'rule': finding.split(':')[0],
'severity': severity,
'file': self._file,
'location': location,
'message': message.format(**values),
'doc': _PAGES.get((page, self._v3), page),
})
def lint_file(
self, path: str, doc: dict[str, Any], version: str, aliases: int | None
) -> None:
"""Checks one parsed file that declares the given OpenAPI version."""
self._file, self._v3 = path, version.startswith('3')
if version == 'unknown':
found = {k: doc[k] for k in ('swagger', 'openapi') if k in doc}
self._add(
'UNSUPPORTED_OPENAPI_VERSION',
'(document)',
found=found or 'no version field',
)
if aliases is not None and aliases > _MAX_YAML_ALIASES:
self._add('YAML_ALIAS_LIMIT', '(document)', count=aliases)
for template, _ in _items(doc.get('paths')):
if isinstance(template, str) and _is_partial_segment(template):
self._add(
'PARTIAL_SEGMENT_PATH_TEMPLATE', f'paths.{template}', path=template
)
self._check_keys(doc, version != 'unknown')
if version == 'unknown':
return
self._check_security(doc)
if self._v3:
self._check_servers(doc)
model_backends = _model_router_backends(doc)
backends = _get(doc, 'x-google-api-management', 'backends')
for name, backend in _items(backends):
self._check_backend(
f'x-google-api-management.backends.{name}',
backend,
('jwtAudience', 'disableAuth'),
name in model_backends,
)
return
host = doc.get('host')
if isinstance(host, str) and re.search(r':\d+$', host):
self._add('HOST_CUSTOM_PORT', 'host', field='host', value=host)
backends = [('x-google-backend', doc.get('x-google-backend'))] + [
(f'{location}.x-google-backend', operation.get('x-google-backend'))
for location, operation in _operations(doc)
]
for location, backend in backends:
self._check_backend(
location, backend, ('jwt_audience', 'disable_auth'), False
)
def lint_set(self, sizes: list[int]) -> None:
"""Checks the limits for the OpenAPI 3.x files of an API config."""
self._file, self._v3 = None, True
if len(sizes) > _OAS3_MAX_FILES or sum(sizes) > _OAS3_MAX_BYTES:
self._add(
'OAS3_SIZE_OR_FILE_COUNT',
'(all files)',
count=len(sizes),
size=sum(sizes),
)
def _check_keys(self, doc: dict[str, Any], known_version: bool) -> None:
"""Checks $ref values and version-specific extensions in all mappings."""
if self._v3:
wrong, other, backend_form = _V2_ONLY_EXTENSIONS, '2.0', (dict, 'object')
backend_fix = (
'Set x-google-backend to the name of a backend in'
' x-google-api-management.backends'
)
else:
wrong, other, backend_form = _V3_ONLY_EXTENSIONS, '3.x', (str, 'string')
backend_fix = 'Set x-google-backend to an object with an address field'
for location, mapping in _mappings(doc):
for key, value in mapping.items():
where = _child(location, key)
if key == '$ref' and isinstance(value, str) and value[:1] != '#':
self._check_ref(where, value)
elif not known_version:
continue
elif key == 'x-google-allow' and self._v3:
self._add('X_GOOGLE_ALLOW_IN_OAS3', where)
elif key in wrong:
self._add(
'WRONG_VERSION_EXTENSION',
where,
fix=wrong[key] or f'Remove {key} or migrate to OpenAPI 3.x',
extension=key,
other=other,
)
elif key == 'x-google-backend' and isinstance(value, backend_form[0]):
self._add(
'WRONG_VERSION_EXTENSION',
where,
fix=backend_fix,
extension=f'its {backend_form[1]} form',
other=other,
)
def _check_ref(self, location: str, ref: str) -> None:
"""Checks a $ref that does not point inside the same document."""
base = os.path.dirname(os.path.abspath(self._file or ''))
target = os.path.normpath(os.path.join(base, ref.split('#', 1)[0]))
if self._multi_file and '://' not in ref and target in self._all_files:
self._add('EXTERNAL_REF:between_files', location, ref=ref)
else:
self._add('EXTERNAL_REF', location, ref=ref)
def _check_security(self, doc: dict[str, Any]) -> None:
"""Checks the top-level and operation-level security requirements."""
if self._v3:
definitions = 'components.securitySchemes'
schemes = _get(doc, 'components', 'securitySchemes')
else:
definitions = 'securityDefinitions'
schemes = doc.get('securityDefinitions')
types = {name: _get(scheme, 'type') for name, scheme in _items(schemes)}
# The limitations pages document these rejections for OpenAPI 3.x only.
variant = '' if self._v3 else ':oas2'
lists = [('security', doc.get('security'))] + [
(f'{location}.security', operation.get('security'))
for location, operation in _operations(doc)
]
scoped = set()
for location, requirements in lists:
for index, requirement in enumerate(_list(requirements)):
for name, scopes in _items(requirement):
where = f'{location}[{index}].{name}'
if name not in types:
self._add(
'UNDEFINED_SECURITY_SCHEME' + variant,
where,
name=name,
definitions=definitions,
)
if _list(scopes) and name not in scoped:
scoped.add(name)
self._add('SCOPES_NOT_ENFORCED', where, name=name)
oauth = [
str(name)
for name, _ in _items(requirement)
if types.get(name) == 'oauth2'
]
if len(oauth) > 1:
self._add(
'OAUTH_LOGICAL_AND',
f'{location}[{index}]',
names=', '.join(oauth),
)
objects = [r for r in _list(requirements) if isinstance(r, dict)]
alternatives = [r for r in objects if r]
kinds = set()
for alternative in alternatives:
kinds.update(types.get(name) for name in alternative)
if len(alternatives) > 1 and 'apiKey' in kinds:
self._add('API_KEY_IN_LOGICAL_OR' + variant, location)
if len(alternatives) < len(objects) and 'oauth2' in kinds:
self._add('OPTIONAL_SECURITY_WITH_OAUTH' + variant, location)
def _check_servers(self, doc: dict[str, Any]) -> None:
"""Checks the server URLs and x-google-endpoint of an OpenAPI 3.x file."""
endpoints = []
for index, server in enumerate(_list(doc.get('servers'))):
if not isinstance(server, dict):
continue
if 'x-google-endpoint' in server:
endpoints.append(f'servers[{index}]')
url, where = server.get('url'), f'servers[{index}].url'
if not isinstance(url, str) or not (url[:1] == '/' or '://' in url):
self._add('INVALID_SERVER_URL', where, url=url)
elif _has_port(url):
self._add('HOST_CUSTOM_PORT', where, field='servers.url', value=url)
if len(endpoints) > 1:
self._add(
'MULTIPLE_X_GOOGLE_ENDPOINT_SERVERS',
'servers',
servers=', '.join(endpoints),
)
def _check_backend(
self,
location: str,
backend: Any,
auth_keys: tuple[str, str],
model_backend: bool,
) -> None:
"""Checks an OpenAPI 2.0 x-google-backend or an OpenAPI 3.x backend."""
if not isinstance(backend, dict):
return
deadline = backend.get('deadline')
if isinstance(deadline, (int, float)) and not isinstance(deadline, bool):
if deadline > _MAX_DEADLINE_SECONDS:
self._add(
'DEADLINE_ABOVE_MAX', f'{location}.deadline', deadline=deadline
)
elif deadline > _MAX_NON_STREAMING_DEADLINE_SECONDS:
self._add(
'DEADLINE_ABOVE_NON_STREAMING_MAX',
f'{location}.deadline',
deadline=deadline,
)
address = backend.get('address')
host = _host(address) if isinstance(address, str) else ''
# The documented model routing example uses a googleapis.com backend.
if not model_backend and (
host == 'googleapis.com' or host.endswith('.googleapis.com')
):
self._add('GOOGLEAPIS_BACKEND', f'{location}.address', host=host)
audience, disable = auth_keys
if audience in backend and disable in backend:
self._add(
'JWT_AUDIENCE_AND_DISABLE_AUTH',
location,
audience=audience,
disable=disable,
)
def _format_text(report: dict[str, Any]) -> str:
"""Returns the report as human-readable text, for --format text."""
lines = []
for finding in report['findings']:
where = finding['location']
if finding['file']:
where = f'{finding["file"]}: {where}'
lines.append(f'{finding["severity"]} {finding["rule"]} at {where}')
lines.append(f' {finding["message"]}\n {finding["doc"]}')
summary = report['summary']
lines.append(
f'{len(report["files"])} file(s), OpenAPI {report["openapi_version"]}:'
f' {summary["errors"]} error(s), {summary["warnings"]} warning(s),'
f' {summary["info"]} info finding(s).'
)
return '\n'.join(lines)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description=__doc__,
epilog=_EPILOG,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
'specs',
nargs='+',
metavar='SPEC',
help='OpenAPI file of the API config (JSON, or YAML with PyYAML).',
)
parser.add_argument(
'--format',
choices=('json', 'text'),
default='json',
help='Output format on stdout (default: json).',
)
args = parser.parse_args(argv)
paths = list(dict.fromkeys(args.specs))
try:
loaded = [(path, *load_spec(path)) for path in paths]
except InputError as exc:
print(f'Error: {exc}', file=sys.stderr)
return 2
versions = {path: _version(doc) for path, doc, _, _ in loaded}
known = sorted(set(versions.values()) - {'unknown'})
if len(known) > 1:
print(
f'Error: This linter checks one OpenAPI version per run ({versions}).'
' Lint files that use different versions in separate runs.',
file=sys.stderr,
)
return 2
version = known[0] if known else 'unknown'
linter = Linter(paths)
for path, doc, _, aliases in loaded:
linter.lint_file(path, doc, versions[path], aliases)
if version.startswith('3'):
linter.lint_set([size for _, _, size, _ in loaded])
findings = sorted(
linter.findings, key=lambda finding: _SEVERITY_RANK[finding['severity']]
)
counts = collections.Counter(finding['severity'] for finding in findings)
summary = {key: counts[severity] for key, severity in _SUMMARY_KEYS.items()}
report = {'files': paths, 'openapi_version': version, 'findings': findings}
report['summary'] = summary
if args.format == 'json':
print(json.dumps(report, indent=2))
else:
print(_format_text(report))
if summary['errors']:
print(
f'Found {summary["errors"]} ERROR finding(s). Fix them before you'
' create the API config.',
file=sys.stderr,
)
return 1 if summary['errors'] else 0
if __name__ == '__main__':
sys.exit(main())
Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Needs an API key, Needs a command-line tool, Needs Python
Security
What our scanner noticed· Low risk
- Instructions that use the network
- Scripts that your agent may run
This skill includes scripts that your agent may run. Read them before you use it.
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Published by
- Publisher
- Google (Official)
- Source
- google/skills · skills/cloud/api-gateway-troubleshooting · commit 2194d3b
- Licence
- Apache License 2.0 (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by Google.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download api-gateway-troubleshooting.zip