@63klabs/cache-data 1.3.7 → 1.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,126 @@ const https = require('https');
4
4
  const {AWS, AWSXRay} = require('./AWS.classes.js');
5
5
  const DebugAndLog = require('./DebugAndLog.class.js');
6
6
 
7
+ /**
8
+ * @typedef {object} ResponseMetadata
9
+ * @description Metadata about pagination and retry operations. This object is only
10
+ * present in the response when pagination or retry features are used and actually occur.
11
+ *
12
+ * @property {object} [retries] - Retry metadata (present only if retries occurred)
13
+ * @property {boolean} retries.occurred - Whether retry attempts were made
14
+ * @property {number} retries.attempts - Total number of attempts made (including initial attempt)
15
+ * @property {number} retries.finalAttempt - Which attempt number succeeded (1-indexed)
16
+ *
17
+ * @property {object} [pagination] - Pagination metadata (present only if pagination occurred)
18
+ * @property {boolean} pagination.occurred - Whether pagination was performed
19
+ * @property {number} pagination.totalPages - Total number of pages retrieved
20
+ * @property {number} pagination.totalItems - Total number of items returned across all pages
21
+ * @property {boolean} pagination.incomplete - Whether pagination failed to complete (some pages failed)
22
+ * @property {string|null} pagination.error - Error message if pagination was incomplete, null otherwise
23
+ *
24
+ * @example
25
+ * // Response with retry metadata only
26
+ * {
27
+ * success: true,
28
+ * statusCode: 200,
29
+ * headers: {...},
30
+ * body: "...",
31
+ * message: "SUCCESS",
32
+ * metadata: {
33
+ * retries: {
34
+ * occurred: true,
35
+ * attempts: 2,
36
+ * finalAttempt: 2
37
+ * }
38
+ * }
39
+ * }
40
+ *
41
+ * @example
42
+ * // Response with pagination metadata only
43
+ * {
44
+ * success: true,
45
+ * statusCode: 200,
46
+ * headers: {...},
47
+ * body: "{\"items\": [...], \"returnedItemCount\": 1000}",
48
+ * message: "SUCCESS",
49
+ * metadata: {
50
+ * pagination: {
51
+ * occurred: true,
52
+ * totalPages: 5,
53
+ * totalItems: 1000,
54
+ * incomplete: false,
55
+ * error: null
56
+ * }
57
+ * }
58
+ * }
59
+ *
60
+ * @example
61
+ * // Response with both retry and pagination metadata
62
+ * {
63
+ * success: true,
64
+ * statusCode: 200,
65
+ * headers: {...},
66
+ * body: "{\"items\": [...], \"returnedItemCount\": 1000}",
67
+ * message: "SUCCESS",
68
+ * metadata: {
69
+ * retries: {
70
+ * occurred: true,
71
+ * attempts: 2,
72
+ * finalAttempt: 2
73
+ * },
74
+ * pagination: {
75
+ * occurred: true,
76
+ * totalPages: 5,
77
+ * totalItems: 1000,
78
+ * incomplete: false,
79
+ * error: null
80
+ * }
81
+ * }
82
+ * }
83
+ *
84
+ * @example
85
+ * // Response with incomplete pagination
86
+ * {
87
+ * success: true,
88
+ * statusCode: 200,
89
+ * headers: {...},
90
+ * body: "{\"items\": [...], \"returnedItemCount\": 600}",
91
+ * message: "SUCCESS",
92
+ * metadata: {
93
+ * pagination: {
94
+ * occurred: true,
95
+ * totalPages: 3,
96
+ * totalItems: 600,
97
+ * incomplete: true,
98
+ * error: "Network error fetching page at offset 600"
99
+ * }
100
+ * }
101
+ * }
102
+ *
103
+ * @example
104
+ * // Response without metadata (no pagination or retry occurred)
105
+ * {
106
+ * success: true,
107
+ * statusCode: 200,
108
+ * headers: {...},
109
+ * body: "...",
110
+ * message: "SUCCESS"
111
+ * // No metadata field present
112
+ * }
113
+ */
114
+
115
+ /**
116
+ * @typedef {object} APIResponse
117
+ * @description Standard response object returned by APIRequest.send()
118
+ *
119
+ * @property {boolean} success - Whether the request was successful (statusCode < 400)
120
+ * @property {number} statusCode - HTTP status code
121
+ * @property {object|null} headers - Response headers
122
+ * @property {string|null} body - Response body
123
+ * @property {string|null} message - Response message
124
+ * @property {ResponseMetadata} [metadata] - Optional metadata (present only if pagination or retry occurred)
125
+ */
126
+
7
127
  /* Either return an XRay segment or mock one up so we don't need much logic if xray isn't used */
8
128
  const xRayProxyFunc = {
9
129
  addMetadata: (mockParam, mockObj) => { DebugAndLog.debug(`Mocking XRay addMetadata: ${mockParam} | ${mockObj}`); },
@@ -254,24 +374,69 @@ const _httpGetExecute = async function (options, requestObject, xRaySegment = xR
254
374
  /**
255
375
  * Submit GET and POST requests and handle responses.
256
376
  * This class can be used in a DAO class object within its call() method.
257
- * @example
258
- *
259
- * async call() {
260
377
  *
261
- * var response = null;
378
+ * Features:
379
+ * - Automatic redirect handling (up to MAX_REDIRECTS)
380
+ * - Optional automatic pagination for paginated APIs
381
+ * - Optional automatic retry logic for transient failures
382
+ * - AWS X-Ray distributed tracing with unique subsegments
383
+ * - Response metadata for pagination and retry details
262
384
  *
263
- * try {
264
- * var apiRequest = new tools.APIRequest(this.request);
265
- * response = await apiRequest.send();
385
+ * @example
386
+ * // Basic usage in a DAO class
387
+ * async call() {
388
+ * var response = null;
389
+ * try {
390
+ * var apiRequest = new tools.APIRequest(this.request);
391
+ * response = await apiRequest.send();
392
+ * } catch (error) {
393
+ * DebugAndLog.error(`Error in call: ${error.message}`, error.stack);
394
+ * response = tools.APIRequest.responseFormat(false, 500, "Error in call()");
395
+ * }
396
+ * return response;
397
+ * }
266
398
  *
267
- * } catch (error) {
268
- * DebugAndLog.error(`Error in call: ${error.message}`, error.stack);
269
- * response = tools.APIRequest.responseFormat(false, 500, "Error in call()");
270
- * }
399
+ * @example
400
+ * // Using pagination feature
401
+ * const apiRequest = new tools.APIRequest({
402
+ * host: 'api.example.com',
403
+ * path: '/data',
404
+ * pagination: {
405
+ * enabled: true,
406
+ * totalItemsLabel: 'total',
407
+ * itemsLabel: 'results'
408
+ * }
409
+ * });
410
+ * const response = await apiRequest.send();
411
+ * // All pages automatically retrieved and combined
271
412
  *
272
- * return response;
413
+ * @example
414
+ * // Using retry feature
415
+ * const apiRequest = new tools.APIRequest({
416
+ * host: 'api.example.com',
417
+ * path: '/data',
418
+ * retry: {
419
+ * enabled: true,
420
+ * maxRetries: 3
421
+ * }
422
+ * });
423
+ * const response = await apiRequest.send();
424
+ * // Automatically retries on transient failures
273
425
  *
274
- * };
426
+ * @example
427
+ * // Using both pagination and retry
428
+ * const apiRequest = new tools.APIRequest({
429
+ * host: 'api.example.com',
430
+ * path: '/data',
431
+ * pagination: { enabled: true },
432
+ * retry: { enabled: true, maxRetries: 2 }
433
+ * });
434
+ * const response = await apiRequest.send();
435
+ * // Response includes metadata about both features
436
+ * if (response.metadata) {
437
+ * console.log('Retries:', response.metadata.retries);
438
+ * console.log('Pagination:', response.metadata.pagination);
439
+ * }
275
440
  */
276
441
  class APIRequest {
277
442
 
@@ -281,12 +446,119 @@ class APIRequest {
281
446
  #requestComplete = false;
282
447
  #response = null;
283
448
  #request = null;
449
+ #responseMetadata = null;
284
450
 
285
451
  /**
286
452
  * Function used to make an API request utilized directly or from within
287
453
  * a data access object.
288
454
  *
289
- * @param {ConnectionObject} request
455
+ * @param {object} request - Request configuration object
456
+ * @param {string} [request.method="GET"] - HTTP method (GET, POST)
457
+ * @param {string} [request.protocol="https"] - Protocol (https or http)
458
+ * @param {string} request.host - Host domain for the request
459
+ * @param {string} [request.path=""] - Path for the request
460
+ * @param {string} [request.uri] - Complete URI (overrides host/path if provided)
461
+ * @param {object} [request.parameters={}] - Query string parameters
462
+ * @param {object} [request.headers={}] - HTTP headers
463
+ * @param {string|null} [request.body=null] - Request body for POST requests
464
+ * @param {string} [request.note=""] - Note for troubleshooting and tracing
465
+ * @param {object} [request.options] - Request options
466
+ * @param {number} [request.options.timeout=8000] - Request timeout in milliseconds
467
+ * @param {boolean} [request.options.separateDuplicateParameters=false] - Whether to separate array parameters into multiple key/value pairs
468
+ * @param {string} [request.options.separateDuplicateParametersAppendToKey=""] - String to append to key for separated parameters ("", "[]", "0++", "1++")
469
+ * @param {string} [request.options.combinedDuplicateParameterDelimiter=","] - Delimiter for combined array parameters
470
+ * @param {object} [request.pagination] - Pagination configuration (opt-in)
471
+ * @param {boolean} [request.pagination.enabled=false] - Enable automatic pagination
472
+ * @param {string} [request.pagination.totalItemsLabel="totalItems"] - Response field name for total items count
473
+ * @param {string} [request.pagination.itemsLabel="items"] - Response field name for items array
474
+ * @param {string} [request.pagination.offsetLabel="offset"] - Parameter name for offset
475
+ * @param {string} [request.pagination.limitLabel="limit"] - Parameter name for limit
476
+ * @param {string|null} [request.pagination.continuationTokenLabel=null] - Parameter name for continuation token (for token-based pagination)
477
+ * @param {string} [request.pagination.responseReturnCountLabel="returnedItemCount"] - Response field name for returned item count
478
+ * @param {number} [request.pagination.defaultLimit=200] - Default limit per page
479
+ * @param {number} [request.pagination.batchSize=5] - Number of pages to fetch concurrently
480
+ * @param {object} [request.retry] - Retry configuration (opt-in)
481
+ * @param {boolean} [request.retry.enabled=false] - Enable automatic retries
482
+ * @param {number} [request.retry.maxRetries=1] - Maximum number of retry attempts after initial attempt (total attempts = maxRetries + 1)
483
+ * @param {object} [request.retry.retryOn] - Conditions for retrying requests
484
+ * @param {boolean} [request.retry.retryOn.networkError=true] - Retry on network errors
485
+ * @param {boolean} [request.retry.retryOn.emptyResponse=true] - Retry on empty or null response body
486
+ * @param {boolean} [request.retry.retryOn.parseError=true] - Retry on JSON parse errors
487
+ * @param {boolean} [request.retry.retryOn.serverError=true] - Retry on 5xx status codes
488
+ * @param {boolean} [request.retry.retryOn.clientError=false] - Retry on 4xx status codes (default: false)
489
+ *
490
+ * @example
491
+ * // Basic request without pagination or retry
492
+ * const request = new APIRequest({
493
+ * host: 'api.example.com',
494
+ * path: '/users',
495
+ * parameters: { limit: 10 }
496
+ * });
497
+ * const response = await request.send();
498
+ *
499
+ * @example
500
+ * // Request with minimal pagination configuration (uses all defaults)
501
+ * const request = new APIRequest({
502
+ * host: 'api.example.com',
503
+ * path: '/data',
504
+ * pagination: { enabled: true }
505
+ * });
506
+ * const response = await request.send();
507
+ * // Response will include metadata.pagination with details
508
+ *
509
+ * @example
510
+ * // Request with custom pagination labels
511
+ * const request = new APIRequest({
512
+ * host: 'api.example.com',
513
+ * path: '/data',
514
+ * pagination: {
515
+ * enabled: true,
516
+ * totalItemsLabel: 'total',
517
+ * itemsLabel: 'results',
518
+ * offsetLabel: 'skip',
519
+ * limitLabel: 'take',
520
+ * batchSize: 3
521
+ * }
522
+ * });
523
+ * const response = await request.send();
524
+ *
525
+ * @example
526
+ * // Request with minimal retry configuration (uses all defaults)
527
+ * const request = new APIRequest({
528
+ * host: 'api.example.com',
529
+ * path: '/data',
530
+ * retry: { enabled: true }
531
+ * });
532
+ * const response = await request.send();
533
+ * // Response will include metadata.retries if retries occurred
534
+ *
535
+ * @example
536
+ * // Request with custom retry configuration
537
+ * const request = new APIRequest({
538
+ * host: 'api.example.com',
539
+ * path: '/data',
540
+ * retry: {
541
+ * enabled: true,
542
+ * maxRetries: 3,
543
+ * retryOn: {
544
+ * clientError: true, // Also retry on 4xx errors
545
+ * serverError: true,
546
+ * networkError: true
547
+ * }
548
+ * }
549
+ * });
550
+ * const response = await request.send();
551
+ *
552
+ * @example
553
+ * // Request with both pagination and retry
554
+ * const request = new APIRequest({
555
+ * host: 'api.example.com',
556
+ * path: '/data',
557
+ * pagination: { enabled: true },
558
+ * retry: { enabled: true, maxRetries: 2 }
559
+ * });
560
+ * const response = await request.send();
561
+ * // Response may include both metadata.pagination and metadata.retries
290
562
  */
291
563
  constructor(request) {
292
564
  this.resetRequest();
@@ -296,6 +568,32 @@ class APIRequest {
296
568
 
297
569
  let timeOutInMilliseconds = 8000;
298
570
 
571
+ /* Default pagination configuration */
572
+ const defaultPaginationConfig = {
573
+ enabled: false,
574
+ totalItemsLabel: 'totalItems',
575
+ itemsLabel: 'items',
576
+ offsetLabel: 'offset',
577
+ limitLabel: 'limit',
578
+ continuationTokenLabel: null,
579
+ responseReturnCountLabel: 'returnedItemCount',
580
+ defaultLimit: 200,
581
+ batchSize: 5
582
+ };
583
+
584
+ /* Default retry configuration */
585
+ const defaultRetryConfig = {
586
+ enabled: false,
587
+ maxRetries: 1,
588
+ retryOn: {
589
+ networkError: true,
590
+ emptyResponse: true,
591
+ parseError: true,
592
+ serverError: true,
593
+ clientError: false
594
+ }
595
+ };
596
+
299
597
  /* Default values */
300
598
  let req = {
301
599
  method: "GET",
@@ -312,7 +610,9 @@ class APIRequest {
312
610
  separateDuplicateParameters: false,
313
611
  separateDuplicateParametersAppendToKey: "", // "" "[]", or "0++", "1++"
314
612
  combinedDuplicateParameterDelimiter: ',' // "," or "|" or " "
315
- }
613
+ },
614
+ pagination: defaultPaginationConfig,
615
+ retry: defaultRetryConfig
316
616
  };
317
617
 
318
618
  /* if we have a method or protocol passed to us, set them */
@@ -326,6 +626,20 @@ class APIRequest {
326
626
  // With options we want to keep our defaults so we'll use Object.assign
327
627
  if ("options" in request && request.options !== null) { req.options = Object.assign(req.options, request.options); }
328
628
 
629
+ // Merge pagination configuration with defaults
630
+ if ("pagination" in request && request.pagination !== null) {
631
+ req.pagination = Object.assign({}, defaultPaginationConfig, request.pagination);
632
+ }
633
+
634
+ // Merge retry configuration with defaults, including nested retryOn object
635
+ if ("retry" in request && request.retry !== null && request.retry !== undefined) {
636
+ req.retry = Object.assign({}, defaultRetryConfig, request.retry);
637
+ // Ensure nested retryOn object is also merged properly
638
+ if ("retryOn" in request.retry && request.retry.retryOn !== null) {
639
+ req.retry.retryOn = Object.assign({}, defaultRetryConfig.retryOn, request.retry.retryOn);
640
+ }
641
+ }
642
+
329
643
  /* if there is no timeout set, or if it is less than 1, then set to default */
330
644
  if ( !("timeout" in req.options && req.options.timeout > 0) ) {
331
645
  req.options.timeout = timeOutInMilliseconds;
@@ -394,6 +708,398 @@ class APIRequest {
394
708
  return (qString.length > 0) ? '?'+qString.join("&") : "";
395
709
  }
396
710
 
711
+ /**
712
+ * Handle request with retry logic. This private method wraps the HTTP request
713
+ * execution with automatic retry functionality based on the retry configuration.
714
+ *
715
+ * @private
716
+ * @param {object} options - HTTPS request options for the underlying https.request call
717
+ * @param {object} xRaySegment - X-Ray subsegment for tracking request attempts
718
+ * @returns {Promise<{response: object, metadata: object}>} Promise resolving to response and retry metadata
719
+ * @returns {object} return.response - The final response object from the request
720
+ * @returns {boolean} return.response.success - Whether the request was successful
721
+ * @returns {number} return.response.statusCode - HTTP status code
722
+ * @returns {object} return.response.headers - Response headers
723
+ * @returns {string} return.response.body - Response body
724
+ * @returns {string} return.response.message - Response message
725
+ * @returns {object} return.metadata - Metadata about retry attempts
726
+ * @returns {object} return.metadata.retries - Retry information
727
+ * @returns {boolean} return.metadata.retries.occurred - Whether retries occurred
728
+ * @returns {number} return.metadata.retries.attempts - Total number of attempts made (including initial)
729
+ * @returns {number} return.metadata.retries.finalAttempt - Which attempt succeeded
730
+ *
731
+ * @example
732
+ * // Internal usage within send_get()
733
+ * const { response, metadata } = await this._handleRetries(options, xRaySegment);
734
+ * if (metadata.retries.occurred) {
735
+ * console.log(`Request succeeded after ${metadata.retries.attempts} attempts`);
736
+ * }
737
+ */
738
+ async _handleRetries(options, xRaySegment) {
739
+ const retryConfig = this.#request.retry || { enabled: false };
740
+ const maxRetries = retryConfig.enabled ? (retryConfig.maxRetries || 1) : 0;
741
+
742
+ let lastResponse = null;
743
+ let attempts = 0;
744
+
745
+ // Loop: initial attempt + retry attempts
746
+ // If maxRetries = 1, this loops twice (attempt 0 and attempt 1)
747
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
748
+ attempts++;
749
+
750
+ if (attempt > 0) {
751
+ this.#request.note += ` [Retry ${attempt}/${maxRetries}]`;
752
+ DebugAndLog.warn(`Retrying request (${this.#request.note})`);
753
+ // Reset request state for retry
754
+ this.#requestComplete = false;
755
+ }
756
+
757
+ // Perform the request (handles redirects internally)
758
+ while (!this.#requestComplete) {
759
+ await _httpGetExecute(options, this, xRaySegment);
760
+ }
761
+ lastResponse = this.#response;
762
+
763
+ // Check if we should retry
764
+ const shouldRetry = this._shouldRetry(lastResponse, retryConfig, attempt, maxRetries);
765
+
766
+ if (!shouldRetry) {
767
+ break;
768
+ }
769
+ }
770
+
771
+ return {
772
+ response: lastResponse,
773
+ metadata: {
774
+ retries: {
775
+ occurred: attempts > 1,
776
+ attempts: attempts,
777
+ finalAttempt: attempts
778
+ }
779
+ }
780
+ };
781
+ }
782
+
783
+ /**
784
+ * Determine if a request should be retried based on the response and retry configuration.
785
+ * This private method evaluates various failure conditions to decide whether another
786
+ * attempt should be made.
787
+ *
788
+ * @private
789
+ * @param {object} response - Current response object from the request attempt
790
+ * @param {boolean} response.success - Whether the request was successful
791
+ * @param {number} response.statusCode - HTTP status code
792
+ * @param {string} response.body - Response body
793
+ * @param {object} retryConfig - Retry configuration object
794
+ * @param {boolean} retryConfig.enabled - Whether retries are enabled
795
+ * @param {number} retryConfig.maxRetries - Maximum number of retry attempts
796
+ * @param {object} retryConfig.retryOn - Conditions for retrying
797
+ * @param {boolean} retryConfig.retryOn.networkError - Retry on network errors
798
+ * @param {boolean} retryConfig.retryOn.emptyResponse - Retry on empty responses
799
+ * @param {boolean} retryConfig.retryOn.parseError - Retry on JSON parse errors
800
+ * @param {boolean} retryConfig.retryOn.serverError - Retry on 5xx errors
801
+ * @param {boolean} retryConfig.retryOn.clientError - Retry on 4xx errors
802
+ * @param {number} currentAttempt - Current attempt number (0-indexed)
803
+ * @param {number} maxRetries - Maximum retry attempts allowed
804
+ * @returns {boolean} True if the request should be retried, false otherwise
805
+ *
806
+ * @example
807
+ * // Internal usage within _handleRetries()
808
+ * const shouldRetry = this._shouldRetry(lastResponse, retryConfig, attempt, maxRetries);
809
+ * if (!shouldRetry) {
810
+ * break; // Exit retry loop
811
+ * }
812
+ */
813
+ _shouldRetry(response, retryConfig, currentAttempt, maxRetries) {
814
+ // No more retries available
815
+ if (currentAttempt >= maxRetries) {
816
+ return false;
817
+ }
818
+
819
+ const retryOn = retryConfig.retryOn || {};
820
+
821
+ // Check for network errors (no response)
822
+ if (!response && retryOn.networkError !== false) {
823
+ return true;
824
+ }
825
+
826
+ // Check for empty/null body
827
+ if (retryOn.emptyResponse !== false &&
828
+ (response.body === null || response.body === "")) {
829
+ return true;
830
+ }
831
+
832
+ // Check for server errors (5xx)
833
+ if (retryOn.serverError !== false &&
834
+ response.statusCode >= 500 && response.statusCode < 600) {
835
+ return true;
836
+ }
837
+
838
+ // Check for client errors (4xx) - default is NOT to retry
839
+ if (retryOn.clientError === true &&
840
+ response.statusCode >= 400 && response.statusCode < 500) {
841
+ return true;
842
+ }
843
+
844
+ // Try to parse JSON if it's expected to be JSON
845
+ if (retryOn.parseError !== false && response.body) {
846
+ try {
847
+ JSON.parse(response.body);
848
+ } catch (error) {
849
+ return true;
850
+ }
851
+ }
852
+
853
+ return false;
854
+ }
855
+
856
+ /**
857
+ * Handle pagination for API responses. This private method automatically retrieves
858
+ * all pages of results from a paginated API and combines them into a single response.
859
+ *
860
+ * @private
861
+ * @param {object} initialResponse - Initial API response from the first request
862
+ * @param {boolean} initialResponse.success - Whether the initial request was successful
863
+ * @param {number} initialResponse.statusCode - HTTP status code
864
+ * @param {string} initialResponse.body - Response body (JSON string)
865
+ * @param {object} initialResponse.headers - Response headers
866
+ * @returns {Promise<{response: object, metadata: object}>} Promise resolving to combined response and pagination metadata
867
+ * @returns {object} return.response - The combined response with all pages
868
+ * @returns {boolean} return.response.success - Whether pagination was successful
869
+ * @returns {number} return.response.statusCode - HTTP status code
870
+ * @returns {string} return.response.body - Combined response body with all items
871
+ * @returns {object} return.metadata - Metadata about pagination
872
+ * @returns {object} return.metadata.pagination - Pagination information
873
+ * @returns {boolean} return.metadata.pagination.occurred - Whether pagination occurred
874
+ * @returns {number} [return.metadata.pagination.totalPages] - Total pages retrieved (if pagination occurred)
875
+ * @returns {number} [return.metadata.pagination.totalItems] - Total items returned (if pagination occurred)
876
+ * @returns {boolean} [return.metadata.pagination.incomplete] - Whether pagination was incomplete due to errors
877
+ * @returns {string|null} [return.metadata.pagination.error] - Error message if pagination was incomplete
878
+ *
879
+ * @example
880
+ * // Internal usage within send_get()
881
+ * const { response: finalResponse, metadata: paginationMetadata } =
882
+ * await this._handlePagination(response);
883
+ * if (paginationMetadata.pagination.occurred) {
884
+ * console.log(`Retrieved ${paginationMetadata.pagination.totalItems} items from ${paginationMetadata.pagination.totalPages} pages`);
885
+ * }
886
+ */
887
+ async _handlePagination(initialResponse) {
888
+ const paginationConfig = this.#request.pagination || { enabled: false };
889
+
890
+ // If pagination not enabled or initial request failed, return as-is
891
+ if (!paginationConfig.enabled || !initialResponse.success) {
892
+ return {
893
+ response: initialResponse,
894
+ metadata: { pagination: { occurred: false } }
895
+ };
896
+ }
897
+
898
+ // Parse body to check for pagination indicators
899
+ let body;
900
+ try {
901
+ body = JSON.parse(initialResponse.body);
902
+ } catch (error) {
903
+ // Can't paginate if body isn't JSON
904
+ return {
905
+ response: initialResponse,
906
+ metadata: { pagination: { occurred: false } }
907
+ };
908
+ }
909
+
910
+ const {
911
+ totalItemsLabel = 'totalItems',
912
+ itemsLabel = 'items',
913
+ offsetLabel = 'offset',
914
+ limitLabel = 'limit',
915
+ continuationTokenLabel = null,
916
+ responseReturnCountLabel = 'returnedItemCount',
917
+ defaultLimit = 200,
918
+ batchSize = 5
919
+ } = paginationConfig;
920
+
921
+ // Check if response has pagination indicators
922
+ if (!(totalItemsLabel in body) || !(itemsLabel in body)) {
923
+ return {
924
+ response: initialResponse,
925
+ metadata: { pagination: { occurred: false } }
926
+ };
927
+ }
928
+
929
+ // Check if we're already on a paginated request (offset > 0)
930
+ if (offsetLabel in this.#request.parameters &&
931
+ this.#request.parameters[offsetLabel] > 0) {
932
+ return {
933
+ response: initialResponse,
934
+ metadata: { pagination: { occurred: false } }
935
+ };
936
+ }
937
+
938
+ const limit = this.#request.parameters[limitLabel] || defaultLimit;
939
+ const totalRecords = body[totalItemsLabel];
940
+
941
+ // Calculate offsets for remaining pages
942
+ const offsets = [];
943
+ for (let offset = limit; offset < totalRecords; offset += limit) {
944
+ offsets.push(offset);
945
+ }
946
+
947
+ // If no more pages, return initial response
948
+ if (offsets.length === 0) {
949
+ return {
950
+ response: initialResponse,
951
+ metadata: { pagination: { occurred: false } }
952
+ };
953
+ }
954
+
955
+ // Fetch remaining pages in batches
956
+ const allResults = [];
957
+ let incomplete = false;
958
+ let paginationError = null;
959
+
960
+ for (let i = 0; i < offsets.length; i += batchSize) {
961
+ const batchOffsets = offsets.slice(i, i + batchSize);
962
+ const batchPromises = batchOffsets.map(offset =>
963
+ this._fetchPage(offset, offsetLabel, limitLabel)
964
+ );
965
+
966
+ try {
967
+ const batchResults = await Promise.all(batchPromises);
968
+ allResults.push(...batchResults);
969
+ } catch (error) {
970
+ incomplete = true;
971
+ paginationError = error.message;
972
+ DebugAndLog.warn(`Pagination incomplete: ${error.message}`);
973
+ break;
974
+ }
975
+ }
976
+
977
+ // Combine all results
978
+ const allRecords = [
979
+ ...body[itemsLabel],
980
+ ...allResults.flatMap(result => {
981
+ if (!result || !result.body) {
982
+ incomplete = true;
983
+ return [];
984
+ }
985
+ try {
986
+ const pageBody = JSON.parse(result.body);
987
+ return pageBody[itemsLabel] || [];
988
+ } catch (error) {
989
+ incomplete = true;
990
+ return [];
991
+ }
992
+ })
993
+ ];
994
+
995
+ // Build combined response
996
+ const combinedBody = {
997
+ ...body,
998
+ [itemsLabel]: allRecords
999
+ };
1000
+
1001
+ // Clean up pagination parameters from response
1002
+ delete combinedBody[offsetLabel];
1003
+ delete combinedBody[limitLabel];
1004
+ combinedBody[responseReturnCountLabel] = allRecords.length;
1005
+
1006
+ const combinedResponse = {
1007
+ ...initialResponse,
1008
+ body: JSON.stringify(combinedBody)
1009
+ };
1010
+
1011
+ return {
1012
+ response: combinedResponse,
1013
+ metadata: {
1014
+ pagination: {
1015
+ occurred: true,
1016
+ totalPages: allResults.length + 1,
1017
+ totalItems: allRecords.length,
1018
+ incomplete: incomplete,
1019
+ error: paginationError
1020
+ }
1021
+ }
1022
+ };
1023
+ }
1024
+
1025
+ /**
1026
+ * Fetch a single page of paginated results. This private method creates a new
1027
+ * APIRequest instance for a specific page offset and retrieves that page's data.
1028
+ * If X-Ray is available, it creates a subsegment to track the page request.
1029
+ *
1030
+ * @private
1031
+ * @param {number} offset - Offset value for this page (e.g., 200, 400, 600)
1032
+ * @param {string} offsetLabel - Parameter name for offset in the API (e.g., 'offset', 'skip')
1033
+ * @param {string} limitLabel - Parameter name for limit in the API (e.g., 'limit', 'take')
1034
+ * @returns {Promise<object>} Promise resolving to the page response
1035
+ * @returns {boolean} return.success - Whether the page request was successful
1036
+ * @returns {number} return.statusCode - HTTP status code
1037
+ * @returns {string} return.body - Page response body (JSON string)
1038
+ * @returns {object} return.headers - Response headers
1039
+ *
1040
+ * @example
1041
+ * // Internal usage within _handlePagination()
1042
+ * const pageResponse = await this._fetchPage(200, 'offset', 'limit');
1043
+ * const pageBody = JSON.parse(pageResponse.body);
1044
+ * const pageItems = pageBody.items;
1045
+ */
1046
+ async _fetchPage(offset, offsetLabel, limitLabel) {
1047
+ // Clone the current request
1048
+ const pageRequest = {
1049
+ ...this.#request,
1050
+ parameters: {
1051
+ ...this.#request.parameters,
1052
+ [offsetLabel]: offset
1053
+ },
1054
+ note: `${this.#request.note} [Offset ${offset}]`,
1055
+ // Disable pagination for sub-requests to avoid infinite loops
1056
+ pagination: { enabled: false },
1057
+ // Keep retry configuration
1058
+ retry: this.#request.retry
1059
+ };
1060
+
1061
+ // Create subsegment for this paginated request if X-Ray is available
1062
+ if (AWSXRay) {
1063
+ const subsegmentName = `APIRequest/${this.getHost()}/Page-${offset}`;
1064
+
1065
+ return await AWSXRay.captureAsyncFunc(subsegmentName, async (subsegment) => {
1066
+ try {
1067
+ subsegment.namespace = 'remote';
1068
+
1069
+ // Add page metadata to subsegment
1070
+ subsegment.addAnnotation('page_offset', offset);
1071
+ subsegment.addAnnotation('request_host', this.getHost());
1072
+ subsegment.addAnnotation('request_note', pageRequest.note);
1073
+ subsegment.addMetadata('page_info', {
1074
+ offset: offset,
1075
+ offsetLabel: offsetLabel,
1076
+ limitLabel: limitLabel,
1077
+ parentNote: this.#request.note
1078
+ });
1079
+
1080
+ // Create new APIRequest instance for this page
1081
+ const pageApiRequest = new APIRequest(pageRequest);
1082
+ const response = await pageApiRequest.send();
1083
+
1084
+ subsegment.addAnnotation('success', response.success ? "true" : "false");
1085
+ subsegment.addAnnotation('status_code', response?.statusCode || 500);
1086
+
1087
+ return response;
1088
+ } catch (error) {
1089
+ DebugAndLog.error(`Error fetching page at offset ${offset}: ${error.message}`, error.stack);
1090
+ subsegment.addError(error);
1091
+ throw error;
1092
+ } finally {
1093
+ subsegment.close();
1094
+ }
1095
+ });
1096
+ } else {
1097
+ // No X-Ray available, just create and send the request
1098
+ const pageApiRequest = new APIRequest(pageRequest);
1099
+ return await pageApiRequest.send();
1100
+ }
1101
+ }
1102
+
397
1103
  /**
398
1104
  * Clears out any redirects, completion flag, and response
399
1105
  */
@@ -486,8 +1192,56 @@ class APIRequest {
486
1192
  };
487
1193
 
488
1194
  /**
489
- * Send the request
490
- * @returns {object}
1195
+ * Send the request. This method dispatches the request based on the HTTP method
1196
+ * (GET or POST). It automatically handles pagination and retries if configured.
1197
+ *
1198
+ * @returns {Promise<object>} Promise resolving to the response object
1199
+ * @returns {boolean} return.success - Whether the request was successful
1200
+ * @returns {number} return.statusCode - HTTP status code
1201
+ * @returns {object} return.headers - Response headers
1202
+ * @returns {string} return.body - Response body
1203
+ * @returns {string} return.message - Response message
1204
+ * @returns {object} [return.metadata] - Optional metadata (present if pagination or retries occurred)
1205
+ * @returns {object} [return.metadata.retries] - Retry metadata (if retries occurred)
1206
+ * @returns {boolean} return.metadata.retries.occurred - Whether retries occurred
1207
+ * @returns {number} return.metadata.retries.attempts - Total attempts made
1208
+ * @returns {number} return.metadata.retries.finalAttempt - Which attempt succeeded
1209
+ * @returns {object} [return.metadata.pagination] - Pagination metadata (if pagination occurred)
1210
+ * @returns {boolean} return.metadata.pagination.occurred - Whether pagination occurred
1211
+ * @returns {number} return.metadata.pagination.totalPages - Total pages retrieved
1212
+ * @returns {number} return.metadata.pagination.totalItems - Total items returned
1213
+ * @returns {boolean} return.metadata.pagination.incomplete - Whether pagination was incomplete
1214
+ * @returns {string|null} return.metadata.pagination.error - Error message if incomplete
1215
+ *
1216
+ * @example
1217
+ * // Basic request
1218
+ * const apiRequest = new APIRequest({ host: 'api.example.com', path: '/users' });
1219
+ * const response = await apiRequest.send();
1220
+ * console.log(response.body);
1221
+ *
1222
+ * @example
1223
+ * // Request with pagination
1224
+ * const apiRequest = new APIRequest({
1225
+ * host: 'api.example.com',
1226
+ * path: '/data',
1227
+ * pagination: { enabled: true }
1228
+ * });
1229
+ * const response = await apiRequest.send();
1230
+ * if (response.metadata?.pagination?.occurred) {
1231
+ * console.log(`Retrieved ${response.metadata.pagination.totalItems} items`);
1232
+ * }
1233
+ *
1234
+ * @example
1235
+ * // Request with retry
1236
+ * const apiRequest = new APIRequest({
1237
+ * host: 'api.example.com',
1238
+ * path: '/data',
1239
+ * retry: { enabled: true, maxRetries: 2 }
1240
+ * });
1241
+ * const response = await apiRequest.send();
1242
+ * if (response.metadata?.retries?.occurred) {
1243
+ * console.log(`Succeeded after ${response.metadata.retries.attempts} attempts`);
1244
+ * }
491
1245
  */
492
1246
  async send() {
493
1247
 
@@ -510,8 +1264,34 @@ class APIRequest {
510
1264
  }
511
1265
 
512
1266
  /**
513
- * Process the request
514
- * @returns {object} Response
1267
+ * Process the request with GET or POST method. This method handles the actual
1268
+ * HTTP request execution, including retry logic, pagination, and X-Ray tracing.
1269
+ * It creates unique X-Ray subsegments for each request and includes metadata
1270
+ * about retries and pagination in the subsegment annotations.
1271
+ *
1272
+ * @returns {Promise<object>} Promise resolving to the response object
1273
+ * @returns {boolean} return.success - Whether the request was successful
1274
+ * @returns {number} return.statusCode - HTTP status code
1275
+ * @returns {object} return.headers - Response headers
1276
+ * @returns {string} return.body - Response body
1277
+ * @returns {string} return.message - Response message
1278
+ * @returns {object} [return.metadata] - Optional metadata (present if pagination or retries occurred)
1279
+ * @returns {object} [return.metadata.retries] - Retry metadata (if retries occurred)
1280
+ * @returns {object} [return.metadata.pagination] - Pagination metadata (if pagination occurred)
1281
+ *
1282
+ * @example
1283
+ * // Internal usage - typically called via send()
1284
+ * const response = await this.send_get();
1285
+ *
1286
+ * @example
1287
+ * // Response with retry metadata
1288
+ * const response = await this.send_get();
1289
+ * // response.metadata.retries = { occurred: true, attempts: 2, finalAttempt: 2 }
1290
+ *
1291
+ * @example
1292
+ * // Response with pagination metadata
1293
+ * const response = await this.send_get();
1294
+ * // response.metadata.pagination = { occurred: true, totalPages: 5, totalItems: 1000, incomplete: false, error: null }
515
1295
  */
516
1296
  async send_get() {
517
1297
 
@@ -544,66 +1324,117 @@ class APIRequest {
544
1324
  try {
545
1325
 
546
1326
  // we will want to follow redirects, so keep submitting until considered complete
547
- while ( !this.#requestComplete ) {
548
- if (AWSXRay) {
1327
+ if (AWSXRay) {
549
1328
 
550
- // async send() {
551
- // const parentSegment = AWSXRay.getSegment();
552
- // const customSegment = new AWSXRay.Segment('APIRequest');
553
-
554
- // return await AWSXRay.captureAsyncFunc('APIRequest', async (subsegment) => {
555
- // AWSXRay.setSegment(customSegment);
556
- // try {
557
- // // Your custom subsegments here
558
- // subsegment.addAnnotation('host', this.host);
559
- // subsegment.addAnnotation('method', this.method);
560
-
561
- // const result = await this._performRequest();
562
- // return result;
563
- // } finally {
564
- // AWSXRay.setSegment(parentSegment);
565
- // }
566
- // }, customSegment);
567
- // }
568
-
569
- const subsegmentName = "APIRequest/" + ((this.getHost()) ? this.getHost() : new URL(this.getURI()).hostname);
570
- // const parentSegment = AWSXRay.getSegment();
571
- // const customSegment = new AWSXRay.Segment('APIRequest');
572
-
573
- await AWSXRay.captureAsyncFunc(subsegmentName, async (subsegment) => {
574
- // AWSXRay.setSegment(subsegment);
575
-
576
- try {
577
-
578
- subsegment.namespace = 'remote';
579
-
580
- // Add searchable annotations
581
- subsegment.addAnnotation('request_method', this.getMethod());
582
- subsegment.addAnnotation('request_host', this.getHost());
583
- subsegment.addAnnotation('request_uri', this.getURI(false));
584
- subsegment.addAnnotation('request_note', this.getNote());
585
-
586
- const result = await _httpGetExecute(options, this, subsegment);
587
-
588
- subsegment.addAnnotation('success', result ? "true" : "false");
589
- subsegment.addAnnotation('status_code', this.#response?.statusCode || 500);
590
- subsegment.addAnnotation('note', this.getNote());
591
- return result;
592
- } catch (error) {
593
- DebugAndLog.error(`Error in APIRequest call to remote endpoint (${this.getNote()}): ${error.message}`, error.stack);
594
- subsegment.addError(error);
595
- throw error;
596
- } finally {
597
- subsegment.close();
598
- // AWSXRay.setSegment(parentSegment);
1329
+ // Use timestamp to ensure unique subsegment names for each request
1330
+ const subsegmentName = `APIRequest/${this.getHost()}/${Date.now()}`;
1331
+
1332
+ await AWSXRay.captureAsyncFunc(subsegmentName, async (subsegment) => {
1333
+
1334
+ try {
1335
+
1336
+ subsegment.namespace = 'remote';
1337
+
1338
+ // Add searchable annotations
1339
+ subsegment.addAnnotation('request_method', this.getMethod());
1340
+ subsegment.addAnnotation('request_host', this.getHost());
1341
+ subsegment.addAnnotation('request_uri', this.getURI(false));
1342
+ subsegment.addAnnotation('request_note', this.getNote());
1343
+
1344
+ // Add retry configuration to subsegment metadata (if enabled)
1345
+ if (this.#request.retry?.enabled) {
1346
+ subsegment.addMetadata('retry_config', this.#request.retry);
599
1347
  }
600
- }/*, customSegment*/);
601
- } else {
602
- await _httpGetExecute(options, this);
603
- }
604
- };
605
1348
 
606
- // we now have a completed response
1349
+ // Add pagination configuration to subsegment metadata (if enabled)
1350
+ if (this.#request.pagination?.enabled) {
1351
+ subsegment.addMetadata('pagination_config', this.#request.pagination);
1352
+ }
1353
+
1354
+ // Use retry handler which handles redirects internally
1355
+ const { response, metadata: retryMetadata } = await this._handleRetries(options, subsegment);
1356
+
1357
+ // Add retry metadata to subsegment annotations and metadata
1358
+ if (retryMetadata.retries?.occurred) {
1359
+ subsegment.addAnnotation('retry_attempts', retryMetadata.retries.attempts);
1360
+ subsegment.addMetadata('retry_details', retryMetadata.retries);
1361
+ }
1362
+
1363
+ // Handle pagination if enabled
1364
+ const { response: finalResponse, metadata: paginationMetadata } =
1365
+ await this._handlePagination(response);
1366
+
1367
+ // Add pagination metadata to subsegment annotations and metadata
1368
+ if (paginationMetadata.pagination?.occurred) {
1369
+ subsegment.addAnnotation('pagination_pages', paginationMetadata.pagination.totalPages);
1370
+ subsegment.addAnnotation('pagination_items', paginationMetadata.pagination.totalItems);
1371
+ subsegment.addMetadata('pagination_details', paginationMetadata.pagination);
1372
+ }
1373
+
1374
+ // Store final response and combined metadata
1375
+ this.#response = finalResponse;
1376
+ this.#responseMetadata = {
1377
+ ...retryMetadata,
1378
+ ...paginationMetadata
1379
+ };
1380
+
1381
+ subsegment.addAnnotation('success', finalResponse.success ? "true" : "false");
1382
+ subsegment.addAnnotation('status_code', finalResponse?.statusCode || 500);
1383
+ subsegment.addAnnotation('note', this.getNote());
1384
+
1385
+ return true;
1386
+ } catch (error) {
1387
+ DebugAndLog.error(`Error in APIRequest call to remote endpoint (${this.getNote()}): ${error.message}`, error.stack);
1388
+ subsegment.addError(error);
1389
+ throw error;
1390
+ } finally {
1391
+ subsegment.close();
1392
+ }
1393
+ });
1394
+ } else {
1395
+ // Use retry handler which handles redirects internally
1396
+ const { response, metadata: retryMetadata } = await this._handleRetries(options, xRayProxy);
1397
+
1398
+ // Handle pagination if enabled
1399
+ const { response: finalResponse, metadata: paginationMetadata } =
1400
+ await this._handlePagination(response);
1401
+
1402
+ // Store final response and combined metadata
1403
+ this.#response = finalResponse;
1404
+ this.#responseMetadata = {
1405
+ ...retryMetadata,
1406
+ ...paginationMetadata
1407
+ };
1408
+ }
1409
+
1410
+ // Add metadata to response if retries or pagination occurred
1411
+ if (this.#responseMetadata) {
1412
+ const hasRetries = this.#responseMetadata.retries?.occurred === true;
1413
+ const hasPagination = this.#responseMetadata.pagination?.occurred === true;
1414
+
1415
+ if (hasRetries || hasPagination) {
1416
+ // Create response with metadata
1417
+ const responseWithMetadata = {
1418
+ ...this.#response,
1419
+ metadata: {}
1420
+ };
1421
+
1422
+ // Add retry metadata if retries occurred
1423
+ if (hasRetries) {
1424
+ responseWithMetadata.metadata.retries = this.#responseMetadata.retries;
1425
+ }
1426
+
1427
+ // Add pagination metadata if pagination occurred
1428
+ if (hasPagination) {
1429
+ responseWithMetadata.metadata.pagination = this.#responseMetadata.pagination;
1430
+ }
1431
+
1432
+ resolve(responseWithMetadata);
1433
+ return;
1434
+ }
1435
+ }
1436
+
1437
+ // we now have a completed response (without metadata)
607
1438
  resolve( this.#response );
608
1439
  }
609
1440
  catch (error) {
@@ -640,21 +1471,47 @@ class APIRequest {
640
1471
  };
641
1472
 
642
1473
  /**
643
- * Formats the response for returning to program logic
644
- * @param {boolean} success
645
- * @param {number} statusCode
646
- * @param {string} message
647
- * @param {object} headers
648
- * @param {string} body
649
- * @returns {
650
- * {
651
- * success: boolean
652
- * statusCode: number
653
- * headers: object
654
- * body: string
655
- * message: string
656
- * }
657
- * }
1474
+ * Formats the response for returning to program logic. When pagination or retry
1475
+ * features are used, the response may include an optional metadata field with
1476
+ * details about those operations.
1477
+ *
1478
+ * @param {boolean} [success=false] - Whether the request was successful
1479
+ * @param {number} [statusCode=0] - HTTP status code
1480
+ * @param {string|null} [message=null] - Response message
1481
+ * @param {object|null} [headers=null] - Response headers
1482
+ * @param {string|null} [body=null] - Response body
1483
+ * @returns {object} Formatted response object
1484
+ * @returns {boolean} return.success - Whether the request was successful
1485
+ * @returns {number} return.statusCode - HTTP status code
1486
+ * @returns {object|null} return.headers - Response headers
1487
+ * @returns {string|null} return.body - Response body
1488
+ * @returns {string|null} return.message - Response message
1489
+ *
1490
+ * @example
1491
+ * // Basic response format
1492
+ * const response = APIRequest.responseFormat(true, 200, "SUCCESS", headers, body);
1493
+ * // { success: true, statusCode: 200, headers: {...}, body: "...", message: "SUCCESS" }
1494
+ *
1495
+ * @example
1496
+ * // Error response format
1497
+ * const response = APIRequest.responseFormat(false, 500, "Internal Server Error");
1498
+ * // { success: false, statusCode: 500, headers: null, body: null, message: "Internal Server Error" }
1499
+ *
1500
+ * @example
1501
+ * // Response with metadata (added by send() method when pagination/retry occurs)
1502
+ * // Note: metadata is NOT added by this static method, but by the send() method
1503
+ * const response = await apiRequest.send();
1504
+ * // {
1505
+ * // success: true,
1506
+ * // statusCode: 200,
1507
+ * // headers: {...},
1508
+ * // body: "...",
1509
+ * // message: "SUCCESS",
1510
+ * // metadata: {
1511
+ * // retries: { occurred: true, attempts: 2, finalAttempt: 2 },
1512
+ * // pagination: { occurred: true, totalPages: 5, totalItems: 1000, incomplete: false, error: null }
1513
+ * // }
1514
+ * // }
658
1515
  */
659
1516
  static responseFormat(success = false, statusCode = 0, message = null, headers = null, body = null) {
660
1517