Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/clusterfuzz/_internal/base/feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class FeatureFlags(Enum):

ENABLE_FUZZ_FOR_BOTS = 'enable_fuzz_for_bots'
STORAGE_THREADED_OPS_FUZZ_TARGETS = 'storage_threaded_ops_fuzz_targets'
CALL_ANDROID_API = 'call_android_api'

@property
def flag(self):
Expand Down
44 changes: 37 additions & 7 deletions src/clusterfuzz/_internal/base/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,16 +116,46 @@ def encode_as_unicode(obj):
retries=URL_REQUEST_RETRIES,
delay=URL_REQUEST_FAIL_WAIT,
function='base.utils.fetch_url')
def fetch_url(url):
"""Fetch url content."""
operations_timeout = environment.get_value('URL_BLOCKING_OPERATIONS_TIMEOUT')

response = requests.get(url, timeout=operations_timeout)
if response.status_code == 404:
def fetch_url(url: str,
params: dict | None = None,
headers: dict | None = None,
request_timeout: int | float | None = None,
raise_for_not_found: bool = False,
stream: bool = False) -> str | requests.Response | None:
"""Launches an HTTP GET request with retries.

Args:
url: Target URL for the GET request.
params: Optional query parameters dictionary.
headers: Optional additional headers dictionary.
request_timeout: Optional timeout for the request in seconds.
raise_for_not_found: False by default, raise an exception for 404
response, otherwise returns None on 404.
stream: False by default, whether to stream the response content.

Returns:
The HTTP response text on success (or response object if stream=True),
None if 404 and raise_for_not_found is False.

Raises:
requests.exceptions.RequestException: If the HTTP request fails or
returns an error status.
"""
if request_timeout is None:
request_timeout = environment.get_value('URL_BLOCKING_OPERATIONS_TIMEOUT')

logs.info(f'Request for {url}', params=params, headers=headers, stream=stream)
response = requests.get(
url,
timeout=request_timeout,
params=params,
headers=headers,
stream=stream)
if not raise_for_not_found and response.status_code == 404:
return None

response.raise_for_status()
return response.text
return response if stream else response.text


@retry.wrap(
Expand Down
269 changes: 269 additions & 0 deletions src/clusterfuzz/_internal/platforms/android/android_build_v4_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
# 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.
"""HTTP client for Android Build API V4 REST endpoints."""
Comment thread
dylanjew marked this conversation as resolved.

import json
import os
from urllib.parse import quote

import google.auth.exceptions
from google.auth.transport.requests import Request
from google.oauth2 import service_account
import requests

from clusterfuzz._internal.base import utils
from clusterfuzz._internal.metrics import logs

# HTTP request timeout in seconds.
HTTP_TIMEOUT = 60

# 20 MB default chunk size for file downloads.
DEFAULT_CHUNK_SIZE = 20 * 1024 * 1024

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.



class AndroidBuildV4Api:
"""HTTP client for Android Build API V4 REST endpoints."""

BASE_URL = 'https://androidbuild-pa.googleapis.com'

def __init__(self, token: str):
"""Initializes the Android Build API V4 client.

Do not call this directly. Use AndroidBuildV4Api.create_authenticated()
instead.

Args:
token: OAuth2 authorization bearer token.
"""
self.token = token

@staticmethod
def create_authenticated(
credentials: service_account.Credentials) -> 'AndroidBuildV4Api':
"""Validates credentials and constructs an authenticated client instance.

Args:
credentials: Service account credentials for authenticating requests.

Returns:
An instance of AndroidBuildV4Api ready for requests.

Raises:
google.auth.exceptions.GoogleAuthError: If authentication token refresh
fails.
"""
if not credentials.valid:
credentials.refresh(Request())
return AndroidBuildV4Api(credentials.token)

def _get_headers(self) -> dict[str, str]:
"""Returns headers required for Android Build API V4 requests.

Returns:
A dictionary containing the Authorization and Accept headers.
"""
return {
'Authorization': f'Bearer {self.token}',
'Accept': 'application/json',
}

def _download_file(self, url: str, output_path: str) -> None:
"""Downloads content from a URL to output_path.

Args:
url: Download URL.
output_path: Local filesystem path where the file should be saved.

Raises:
requests.exceptions.RequestException: If the HTTP download fails.
OSError: If creating directories or writing the file fails.
"""
dirname = os.path.dirname(output_path)
Comment thread
IvanBM18 marked this conversation as resolved.
if dirname:
os.makedirs(dirname, exist_ok=True)

response = utils.fetch_url(
url,
request_timeout=HTTP_TIMEOUT,
raise_for_not_found=True,
stream=True)
with open(output_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=DEFAULT_CHUNK_SIZE):
if chunk:
f.write(chunk)

def list_builds(self, branch: str, target: str,
signed: bool = False) -> dict | None:
"""List builds for a branch and target.

Args:
branch: Android build branch (e.g. 'git_main').
target: Android build target (e.g. 'cf_x86_64_phone-next-userdebug').
signed: Whether to request signed builds only.

Returns:
JSON response dictionary containing builds, or None on failure.
"""
params = {
'buildType': 'submitted',
'branches': branch,
'targets': target,
'successful': 'true',
'pageSize': 1,
}
if signed:
params['signed'] = 'true'

url = f'{self.BASE_URL}/v4/builds'
try:
response_text = utils.fetch_url(
url,
params=params,
headers=self._get_headers(),
request_timeout=HTTP_TIMEOUT,
raise_for_not_found=True)
data = json.loads(response_text)
if data.get('builds') and len(data['builds']) > 0:
return data
return None
except (requests.exceptions.RequestException,
google.auth.exceptions.GoogleAuthError, ValueError) as e:
logs.error(
f'V4 list_builds failed for branch {branch}, target {target}: {e}')
return None

def list_artifacts(self,
bid: str,
target: str,
attempt_id: str = 'latest',
regexp: str | None = None,
page_size: int = 100) -> list:
"""List artifacts for a given build.

Args:
bid: Android build ID.
target: Android build target name.
attempt_id: Build attempt identifier (defaults to 'latest').
regexp: Optional regular expression pattern to filter artifact names.
page_size: Number of artifacts per page (defaults to 100).

Returns:
List of artifact dictionary objects.
"""
params = {'pageSize': page_size}
if regexp:
params['nameRegexp'] = regexp

path = f'/v4/builds/{bid}/{target}/attempts/{attempt_id}/artifacts'
url = f'{self.BASE_URL}{path}'
artifacts = []

page_token = None
while True:
if page_token:
params['pageToken'] = page_token

try:
response_text = utils.fetch_url(
url,
params=params,
headers=self._get_headers(),
request_timeout=HTTP_TIMEOUT,
raise_for_not_found=True)
result = json.loads(response_text)
except (requests.exceptions.RequestException,
google.auth.exceptions.GoogleAuthError, ValueError) as e:
logs.error(
f'V4 list_artifacts failed for build {bid}, target {target}: {e}')
break

if 'artifacts' in result:
artifacts.extend(result['artifacts'])

page_token = result.get('nextPageToken')
if not page_token:
break

return artifacts

def get_artifact_metadata(self, bid: str, target: str, attempt_id: str,
name: str) -> dict | None:
"""Get artifact metadata.

Args:
bid: Android build ID.
target: Android build target name.
attempt_id: Build attempt identifier.
name: Artifact name.

Returns:
Artifact metadata dictionary, or None on failure.
"""
resource_id = quote(name, safe='')
path = (f'/v4/builds/{bid}/{target}/attempts/{attempt_id}'
f'/artifacts/{resource_id}')
url = f'{self.BASE_URL}{path}'
try:
response_text = utils.fetch_url(
url,
headers=self._get_headers(),
request_timeout=HTTP_TIMEOUT,
raise_for_not_found=True)
data = json.loads(response_text)
return data.get('buildArtifactMetadata', data)
except (requests.exceptions.RequestException,
google.auth.exceptions.GoogleAuthError, ValueError) as e:
logs.error(f'V4 get_artifact_metadata failed for artifact {name}: {e}')
return None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason we catch and log instead of just allowing the error to raise? Was this the previous behavior?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, in the V3 API implementation, all HTTP requests were wrapped in _execute_request_with_retries(request)

When failed, it catched the exception logged an error and then returned None


def download_artifact_file(self, bid: str, target: str, attempt_id: str,
name: str, output_path: str) -> bool:
"""Download artifact file content using signed URL.

Args:
bid: Android build ID.
target: Android build target name.
attempt_id: Build attempt identifier.
name: Artifact name.
output_path: Local filesystem path where the file should be saved.

Returns:
True if download succeeded, False otherwise.
"""
resource_id = quote(name, safe='')
path = (f'/v4/builds/{bid}/{target}/attempts/{attempt_id}'
f'/artifacts/{resource_id}/url')
url = f'{self.BASE_URL}{path}'
try:
response_text = utils.fetch_url(
url,
headers=self._get_headers(),
request_timeout=HTTP_TIMEOUT,
raise_for_not_found=True)
signed_url = json.loads(response_text).get('signedUrl')
except (requests.exceptions.RequestException,
google.auth.exceptions.GoogleAuthError, ValueError) as e:
logs.error(f'Error getting V4 download url for artifact {name}: {e}')
return False

if not signed_url:
logs.error(f'V4 download url missing in response for artifact {name}')
return False

try:
self._download_file(signed_url, output_path)
return True
except (requests.exceptions.RequestException, OSError) as e:
logs.error(f'Error downloading V4 media for artifact {name}: {e}')
return False
Loading
Loading