Skip to content

Commit 652bed5

Browse files
committed
Refactor MosApiClient for statelessness and resilience
Problem: The existing MosApiClient was stateful, using an in-memory CookieManager. This design is incompatible with a multi-pod environment, leading to authentication failures as session state wasn't shared. It also lacked automatic handling for session expiry (401 errors). Solution: - Introduced `MosApiSessionCache` to store session cookies externally in Secret Manager, enabling shared state across pods. - Refactored `MosApiClient` into a stateless "engine" that utilizes `MosApiSessionCache` for session management. - Implemented automatic re-login and retry logic within `MosApiClient` to handle 401 Unauthorized errors transparently. It now attempts to log in and retries the original request once upon encountering a 401. - Added specific handling for 429 Rate Limit Exceeded errors during login. - Refactored status codes into constants, using standard `HttpURLConnection` constants where applicable. This change makes the MoSAPI integration robust, scalable in a multi-pod setup, and significantly more maintainable.
1 parent d3e594c commit 652bed5

File tree

8 files changed

+1155
-105
lines changed

8 files changed

+1155
-105
lines changed

core/src/main/java/google/registry/reporting/mosapi/MosApiClient.java

Lines changed: 264 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,29 @@
1414

1515
package google.registry.reporting.mosapi;
1616

17+
import com.google.common.base.Splitter;
18+
import com.google.common.base.Strings;
1719
import com.google.common.collect.ImmutableMap;
1820
import com.google.common.flogger.FluentLogger;
1921
import google.registry.config.RegistryConfig.Config;
2022
import google.registry.util.HttpUtils;
2123
import jakarta.inject.Inject;
2224
import jakarta.inject.Named;
2325
import jakarta.inject.Singleton;
24-
import java.net.CookieManager;
25-
import java.net.CookiePolicy;
26+
import java.io.IOException;
27+
import java.net.HttpURLConnection;
28+
import java.net.URLEncoder;
2629
import java.net.http.HttpClient;
2730
import java.net.http.HttpResponse;
2831
import java.nio.charset.StandardCharsets;
2932
import java.util.Base64;
33+
import java.util.Collections;
34+
import java.util.Map;
35+
import java.util.Optional;
36+
import java.util.function.BiFunction;
3037
import java.util.function.Function;
38+
import java.util.stream.Collectors;
39+
import javax.annotation.Nullable;
3140

3241
/**
3342
* A client for interacting with the ICANN Monitoring System API (MoSAPI).
@@ -43,10 +52,26 @@ public final class MosApiClient {
4352
private final Function<String, String> usernameProvider;
4453
private final Function<String, String> passwordProvider;
4554

46-
private final HttpClient httpClient;
47-
private final CookieManager cookieManager;
55+
// API Endpoints
56+
private static final String LOGIN_PATH = "/login";
57+
private static final String LOGOUT_PATH = "/logout";
58+
// HTTP Headers
59+
private static final String HEADER_AUTHORIZATION = "Authorization";
60+
private static final String HEADER_CONTENT_TYPE = "Content-Type";
61+
private static final String HEADER_COOKIE = "Cookie";
62+
private static final String HEADER_SET_COOKIE = "Set-Cookie";
63+
64+
// HTTP Header Prefixes and Values
65+
private static final String AUTH_BASIC_PREFIX = "Basic ";
66+
private static final String CONTENT_TYPE_JSON = "application/json";
4867

49-
private boolean isLoggedIn = false;
68+
// Cookie Parsing
69+
private static final String COOKIE_ID_PREFIX = "id=";
70+
private static final char COOKIE_DELIMITER = ';';
71+
private final int RATE_LIMIT = 429;
72+
73+
private final HttpClient httpClient;
74+
private final MosApiSessionCache mosApiSessionCache;
5075

5176
/**
5277
* Constructs a new MosApiClient.
@@ -61,16 +86,13 @@ public MosApiClient(
6186
@Config("mosapiUrl") String mosapiUrl,
6287
@Config("entityType") String entityType,
6388
@Named("mosapiUsernameProvider") Function<String, String> usernameProvider,
64-
@Named("mosapiPasswordProvider") Function<String, String> passwordProvider) {
89+
@Named("mosapiPasswordProvider") Function<String, String> passwordProvider,
90+
MosApiSessionCache mosApiSessionCache) {
6591
this.baseUrl = String.format("%s/%s", mosapiUrl, entityType);
6692
this.usernameProvider = usernameProvider;
6793
this.passwordProvider = passwordProvider;
68-
this.cookieManager = new CookieManager();
69-
this.cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL);
70-
71-
// Build the final HttpClient using the injected builder and our session-specific
72-
// CookieManager.
73-
this.httpClient = httpClientBuilder.cookieHandler(cookieManager).build();
94+
this.mosApiSessionCache = mosApiSessionCache;
95+
this.httpClient = httpClientBuilder.build();
7496
}
7597

7698
/**
@@ -82,7 +104,7 @@ public MosApiClient(
82104
*/
83105
public void login(String entityId) throws MosApiException {
84106

85-
String loginUrl = baseUrl + "/" + entityId + "/login";
107+
String loginUrl = buildUrl(entityId, LOGIN_PATH, Collections.emptyMap());
86108
String username = usernameProvider.apply(entityId);
87109
String password = passwordProvider.apply(entityId);
88110
String auth = username + ":" + password;
@@ -91,18 +113,26 @@ public void login(String entityId) throws MosApiException {
91113
try {
92114
HttpResponse<String> response =
93115
HttpUtils.sendPostRequest(
94-
httpClient, loginUrl, ImmutableMap.of("Authorization", "Basic " + encodedAuth));
116+
httpClient,
117+
loginUrl,
118+
ImmutableMap.of(HEADER_AUTHORIZATION, AUTH_BASIC_PREFIX + encodedAuth));
95119

96120
switch (response.statusCode()) {
97-
case 200:
98-
isLoggedIn = true;
121+
case HttpURLConnection.HTTP_OK:
122+
Optional<String> setCookieHeader = response.headers().firstValue(HEADER_SET_COOKIE);
123+
if (setCookieHeader.isEmpty()) {
124+
throw new MosApiException(
125+
"Login succeeded but server did not return a Set-Cookie header.");
126+
}
127+
String cookieValue = parseCookieValue(setCookieHeader.get());
128+
mosApiSessionCache.store(entityId, cookieValue);
99129
logger.atInfo().log("MoSAPI login successful");
100130
break;
101-
case 401:
131+
case HttpURLConnection.HTTP_UNAUTHORIZED:
102132
throw new InvalidCredentialsException(response.body());
103-
case 403:
133+
case HttpURLConnection.HTTP_FORBIDDEN:
104134
throw new IpAddressNotAllowedException(response.body());
105-
case 429:
135+
case RATE_LIMIT:
106136
throw new RateLimitExceededException(response.body());
107137
default:
108138
throw new MosApiException(
@@ -123,23 +153,23 @@ public void login(String entityId) throws MosApiException {
123153
* @throws MosApiException if the logout request fails.
124154
*/
125155
public void logout(String entityId) throws MosApiException {
126-
String logoutUrl = baseUrl + "/" + entityId + "/logout";
127-
if (!isLoggedIn) {
128-
return; // Already logged out.
129-
}
156+
String logoutUrl = buildUrl(entityId, LOGOUT_PATH, Collections.emptyMap());
157+
Optional<String> cookie = mosApiSessionCache.get(entityId);
158+
Map<String, String> headers =
159+
cookie.isPresent() ? ImmutableMap.of(HEADER_COOKIE, cookie.get()) : ImmutableMap.of();
130160

131161
try {
132-
HttpResponse<String> response = HttpUtils.sendPostRequest(httpClient, logoutUrl);
162+
HttpResponse<String> response = HttpUtils.sendPostRequest(httpClient, logoutUrl, headers);
133163

134164
switch (response.statusCode()) {
135-
case 200:
165+
case HttpURLConnection.HTTP_OK:
136166
logger.atInfo().log("Logout successful.");
137167
break;
138-
case 401:
168+
case HttpURLConnection.HTTP_UNAUTHORIZED:
139169
logger.atWarning().log(
140170
"Warning: %s (Session may have already expired).", response.body());
141171
break;
142-
case 403:
172+
case HttpURLConnection.HTTP_FORBIDDEN:
143173
throw new IpAddressNotAllowedException(response.body());
144174
default:
145175
throw new MosApiException(
@@ -152,9 +182,214 @@ public void logout(String entityId) throws MosApiException {
152182
} catch (Exception e) {
153183
throw new MosApiException("An error occurred during logout.", e);
154184
} finally {
155-
isLoggedIn = false;
156-
cookieManager.getCookieStore().removeAll(); // Clear local cookies.
185+
mosApiSessionCache.clear(entityId);
186+
logger.atInfo().log("Cleared session cache for %s", entityId);
187+
}
188+
}
189+
190+
/**
191+
* Executes a GET request with automatic session handling and re-login.
192+
*
193+
* @param entityId The entityId (e.g., TLD) for this request.
194+
* @param path The API path, e.g., "/monitoring/state".
195+
* @param queryParams A map of query parameters.
196+
* @param additionalHeaders Any custom headers for this specific request (e.g., "Accept").
197+
* @return The response body as a String.
198+
* @throws MosApiException if the request fails.
199+
*/
200+
public String executeGetRequest(
201+
String entityId,
202+
String path,
203+
Map<String, String> queryParams,
204+
Map<String, String> additionalHeaders)
205+
throws MosApiException {
206+
207+
String url = buildUrl(entityId, path, queryParams);
208+
209+
BiFunction<String, String, HttpResponse<String>> requestExecutor =
210+
(requestUrl, cookie) -> {
211+
try {
212+
ImmutableMap.Builder<String, String> headers = ImmutableMap.builder();
213+
headers.put(HEADER_COOKIE, cookie);
214+
if (additionalHeaders != null) {
215+
headers.putAll(additionalHeaders);
216+
}
217+
return HttpUtils.sendGetRequest(httpClient, requestUrl, headers.build());
218+
} catch (IOException | InterruptedException e) {
219+
throw new RuntimeException(new MosApiException("HTTP GET request failed", e));
220+
}
221+
};
222+
223+
HttpResponse<String> response = executeRequestWithRetry(entityId, url, requestExecutor);
224+
225+
if (response.statusCode() != HttpURLConnection.HTTP_OK) {
226+
throw new MosApiException(
227+
String.format(
228+
"GET request to %s failed with status code: %d - %s",
229+
path, response.statusCode(), response.body()));
230+
}
231+
return response.body();
232+
}
233+
234+
/**
235+
* Executes a POST request with automatic session handling and re-login.
236+
*
237+
* @param entityId The entityId (e.g., TLD) for this request.
238+
* @param path The API path, e.g., "/monitoring/incident/123/falsePositive".
239+
* @param body An optional request body (e.g., JSON string). Use null or empty for no body.
240+
* @param additionalHeaders Any custom headers for this specific request.
241+
* @return The response body as a String.
242+
* @throws MosApiException if the request fails.
243+
*/
244+
public String executePostRequest(
245+
String entityId, String path, @Nullable String body, Map<String, String> additionalHeaders)
246+
throws MosApiException {
247+
248+
String url = buildUrl(entityId, path, Collections.emptyMap());
249+
final String requestBody = Strings.nullToEmpty(body);
250+
251+
// Define the request-executing lambda
252+
BiFunction<String, String, HttpResponse<String>> requestExecutor =
253+
(requestUrl, cookie) -> {
254+
try {
255+
// Build the headers map
256+
ImmutableMap.Builder<String, String> headers = ImmutableMap.builder();
257+
headers.put(HEADER_COOKIE, cookie);
258+
if (additionalHeaders != null) {
259+
headers.putAll(additionalHeaders);
260+
}
261+
if (!requestBody.isEmpty()) {
262+
headers.put(HEADER_CONTENT_TYPE, CONTENT_TYPE_JSON);
263+
}
264+
265+
return HttpUtils.sendPostRequest(httpClient, requestUrl, headers.build(), requestBody);
266+
} catch (IOException | InterruptedException e) {
267+
throw new RuntimeException(new MosApiException("HTTP POST request failed", e));
268+
}
269+
};
270+
271+
HttpResponse<String> response = executeRequestWithRetry(entityId, url, requestExecutor);
272+
273+
if (response.statusCode() != HttpURLConnection.HTTP_OK) {
274+
throw new MosApiException(
275+
String.format(
276+
"POST request to %s failed with status code: %d - %s",
277+
path, response.statusCode(), response.body()));
278+
}
279+
return response.body();
280+
}
281+
282+
/**
283+
* Executes a request function with automatic session caching and re-login on expiry.
284+
*
285+
* @param entityId The entityId for the request.
286+
* @param url The full URL to request.
287+
* @param requestExecutor A function that takes (URL, CookieString) and returns an HttpResponse.
288+
* @return The HttpResponse from the successful request.
289+
* @throws MosApiException if the request fails permanently.
290+
*/
291+
private HttpResponse<String> executeRequestWithRetry(
292+
String entityId, String url, BiFunction<String, String, HttpResponse<String>> requestExecutor)
293+
throws MosApiException {
294+
295+
// 1. Try with existing cookie from cache
296+
Optional<String> cookie = mosApiSessionCache.get(entityId);
297+
if (cookie.isPresent()) {
298+
try {
299+
HttpResponse<String> response = requestExecutor.apply(url, cookie.get());
300+
if (isSessionExpiredError(response)) {
301+
logger.atWarning().log("Session expired for %s. Re-logging in.", entityId);
302+
} else {
303+
return response; // Success or other non-session-expired error
304+
}
305+
} catch (RuntimeException e) {
306+
if (e.getCause() instanceof MosApiException) {
307+
throw (MosApiException) e.getCause();
308+
}
309+
throw new MosApiException("Request failed", e);
310+
}
311+
} else {
312+
logger.atInfo().log("No session cookie cached for %s. Logging in.", entityId);
313+
}
314+
315+
// 2. If no cookie, or if session was expired, perform login.
316+
try {
317+
login(entityId);
318+
} catch (RateLimitExceededException e) {
319+
throw new MosApiException("Try running after some time", e);
320+
} catch (MosApiException e) {
321+
throw new MosApiException("Automatic re-login failed.", e);
322+
}
323+
324+
// 3. Retry the original request with the new cookie
325+
logger.atInfo().log("Login successful. Retrying original request for %s.", entityId);
326+
cookie = mosApiSessionCache.get(entityId);
327+
if (cookie.isEmpty()) {
328+
throw new MosApiException("Login succeeded but failed to retrieve new session cookie.");
329+
}
330+
331+
try {
332+
HttpResponse<String> response = requestExecutor.apply(url, cookie.get());
333+
if (isSessionExpiredError(response)) {
334+
throw new MosApiException(
335+
"Authentication failed even after re-login.",
336+
new InvalidCredentialsException(response.body()));
337+
}
338+
return response;
339+
} catch (RuntimeException e) {
340+
if (e.getCause() instanceof MosApiException) {
341+
throw (MosApiException) e.getCause();
342+
}
343+
throw new MosApiException("Request failed after re-login", e);
344+
}
345+
}
346+
347+
/**
348+
* Parses the "id=..." cookie value from the "Set-Cookie" header.
349+
*
350+
* @param setCookieHeader The raw value of the "Set-Cookie" header.
351+
* @return The "id=..." part of the cookie.
352+
* @throws MosApiException if the "id" part cannot be found.
353+
*/
354+
private String parseCookieValue(String setCookieHeader) throws MosApiException {
355+
for (String part : Splitter.on(COOKIE_DELIMITER).trimResults().split(setCookieHeader)) {
356+
if (part.startsWith(COOKIE_ID_PREFIX)) {
357+
return part;
358+
}
359+
}
360+
throw new MosApiException(
361+
String.format("Could not parse 'id' from Set-Cookie header: %s", setCookieHeader));
362+
}
363+
364+
/**
365+
* Checks if an HTTP response indicates an expired session.
366+
*
367+
* @param response The HTTP response.
368+
* @return True if the response is a 401 with the specific session expired message.
369+
*/
370+
private boolean isSessionExpiredError(HttpResponse<String> response) {
371+
return (response.statusCode() == HttpURLConnection.HTTP_UNAUTHORIZED);
372+
}
373+
374+
/**
375+
* Builds the full URL for a request, including the base URL, entityId, path, and query params.
376+
*/
377+
private String buildUrl(String entityId, String path, Map<String, String> queryParams) {
378+
String sanitizedPath = path.startsWith("/") ? path : "/" + path;
379+
String fullPath = "/" + entityId + sanitizedPath;
380+
381+
if (queryParams == null || queryParams.isEmpty()) {
382+
return baseUrl + fullPath;
157383
}
384+
String queryString =
385+
queryParams.entrySet().stream()
386+
.map(
387+
entry ->
388+
entry.getKey()
389+
+ "="
390+
+ URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8))
391+
.collect(Collectors.joining("&"));
392+
return baseUrl + fullPath + "?" + queryString;
158393
}
159394

160395
/** Custom exception for MoSAPI client errors. */

0 commit comments

Comments
 (0)