55import java .net .ConnectException ;
66import java .util .ArrayList ;
77import java .util .HashMap ;
8+ import java .util .LinkedHashMap ;
89import java .util .List ;
910import java .util .Calendar ;
1011import java .util .Map ;
@@ -66,6 +67,7 @@ public class BrowserEmulatorClient {
6667 private WorkerUrlResolver workerUrlResolver ;
6768
6869 private ConcurrentHashMap <String , ConcurrentHashMap <String , AtomicInteger >> clientFailures = new ConcurrentHashMap <>();
70+ private ConcurrentHashMap <String , ConcurrentHashMap <String , List <RetryAttempt >>> clientRetryAttempts = new ConcurrentHashMap <>();
6971 private ConcurrentHashMap <String , ConcurrentHashMap <String , Role >> clientRoles = new ConcurrentHashMap <>();
7072 private ConcurrentHashMap <String , TestCase > participantTestCases = new ConcurrentHashMap <>();
7173 private ConcurrentHashMap <String , AtomicBoolean > participantConnecting = new ConcurrentHashMap <>();
@@ -81,6 +83,33 @@ public class BrowserEmulatorClient {
8183
8284 private String httpProtocolPrefix ;
8385
86+ public static class RetryAttempt {
87+ private final int attemptNumber ;
88+ private final Calendar errorTimestamp ;
89+ private Calendar reconnectTimestamp ;
90+
91+ public RetryAttempt (int attemptNumber , Calendar errorTimestamp ) {
92+ this .attemptNumber = attemptNumber ;
93+ this .errorTimestamp = errorTimestamp ;
94+ }
95+
96+ public void setReconnectTimestamp (Calendar reconnectTimestamp ) {
97+ this .reconnectTimestamp = reconnectTimestamp ;
98+ }
99+
100+ public int getAttemptNumber () {
101+ return attemptNumber ;
102+ }
103+
104+ public Calendar getErrorTimestamp () {
105+ return errorTimestamp ;
106+ }
107+
108+ public Calendar getReconnectTimestamp () {
109+ return reconnectTimestamp ;
110+ }
111+ }
112+
84113 public BrowserEmulatorClient (LoadTestConfig loadTestConfig , CustomHttpClient httpClient , JsonUtils jsonUtils ,
85114 Sleeper sleeper , WorkerUrlResolver workerUrlResolver ) {
86115 this .loadTestConfig = loadTestConfig ;
@@ -94,6 +123,7 @@ public BrowserEmulatorClient(LoadTestConfig loadTestConfig, CustomHttpClient htt
94123 public void clean () {
95124 this .isClean .set (true );
96125 this .clientFailures .clear ();
126+ this .clientRetryAttempts .clear ();
97127 this .clientRoles .clear ();
98128 this .participantTestCases .clear ();
99129 this .participantConnecting .clear ();
@@ -227,8 +257,7 @@ public void addClientFailure(String workerUrl, String participant, String sessio
227257 log .debug ("Stop reconnecting participant {} in session {}" , participant , session );
228258 this .lastErrorReconnectingResponse = new CreateParticipantResponse ()
229259 .setResponseOk (false )
230- .setStopReason ("Participant " + participant + "-" + session + " failed after "
231- + newFailures + " retries" );
260+ .setStopReason (this .buildParticipantFailureReason (participant , session , newFailures , true ));
232261 }
233262 }
234263 }
@@ -266,23 +295,27 @@ private void afterDisconnect(String workerUrl, String participant, String sessio
266295 try {
267296 ConcurrentHashMap <String , Role > workerRoles = this .clientRoles .get (workerUrl );
268297 if (workerRoles == null ) {
269- log .debug ("Worker roles is null for {} in session {} in {}. Waiting ..." , participant , session , workerUrl );
298+ log .debug ("Worker roles is null for {} in session {} in {}. Waiting ..." , participant , session ,
299+ workerUrl );
270300 sleeper .sleep (WAIT_S , null );
271301 this .afterDisconnect (workerUrl , participant , session );
272302 return ;
273303 }
274304 Role role = workerRoles .get (user );
275305 if (role == null ) {
276- log .warn ("Role is null for {} in session {} in {}. This worker may not have this participant." , participant , session , workerUrl );
306+ log .warn ("Role is null for {} in session {} in {}. This worker may not have this participant." ,
307+ participant , session , workerUrl );
277308 return ;
278309 }
279310 int userNumber = Integer .parseInt (participant .replace (loadTestConfig .getUserNamePrefix (), "" ));
280311 int sessionNumber = Integer .parseInt (session .replace (loadTestConfig .getSessionNamePrefix (), "" ));
281312 CreateParticipantResponse response = null ;
282313 if (role .equals (Role .PUBLISHER )) {
283- response = this .createPublisher (workerUrl , userNumber , sessionNumber , this .participantTestCases .get (user ));
314+ response = this .createPublisher (workerUrl , userNumber , sessionNumber ,
315+ this .participantTestCases .get (user ));
284316 } else {
285- response = this .createSubscriber (workerUrl , userNumber , sessionNumber , this .participantTestCases .get (user ));
317+ response = this .createSubscriber (workerUrl , userNumber , sessionNumber ,
318+ this .participantTestCases .get (user ));
286319 }
287320 if (response .isResponseOk ()) {
288321 this .participantReconnecting .remove (user );
@@ -295,7 +328,7 @@ private void afterDisconnect(String workerUrl, String participant, String sessio
295328 }
296329 }
297330
298- private HttpResponse <String > disconnectUser (String workerUrl , String participant , String session ) {
331+ private HttpResponse <String > disconnectUser (String workerUrl , String participant , String session ) {
299332 try {
300333 log .info ("Deleting participant {} from worker {}" , participant , workerUrl );
301334 Map <String , String > headers = new HashMap <>();
@@ -464,22 +497,31 @@ private CreateParticipantResponse createParticipant(String workerUrl, int userNu
464497 key -> new ConcurrentHashMap <>());
465498 AtomicInteger userFailures = failuresMap .computeIfAbsent (user , key -> new AtomicInteger (0 ));
466499 int failures = userFailures .incrementAndGet ();
500+
501+ ConcurrentHashMap <String , List <RetryAttempt >> retryAttemptsMap = this .clientRetryAttempts
502+ .computeIfAbsent (workerUrl , key -> new ConcurrentHashMap <>());
503+ List <RetryAttempt > userAttempts = retryAttemptsMap .computeIfAbsent (user , key -> new ArrayList <>());
504+ Calendar errorTime = Calendar .getInstance ();
505+ userAttempts .add (new RetryAttempt (failures , errorTime ));
467506 log .error ("Participant {} in session {} failed {} times" , userId , sessionId , failures );
468507 sleeper .sleep (WAIT_S , null );
469508 if (!loadTestConfig .isRetryMode () || isResponseLimitReached (failures ) || endOfTest .get ()) {
470- String reason = "Participant " + userId + "-" + sessionId + " failed after "
471- + failures + " retries" ;
472- // Set lastErrorReconnectingResponse to trigger test termination
509+ boolean isReconnecting = this .participantReconnecting .contains (user );
510+ String reason = this .buildParticipantFailureReason (userId , sessionId , failures , isReconnecting );
473511 this .lastErrorReconnectingResponse = new CreateParticipantResponse ()
474512 .setResponseOk (false )
475513 .setStopReason (reason );
476- // Also set stopReason on the returned response to avoid race condition
477- // where getLastResponse() may return this object before lastErrorReconnectingResponse is visible
478514 return cpr .setResponseOk (false ).setStopReason (reason );
479515 }
480516 log .warn ("Retrying" );
481517 return this .createParticipant (workerUrl , userNumber , sessionNumber , testCase , role );
482518 } else {
519+ ConcurrentHashMap <String , List <RetryAttempt >> retryAttemptsMap = this .clientRetryAttempts
520+ .computeIfAbsent (workerUrl , key -> new ConcurrentHashMap <>());
521+ List <RetryAttempt > userAttempts = retryAttemptsMap .computeIfAbsent (user , key -> new ArrayList <>());
522+ if (!userAttempts .isEmpty ()) {
523+ userAttempts .get (userAttempts .size () - 1 ).setReconnectTimestamp (Calendar .getInstance ());
524+ }
483525 this .participantConnecting .get (user ).set (false );
484526 this .saveParticipantData (workerUrl , testCase .isTeaching () ? Role .PUBLISHER : role );
485527 }
@@ -580,6 +622,15 @@ private boolean isResponseLimitReached(int failures) {
580622 return failures == loadTestConfig .getRetryTimes ();
581623 }
582624
625+ private String buildParticipantFailureReason (String participant , String session , int attempts ,
626+ boolean reconnecting ) {
627+ if (reconnecting ) {
628+ return "Participant " + participant + "-" + session + " failed to reconnect after " + attempts
629+ + " attempts" ;
630+ }
631+ return "Participant " + participant + "-" + session + " failed after " + attempts + " retries" ;
632+ }
633+
583634 private CreateUserRequestBody generateRequestBody (int userNumber , String sessionNumber , Role role ,
584635 TestCase testCase ) {
585636 boolean video = (testCase .isTeaching () && role .equals (Role .PUBLISHER )) || !testCase .isTeaching ();
@@ -721,6 +772,22 @@ public Map<String, Integer> getPerUserRetryCounts() {
721772 return counts ;
722773 }
723774
775+ public Map <String , List <RetryAttempt >> getPerUserRetryAttempts () {
776+ Map <String , List <RetryAttempt >> result = new LinkedHashMap <>();
777+ for (ConcurrentHashMap <String , List <RetryAttempt >> userAttempts : clientRetryAttempts .values ()) {
778+ for (Map .Entry <String , List <RetryAttempt >> entry : userAttempts .entrySet ()) {
779+ String userSession = entry .getKey ();
780+ List <RetryAttempt > attempts = entry .getValue ();
781+ result .merge (userSession , attempts , (existing , incoming ) -> {
782+ existing .addAll (incoming );
783+ existing .sort ((a , b ) -> Integer .compare (a .getAttemptNumber (), b .getAttemptNumber ()));
784+ return existing ;
785+ });
786+ }
787+ }
788+ return result ;
789+ }
790+
724791 public void shutdownWorkers (List <String > workerUrls , boolean waitForResponse ) {
725792 if (workerUrls == null || workerUrls .isEmpty ()) {
726793 return ;
0 commit comments