1 /*
2  * Copyright (C) 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.federatedcompute.services.http;
18 
19 import static com.android.federatedcompute.services.common.FederatedComputeExecutors.getBlockingExecutor;
20 import static com.android.federatedcompute.services.http.HttpClientUtil.HTTP_OK_STATUS;
21 
22 import android.annotation.NonNull;
23 import android.annotation.Nullable;
24 
25 import com.android.federatedcompute.internal.util.LogUtil;
26 import com.android.federatedcompute.services.common.Flags;
27 import com.android.federatedcompute.services.common.PhFlags;
28 
29 import com.google.common.annotations.VisibleForTesting;
30 import com.google.common.util.concurrent.Futures;
31 import com.google.common.util.concurrent.ListenableFuture;
32 
33 import java.io.BufferedOutputStream;
34 import java.io.ByteArrayOutputStream;
35 import java.io.IOException;
36 import java.io.InputStream;
37 import java.net.HttpURLConnection;
38 import java.net.MalformedURLException;
39 import java.net.URL;
40 import java.net.URLConnection;
41 import java.util.Map;
42 import java.util.Objects;
43 import java.util.concurrent.TimeUnit;
44 
45 /**
46  * The HTTP client to be used by the FederatedCompute to communicate with remote federated servers.
47  */
48 public class HttpClient {
49     private static final String TAG = HttpClient.class.getSimpleName();
50     private static final int NETWORK_CONNECT_TIMEOUT_MS = (int) TimeUnit.SECONDS.toMillis(5);
51     private static final int NETWORK_READ_TIMEOUT_MS = (int) TimeUnit.SECONDS.toMillis(30);
52     private final Flags mFlags;
53 
HttpClient()54     public HttpClient() {
55         mFlags = PhFlags.getInstance();
56     }
57 
58     @NonNull
59     @VisibleForTesting
setup(@onNull URL url)60     URLConnection setup(@NonNull URL url) throws IOException {
61         Objects.requireNonNull(url);
62         URLConnection urlConnection = url.openConnection();
63         urlConnection.setConnectTimeout(NETWORK_CONNECT_TIMEOUT_MS);
64         urlConnection.setReadTimeout(NETWORK_READ_TIMEOUT_MS);
65         return urlConnection;
66     }
67 
68     /**
69      * Perform HTTP requests based on given information asynchronously with retries in case http
70      * will return not OK response code.
71      */
72     @NonNull
performRequestAsyncWithRetry( FederatedComputeHttpRequest request)73     public ListenableFuture<FederatedComputeHttpResponse> performRequestAsyncWithRetry(
74             FederatedComputeHttpRequest request) {
75         try {
76             return getBlockingExecutor().submit(() -> performRequestWithRetry(request));
77         } catch (Exception e) {
78             return Futures.immediateFailedFuture(e);
79         }
80     }
81 
82     /** Perform HTTP requests based on given information with retries. */
83     @NonNull
performRequestWithRetry(FederatedComputeHttpRequest request)84     public FederatedComputeHttpResponse performRequestWithRetry(FederatedComputeHttpRequest request)
85             throws IOException {
86         int count = 0;
87         FederatedComputeHttpResponse response = null;
88         int retryLimit = mFlags.getHttpRequestRetryLimit();
89         while (count < retryLimit) {
90             try {
91                 response = performRequest(request);
92                 if (HTTP_OK_STATUS.contains(response.getStatusCode())) {
93                     return response;
94                 }
95                 // we want to continue retry in case it is IO exception.
96             } catch (IOException e) {
97                 // propagate IO exception after RETRY_LIMIT times attempt.
98                 if (count >= retryLimit - 1) {
99                     throw e;
100                 }
101             } finally {
102                 count++;
103             }
104         }
105         return response;
106     }
107 
108     /** Perform HTTP requests based on given information. */
109     @NonNull
performRequest(FederatedComputeHttpRequest request)110     public FederatedComputeHttpResponse performRequest(FederatedComputeHttpRequest request)
111             throws IOException {
112         if (request.getUri() == null || request.getHttpMethod() == null) {
113             LogUtil.e(TAG, "Endpoint or http method is empty");
114             throw new IllegalArgumentException("Endpoint or http method is empty");
115         }
116 
117         URL url;
118         try {
119             url = new URL(request.getUri());
120         } catch (MalformedURLException e) {
121             LogUtil.e(TAG, e, "Malformed registration target URL");
122             throw new IllegalArgumentException("Malformed registration target URL", e);
123         }
124 
125         HttpURLConnection urlConnection;
126         try {
127             urlConnection = (HttpURLConnection) setup(url);
128         } catch (IOException e) {
129             LogUtil.e(TAG, e, "Failed to open target URL");
130             throw new IOException("Failed to open target URL", e);
131         }
132 
133         try {
134             urlConnection.setRequestMethod(request.getHttpMethod().name());
135             urlConnection.setInstanceFollowRedirects(true);
136 
137             if (request.getExtraHeaders() != null && !request.getExtraHeaders().isEmpty()) {
138                 for (Map.Entry<String, String> entry : request.getExtraHeaders().entrySet()) {
139                     urlConnection.setRequestProperty(entry.getKey(), entry.getValue());
140                 }
141             }
142 
143             if (request.getBody() != null && request.getBody().length > 0) {
144                 urlConnection.setDoOutput(true);
145                 try (BufferedOutputStream out =
146                         new BufferedOutputStream(urlConnection.getOutputStream())) {
147                     out.write(request.getBody());
148                 }
149             }
150 
151             int responseCode = urlConnection.getResponseCode();
152             if (HTTP_OK_STATUS.contains(responseCode)) {
153                 return new FederatedComputeHttpResponse.Builder()
154                         .setPayload(
155                                 getByteArray(
156                                         urlConnection.getInputStream(),
157                                         urlConnection.getContentLengthLong()))
158                         .setHeaders(urlConnection.getHeaderFields())
159                         .setStatusCode(responseCode)
160                         .build();
161             } else {
162                 return new FederatedComputeHttpResponse.Builder()
163                         .setPayload(
164                                 getByteArray(
165                                         urlConnection.getErrorStream(),
166                                         urlConnection.getContentLengthLong()))
167                         .setHeaders(urlConnection.getHeaderFields())
168                         .setStatusCode(responseCode)
169                         .build();
170             }
171         } catch (IOException e) {
172             LogUtil.e(TAG, e, "Failed to get registration response");
173             throw new IOException("Failed to get registration response", e);
174         } finally {
175             if (urlConnection != null) {
176                 urlConnection.disconnect();
177             }
178         }
179     }
180 
getByteArray(@ullable InputStream in, long contentLength)181     private byte[] getByteArray(@Nullable InputStream in, long contentLength) throws IOException {
182         if (contentLength == 0) {
183             return HttpClientUtil.EMPTY_BODY;
184         }
185         try {
186             // TODO(b/297952090): evaluate the large file download.
187             byte[] buffer = new byte[HttpClientUtil.DEFAULT_BUFFER_SIZE];
188             ByteArrayOutputStream out = new ByteArrayOutputStream();
189             int bytesRead;
190             while ((bytesRead = in.read(buffer)) != -1) {
191                 out.write(buffer, 0, bytesRead);
192             }
193             return out.toByteArray();
194         } finally {
195             in.close();
196         }
197     }
198 }
199