Files
wehub-resource-sync ec2b666284
Continuous Integration / Pre-commit Linter (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.10) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.11) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.12) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.10) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.11) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.12) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.14) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Waiting to run
Copybara PR Handler / close-imported-pr (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:25:13 +08:00

1109 lines
34 KiB
Python

# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Integration tests for /trigger/* endpoints.
Tests exercise the full FastAPI request → TriggerRouter → Runner pipeline
using the same TestClient pattern as test_fast_api.py, with a mocked Runner
that returns deterministic events.
"""
import asyncio
import base64
import json
import signal
from typing import Optional
from unittest.mock import AsyncMock
from unittest.mock import MagicMock
from unittest.mock import patch
from fastapi.testclient import TestClient
from google.adk.agents.base_agent import BaseAgent
from google.adk.agents.run_config import RunConfig
from google.adk.cli import fast_api as fast_api_module
from google.adk.cli.fast_api import get_fast_api_app
from google.adk.cli.trigger_routes import _is_transient_error
from google.adk.cli.trigger_routes import TransientError
from google.adk.cli.trigger_routes import TriggerRouter
from google.adk.events.event import Event
from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.genai import types
import pytest
# ---------------------------------------------------------------------------
# Dummy agent & mocked runner (same pattern as test_fast_api.py)
# ---------------------------------------------------------------------------
class DummyAgent(BaseAgent):
def __init__(self, name):
super().__init__(name=name)
self.sub_agents = []
root_agent = DummyAgent(name="trigger_test_agent")
def _model_event(text: str = "Agent reply") -> Event:
return Event(
author="trigger_test_agent",
invocation_id="inv-trigger",
content=types.Content(
role="model",
parts=[types.Part(text=text)],
),
)
async def dummy_run_async(
self,
user_id,
session_id,
new_message,
state_delta=None,
run_config: Optional[RunConfig] = None,
invocation_id: Optional[str] = None,
):
"""Mocked Runner.run_async that echoes input text back."""
# Extract the input text to echo it back — proves the pipeline works e2e
input_text = ""
if new_message and new_message.parts:
input_text = new_message.parts[0].text or ""
yield _model_event(f"Processed: {input_text}")
await asyncio.sleep(0)
async def dummy_run_async_error(
self,
user_id,
session_id,
new_message,
state_delta=None,
run_config: Optional[RunConfig] = None,
invocation_id: Optional[str] = None,
):
"""Mocked Runner.run_async that raises an exception."""
raise RuntimeError("Agent crashed")
yield # make it an async generator # noqa: E305
def _make_rate_limit_runner(fail_count: int):
"""Create a runner that fails with 429 `fail_count` times, then succeeds."""
call_count = {"value": 0}
async def dummy_run_async_rate_limit(
self,
user_id,
session_id,
new_message,
state_delta=None,
run_config: Optional[RunConfig] = None,
invocation_id: Optional[str] = None,
):
call_count["value"] += 1
if call_count["value"] <= fail_count:
raise RuntimeError("429 Resource has been exhausted")
input_text = ""
if new_message and new_message.parts:
input_text = new_message.parts[0].text or ""
yield _model_event(f"Processed: {input_text}")
await asyncio.sleep(0)
return dummy_run_async_rate_limit
async def dummy_run_async_always_429(
self,
user_id,
session_id,
new_message,
state_delta=None,
run_config: Optional[RunConfig] = None,
invocation_id: Optional[str] = None,
):
"""Mocked Runner.run_async that always raises a 429 error."""
raise RuntimeError("RESOURCE_EXHAUSTED: 429 quota exceeded")
yield # noqa: E305
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def patch_runner(monkeypatch):
monkeypatch.setattr(Runner, "run_async", dummy_run_async)
@pytest.fixture
def mock_agent_loader():
class MockAgentLoader:
def __init__(self, agents_dir: str):
pass
def load_agent(self, app_name):
return root_agent
def list_agents(self):
return ["test_app"]
def list_agents_detailed(self):
return [{
"name": "test_app",
"root_agent_name": "trigger_test_agent",
"description": "Test agent for triggers",
"language": "python",
"is_computer_use": False,
}]
return MockAgentLoader(".")
@pytest.fixture
def mock_session_service():
return InMemorySessionService()
@pytest.fixture
def mock_artifact_service():
service = AsyncMock()
service.list_artifact_keys = AsyncMock(return_value=[])
return service
@pytest.fixture
def mock_memory_service():
return AsyncMock()
def _make_test_client(
mock_session_service,
mock_artifact_service,
mock_memory_service,
mock_agent_loader,
trigger_sources: Optional[list[str]] = None,
) -> TestClient:
"""Build a TestClient with the given trigger setting."""
with (
patch.object(signal, "signal", autospec=True, return_value=None),
patch.object(
fast_api_module,
"create_session_service_from_options",
autospec=True,
return_value=mock_session_service,
),
patch.object(
fast_api_module,
"create_artifact_service_from_options",
autospec=True,
return_value=mock_artifact_service,
),
patch.object(
fast_api_module,
"create_memory_service_from_options",
autospec=True,
return_value=mock_memory_service,
),
patch.object(
fast_api_module,
"AgentLoader",
autospec=True,
return_value=mock_agent_loader,
),
patch.object(
fast_api_module,
"LocalEvalSetsManager",
autospec=True,
return_value=AsyncMock(),
),
patch.object(
fast_api_module,
"LocalEvalSetResultsManager",
autospec=True,
return_value=AsyncMock(),
),
):
app = get_fast_api_app(
agents_dir=".",
web=False,
session_service_uri="",
artifact_service_uri="",
memory_service_uri="",
allow_origins=["*"],
trigger_sources=trigger_sources,
)
return TestClient(app)
@pytest.fixture
def client(
mock_session_service,
mock_artifact_service,
mock_memory_service,
mock_agent_loader,
):
"""TestClient with all triggers enabled."""
return _make_test_client(
mock_session_service,
mock_artifact_service,
mock_memory_service,
mock_agent_loader,
trigger_sources=["pubsub", "eventarc"],
)
@pytest.fixture
def client_no_triggers(
mock_session_service,
mock_artifact_service,
mock_memory_service,
mock_agent_loader,
):
"""TestClient with triggers disabled (default)."""
return _make_test_client(
mock_session_service,
mock_artifact_service,
mock_memory_service,
mock_agent_loader,
trigger_sources=None,
)
# ===================================================================
# /apps/test_app/trigger/pubsub — Pub/Sub Push Subscription
# ===================================================================
class TestTriggerPubSub:
"""Integration tests for the Pub/Sub push subscription trigger."""
def test_success(self, client, monkeypatch):
"""Valid Pub/Sub message is processed and returns success."""
captured_messages = []
async def dummy_run_async_capture(
self, user_id, session_id, new_message, **kwargs
):
captured_messages.append(new_message.parts[0].text)
yield _model_event("Success")
await asyncio.sleep(0)
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
message_data = base64.b64encode(b"Hello from Pub/Sub").decode("utf-8")
payload = {
"message": {
"data": message_data,
"messageId": "msg-001",
},
"subscription": "projects/my-project/subscriptions/my-sub",
}
resp = client.post("/apps/test_app/trigger/pubsub", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "success"
assert len(captured_messages) == 1
parsed_msg = json.loads(captured_messages[0])
assert parsed_msg["data"] == "Hello from Pub/Sub"
assert parsed_msg["attributes"] == {}
def test_message_with_attributes(self, client, monkeypatch):
"""Pub/Sub message with attributes (no data) is processed."""
captured_messages = []
async def dummy_run_async_capture(
self, user_id, session_id, new_message, **kwargs
):
captured_messages.append(new_message.parts[0].text)
yield _model_event("Success")
await asyncio.sleep(0)
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
payload = {
"message": {
"attributes": {"key": "value", "action": "process"},
"messageId": "msg-002",
},
}
resp = client.post("/apps/test_app/trigger/pubsub", json=payload)
assert resp.status_code == 200
assert resp.json()["status"] == "success"
assert len(captured_messages) == 1
parsed_msg = json.loads(captured_messages[0])
assert parsed_msg["data"] is None
assert parsed_msg["attributes"] == {"key": "value", "action": "process"}
def test_json_payload_in_data(self, client, monkeypatch):
"""JSON-encoded data in Pub/Sub message is decoded properly."""
captured_messages = []
async def dummy_run_async_capture(
self, user_id, session_id, new_message, **kwargs
):
captured_messages.append(new_message.parts[0].text)
yield _model_event("Success")
await asyncio.sleep(0)
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
inner_json = json.dumps({"order_id": 42, "amount": 99.99})
message_data = base64.b64encode(inner_json.encode("utf-8")).decode("utf-8")
payload = {
"message": {
"data": message_data,
"messageId": "msg-003",
},
}
resp = client.post("/apps/test_app/trigger/pubsub", json=payload)
assert resp.status_code == 200
assert resp.json()["status"] == "success"
assert len(captured_messages) == 1
parsed_msg = json.loads(captured_messages[0])
assert parsed_msg["data"] == {"order_id": 42, "amount": 99.99}
assert parsed_msg["attributes"] == {}
def test_invalid_base64_returns_400(self, client):
"""Invalid base64 data returns 400."""
payload = {
"message": {
"data": "!!!not-valid-base64!!!",
"messageId": "msg-bad",
},
}
resp = client.post("/apps/test_app/trigger/pubsub", json=payload)
assert resp.status_code == 400
assert "base64" in resp.json()["detail"].lower()
def test_agent_error_returns_500(self, client, monkeypatch):
"""Agent failure returns 500, allowing Pub/Sub to retry."""
monkeypatch.setattr(Runner, "run_async", dummy_run_async_error)
message_data = base64.b64encode(b"trigger error").decode("utf-8")
payload = {
"message": {"data": message_data},
}
resp = client.post("/apps/test_app/trigger/pubsub", json=payload)
assert resp.status_code == 500
assert "Agent processing failed" in resp.json()["detail"]
def test_with_subscription_metadata(self, client, monkeypatch):
"""Subscription field is sanitized for user_id (slashes replaced)."""
captured_user_ids = []
async def dummy_run_async_capture(
self, user_id, session_id, new_message, **kwargs
):
captured_user_ids.append(user_id)
yield _model_event("Success")
await asyncio.sleep(0)
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
message_data = base64.b64encode(b"test").decode("utf-8")
payload = {
"message": {"data": message_data},
"subscription": "projects/p/subscriptions/orders-sub",
}
resp = client.post("/apps/test_app/trigger/pubsub", json=payload)
assert resp.status_code == 200
assert len(captured_user_ids) == 1
assert captured_user_ids[0] == "projects--p--subscriptions--orders-sub"
assert "/" not in captured_user_ids[0]
def test_default_user_id_when_no_subscription(self, client, monkeypatch):
"""Default user_id is used when subscription is absent."""
captured_user_ids = []
async def dummy_run_async_capture(
self, user_id, session_id, new_message, **kwargs
):
captured_user_ids.append(user_id)
yield _model_event("Success")
await asyncio.sleep(0)
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
message_data = base64.b64encode(b"test").decode("utf-8")
payload = {
"message": {"data": message_data},
}
resp = client.post("/apps/test_app/trigger/pubsub", json=payload)
assert resp.status_code == 200
assert len(captured_user_ids) == 1
assert captured_user_ids[0] == "pubsub-caller"
def test_unknown_app_fails_early(
self, client, mock_agent_loader, mock_session_service
):
"""Unknown app fails early and does NOT create a session."""
def load_agent_raising(app_name):
if app_name == "unknown_app":
raise Exception("App not found")
return root_agent
mock_agent_loader.load_agent = load_agent_raising
message_data = base64.b64encode(b"test").decode("utf-8")
payload = {
"message": {"data": message_data},
}
resp = client.post("/apps/unknown_app/trigger/pubsub", json=payload)
assert resp.status_code == 500
assert "unknown_app" not in mock_session_service.sessions
# ===================================================================
# /apps/test_app/trigger/eventarc — Eventarc / CloudEvents
# ===================================================================
class TestTriggerEventarc:
"""Integration tests for the Eventarc / CloudEvents trigger."""
def test_success(self, client, monkeypatch):
"""Valid CloudEvent payload is processed and returns success."""
captured_messages = []
async def dummy_run_async_capture(
self, user_id, session_id, new_message, **kwargs
):
captured_messages.append(new_message.parts[0].text)
yield _model_event("Success")
await asyncio.sleep(0)
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
payload = {
"data": {
"bucket": "my-bucket",
"name": "path/to/file.pdf",
"contentType": "application/pdf",
},
"source": "storage.googleapis.com",
"type": "google.cloud.storage.object.v1.finalized",
"id": "evt-001",
"specversion": "1.0",
}
resp = client.post("/apps/test_app/trigger/eventarc", json=payload)
assert resp.status_code == 200
assert resp.json()["status"] == "success"
assert len(captured_messages) == 1
parsed_msg = json.loads(captured_messages[0])
assert parsed_msg["data"] == payload["data"]
assert parsed_msg["attributes"]["ce-id"] == "evt-001"
assert (
parsed_msg["attributes"]["ce-type"]
== "google.cloud.storage.object.v1.finalized"
)
def test_source_derived_from_body_sanitized(self, client, monkeypatch):
"""Source from body is sanitized for user_id (slashes replaced)."""
captured_user_ids = []
async def dummy_run_async_capture(
self, user_id, session_id, new_message, **kwargs
):
captured_user_ids.append(user_id)
yield _model_event("Success")
await asyncio.sleep(0)
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
payload = {
"data": {"key": "value"},
"source": "//pubsub.googleapis.com/projects/p/topics/t",
}
resp = client.post("/apps/test_app/trigger/eventarc", json=payload)
assert resp.status_code == 200
assert len(captured_user_ids) == 1
assert (
captured_user_ids[0] == "pubsub.googleapis.com--projects--p--topics--t"
)
assert "/" not in captured_user_ids[0]
def test_source_from_ce_header_sanitized(self, client, monkeypatch):
"""ce-source header is sanitized for user_id (slashes replaced)."""
captured_user_ids = []
async def dummy_run_async_capture(
self, user_id, session_id, new_message, **kwargs
):
captured_user_ids.append(user_id)
yield _model_event("Success")
await asyncio.sleep(0)
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
payload = {
"data": {"key": "value"},
}
resp = client.post(
"/apps/test_app/trigger/eventarc",
json=payload,
headers={
"ce-source": (
"//storage.googleapis.com/projects/_/buckets/my-bucket"
),
},
)
assert resp.status_code == 200
assert len(captured_user_ids) == 1
assert (
captured_user_ids[0]
== "storage.googleapis.com--projects--_--buckets--my-bucket"
)
assert "/" not in captured_user_ids[0]
def test_default_user_id_when_no_source(self, client, monkeypatch):
"""Default user_id is used when source is absent."""
captured_user_ids = []
async def dummy_run_async_capture(
self, user_id, session_id, new_message, **kwargs
):
captured_user_ids.append(user_id)
yield _model_event("Success")
await asyncio.sleep(0)
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
payload = {
"data": {"key": "value"},
}
resp = client.post("/apps/test_app/trigger/eventarc", json=payload)
assert resp.status_code == 200
assert len(captured_user_ids) == 1
assert captured_user_ids[0] == "eventarc-caller"
def test_complex_event_data(self, client, monkeypatch):
"""Complex nested event data is serialized as JSON for the agent."""
captured_messages = []
async def dummy_run_async_capture(
self, user_id, session_id, new_message, **kwargs
):
captured_messages.append(new_message.parts[0].text)
yield _model_event("Success")
await asyncio.sleep(0)
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
payload = {
"data": {
"resource": {
"name": "projects/p/topics/t",
"labels": {"env": "prod"},
},
"insertId": "abc123",
"timestamp": "2026-01-01T00:00:00Z",
},
}
resp = client.post("/apps/test_app/trigger/eventarc", json=payload)
assert resp.status_code == 200
assert resp.json()["status"] == "success"
assert len(captured_messages) == 1
parsed_msg = json.loads(captured_messages[0])
assert parsed_msg["data"] == payload["data"]
def test_agent_error_returns_500(self, client, monkeypatch):
"""Agent failure returns 500, allowing Eventarc to retry."""
monkeypatch.setattr(Runner, "run_async", dummy_run_async_error)
payload = {
"data": {"trigger": "error"},
}
resp = client.post("/apps/test_app/trigger/eventarc", json=payload)
assert resp.status_code == 500
assert "Agent processing failed" in resp.json()["detail"]
def test_minimal_payload(self, client, monkeypatch):
"""Minimal payload with just data field works."""
captured_messages = []
async def dummy_run_async_capture(
self, user_id, session_id, new_message, **kwargs
):
captured_messages.append(new_message.parts[0].text)
yield _model_event("Success")
await asyncio.sleep(0)
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
payload = {"data": {}}
resp = client.post("/apps/test_app/trigger/eventarc", json=payload)
assert resp.status_code == 200
assert len(captured_messages) == 1
parsed_msg = json.loads(captured_messages[0])
assert parsed_msg["data"] == {}
def test_structured_mode_pubsub_wrapper(self, client, monkeypatch):
"""Eventarc structured mode with Pub/Sub envelope is base64-decoded."""
captured_messages = []
async def dummy_run_async_capture(
self, user_id, session_id, new_message, **kwargs
):
captured_messages.append(new_message.parts[0].text)
yield _model_event("Success")
await asyncio.sleep(0)
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
inner_message = "Hello from structured Eventarc"
encoded_message = base64.b64encode(inner_message.encode("utf-8")).decode(
"utf-8"
)
payload = {
"data": {
"message": {
"data": encoded_message,
}
},
"source": "my-source",
}
resp = client.post("/apps/test_app/trigger/eventarc", json=payload)
assert resp.status_code == 200
assert resp.json()["status"] == "success"
assert len(captured_messages) == 1
parsed_msg = json.loads(captured_messages[0])
assert parsed_msg["data"] == "Hello from structured Eventarc"
assert parsed_msg["attributes"] == {}
def test_binary_content_mode_pubsub_wrapper(self, client, monkeypatch):
"""Binary content mode: Pub/Sub message wrapper in body, CE attrs in headers."""
captured_messages = []
async def dummy_run_async_capture(
self, user_id, session_id, new_message, **kwargs
):
captured_messages.append(new_message.parts[0].text)
yield _model_event("Success")
await asyncio.sleep(0)
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
payload = {
"message": {
"data": base64.b64encode(b"hello from eventarc").decode(),
"messageId": "evt-msg-001",
},
"subscription": "projects/p/subscriptions/eventarc-sub",
}
resp = client.post(
"/apps/test_app/trigger/eventarc",
json=payload,
headers={
"ce-source": "//pubsub.googleapis.com/projects/p/topics/t",
"ce-type": "google.cloud.pubsub.topic.v1.messagePublished",
"ce-id": "binary-test-1",
"ce-specversion": "1.0",
},
)
assert resp.status_code == 200
assert resp.json()["status"] == "success"
assert len(captured_messages) == 1
parsed_msg = json.loads(captured_messages[0])
assert parsed_msg["data"] == "hello from eventarc"
assert parsed_msg["attributes"] == {}
def test_binary_content_mode_attributes_only(self, client, monkeypatch):
"""Binary content mode with attributes only (no data)."""
captured_messages = []
async def dummy_run_async_capture(
self, user_id, session_id, new_message, **kwargs
):
captured_messages.append(new_message.parts[0].text)
yield _model_event("Success")
await asyncio.sleep(0)
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
payload = {
"message": {
"attributes": {"key": "value"},
"messageId": "evt-msg-002",
},
}
resp = client.post(
"/apps/test_app/trigger/eventarc",
json=payload,
headers={"ce-source": "//pubsub.googleapis.com/test"},
)
assert resp.status_code == 200
assert resp.json()["status"] == "success"
assert len(captured_messages) == 1
parsed_msg = json.loads(captured_messages[0])
assert parsed_msg["data"] is None
assert parsed_msg["attributes"] == {"key": "value"}
def test_binary_content_mode_arbitrary_payload(self, client, monkeypatch):
"""Binary content mode with arbitrary JSON payload (not Pub/Sub)."""
captured_message = []
async def dummy_run_async_capture(
self, user_id, session_id, new_message, **kwargs
):
captured_message.append(new_message.parts[0].text)
yield _model_event("Success")
await asyncio.sleep(0)
monkeypatch.setattr(Runner, "run_async", dummy_run_async_capture)
payload = {
"bucket": "my-bucket",
"name": "file.txt",
"contentType": "application/json",
}
resp = client.post(
"/apps/test_app/trigger/eventarc",
json=payload,
headers={
"ce-source": (
"//storage.googleapis.com/projects/_/buckets/my-bucket"
),
"ce-type": "google.cloud.storage.object.v1.finalized",
"ce-id": "12345",
"ce-specversion": "1.0",
},
)
assert resp.status_code == 200
assert len(captured_message) == 1
received_data = json.loads(captured_message[0])
assert received_data["data"]["bucket"] == "my-bucket"
assert received_data["data"]["name"] == "file.txt"
assert received_data["attributes"]["ce-id"] == "12345"
# ===================================================================
# Triggers disabled (default behavior)
# ===================================================================
class TestTriggersDisabled:
"""Verify trigger endpoints return 404 when not enabled."""
def test_pubsub_returns_404(self, client_no_triggers):
resp = client_no_triggers.post(
"/apps/test_app/trigger/pubsub",
json={"message": {"data": base64.b64encode(b"x").decode()}},
)
assert resp.status_code == 404
def test_eventarc_returns_404(self, client_no_triggers):
resp = client_no_triggers.post(
"/apps/test_app/trigger/eventarc", json={"data": {}}
)
assert resp.status_code == 404
# ===================================================================
# Transient error detection
# ===================================================================
class TestTransientErrorDetection:
"""Unit tests for the _is_transient_error helper."""
def test_429_in_message(self):
assert _is_transient_error(RuntimeError("HTTP 429 Too Many Requests"))
def test_resource_exhausted(self):
assert _is_transient_error(RuntimeError("RESOURCE_EXHAUSTED"))
def test_rate_limit(self):
assert _is_transient_error(RuntimeError("rate limit exceeded"))
def test_quota(self):
assert _is_transient_error(RuntimeError("quota exceeded for project"))
def test_non_transient(self):
assert not _is_transient_error(RuntimeError("Agent crashed"))
def test_permission_denied(self):
assert not _is_transient_error(RuntimeError("PERMISSION_DENIED"))
# ===================================================================
# Retry with exponential backoff
# ===================================================================
class TestRetryLogic:
"""Integration tests for retry with exponential backoff on 429 errors."""
def test_pubsub_retry_exhausted_returns_500(self, client, monkeypatch):
"""Pub/Sub trigger returns 500 when retries are exhausted."""
monkeypatch.setattr(Runner, "run_async", dummy_run_async_always_429)
with patch(
"google.adk.cli.trigger_routes.asyncio.sleep", new_callable=AsyncMock
):
message_data = base64.b64encode(b"429 test").decode("utf-8")
payload = {"message": {"data": message_data}}
resp = client.post("/apps/test_app/trigger/pubsub", json=payload)
assert resp.status_code == 500
assert "Rate limit" in resp.json()["detail"]
def test_eventarc_retry_exhausted_returns_500(self, client, monkeypatch):
"""Eventarc trigger returns 500 when retries are exhausted."""
monkeypatch.setattr(Runner, "run_async", dummy_run_async_always_429)
with patch(
"google.adk.cli.trigger_routes.asyncio.sleep", new_callable=AsyncMock
):
payload = {"data": {"test": "429"}}
resp = client.post("/apps/test_app/trigger/eventarc", json=payload)
assert resp.status_code == 500
assert "Rate limit" in resp.json()["detail"]
def test_non_transient_error_not_retried(self, client, monkeypatch):
"""Non-429 errors are NOT retried — they fail immediately."""
call_count = 0
async def counting_error_runner(
self, user_id, session_id, new_message, **kwargs
):
nonlocal call_count
call_count += 1
raise RuntimeError("PERMISSION_DENIED: no access")
yield # noqa: E305
monkeypatch.setattr(Runner, "run_async", counting_error_runner)
with patch(
"google.adk.cli.trigger_routes.asyncio.sleep", new_callable=AsyncMock
):
payload = {"data": {"test": True}}
resp = client.post("/apps/test_app/trigger/eventarc", json=payload)
assert resp.status_code == 500
# Non-transient errors should NOT be retried — only 1 call
assert call_count == 1
# ===================================================================
# Semaphore / concurrency control
# ===================================================================
class TestConcurrencyControl:
"""Tests for semaphore-based concurrency limiting."""
def test_concurrent_pubsub_and_eventarc(self, client):
"""Multiple trigger types can be called without semaphore starvation."""
# Pub/Sub
ps_resp = client.post(
"/apps/test_app/trigger/pubsub",
json={"message": {"data": base64.b64encode(b"ps").decode()}},
)
assert ps_resp.status_code == 200
# Eventarc
ea_resp = client.post(
"/apps/test_app/trigger/eventarc",
json={"data": {"key": "value"}},
)
assert ea_resp.status_code == 200
# ===================================================================
# Selective trigger registration
# ===================================================================
class TestSelectiveRegistration:
"""Tests that only requested trigger sources are registered."""
def test_only_pubsub(
self,
mock_session_service,
mock_artifact_service,
mock_memory_service,
mock_agent_loader,
):
"""When trigger_sources=['pubsub'], only Pub/Sub is available."""
client = _make_test_client(
mock_session_service,
mock_artifact_service,
mock_memory_service,
mock_agent_loader,
trigger_sources=["pubsub"],
)
# Pub/Sub should work
ps_resp = client.post(
"/apps/test_app/trigger/pubsub",
json={"message": {"data": base64.b64encode(b"test").decode()}},
)
assert ps_resp.status_code == 200
# Eventarc should NOT be available
ea_resp = client.post("/apps/test_app/trigger/eventarc", json={"data": {}})
assert ea_resp.status_code == 404
def test_only_eventarc(
self,
mock_session_service,
mock_artifact_service,
mock_memory_service,
mock_agent_loader,
):
"""When trigger_sources=['eventarc'], only Eventarc is available."""
client = _make_test_client(
mock_session_service,
mock_artifact_service,
mock_memory_service,
mock_agent_loader,
trigger_sources=["eventarc"],
)
# Eventarc should work
ea_resp = client.post(
"/apps/test_app/trigger/eventarc", json={"data": {"k": "v"}}
)
assert ea_resp.status_code == 200
# Pub/Sub should NOT be available
ps_resp = client.post(
"/apps/test_app/trigger/pubsub",
json={"message": {"data": base64.b64encode(b"x").decode()}},
)
assert ps_resp.status_code == 404
class TestUnknownTriggerSources:
"""Verify unknown trigger sources are filtered and warned about."""
def test_unknown_source_ignored(
self,
mock_session_service,
mock_artifact_service,
mock_memory_service,
mock_agent_loader,
):
"""Unknown source is silently dropped; valid sources still work."""
client = _make_test_client(
mock_session_service,
mock_artifact_service,
mock_memory_service,
mock_agent_loader,
trigger_sources=["unknown_source", "pubsub"],
)
# "pubsub" should still be registered
ps_resp = client.post(
"/apps/test_app/trigger/pubsub",
json={"message": {"data": base64.b64encode(b"test").decode()}},
)
assert ps_resp.status_code == 200
# "unknown_source" should NOT be registered
unknown_resp = client.post(
"/apps/test_app/trigger/unknown_source", json={"calls": [["test"]]}
)
assert unknown_resp.status_code == 404
def test_all_unknown_sources_results_in_no_endpoints(
self,
mock_session_service,
mock_artifact_service,
mock_memory_service,
mock_agent_loader,
):
"""All invalid sources means no trigger endpoints registered."""
client = _make_test_client(
mock_session_service,
mock_artifact_service,
mock_memory_service,
mock_agent_loader,
trigger_sources=["foo", "bar"],
)
unknown_resp = client.post(
"/apps/test_app/trigger/unknown_source", json={"calls": [["test"]]}
)
assert unknown_resp.status_code == 404
class TestTriggersDisabled:
"""Verify trigger endpoints return 404 when not enabled."""
def test_pubsub_returns_404(
self,
mock_session_service,
mock_artifact_service,
mock_memory_service,
mock_agent_loader,
):
client = _make_test_client(
mock_session_service,
mock_artifact_service,
mock_memory_service,
mock_agent_loader,
trigger_sources=[],
)
resp = client.post(
"/apps/test_app/trigger/pubsub",
json={"message": {"data": base64.b64encode(b"x").decode()}},
)
assert resp.status_code == 404
def test_eventarc_returns_404(
self,
mock_session_service,
mock_artifact_service,
mock_memory_service,
mock_agent_loader,
):
client = _make_test_client(
mock_session_service,
mock_artifact_service,
mock_memory_service,
mock_agent_loader,
trigger_sources=[],
)
resp = client.post("/apps/test_app/trigger/eventarc", json={"data": {}})
assert resp.status_code == 404