From 22fde316cc1788b9f3890e8a2223ab3e366718c6 Mon Sep 17 00:00:00 2001 From: Kirk Morrow <9563562+kirkmorrow@users.noreply.github.com> Date: Sat, 6 Dec 2025 17:12:00 -0600 Subject: [PATCH 01/10] Implement REST API retry mechanism --- packages/dart/lib/parse_server_sdk.dart | 3 + .../dart/lib/src/data/parse_core_data.dart | 4 + .../lib/src/network/parse_dio_client.dart | 270 +++++---- .../lib/src/network/parse_http_client.dart | 185 ++++-- .../lib/src/network/parse_network_retry.dart | 179 ++++++ .../response/parse_error_response.dart | 27 +- .../parse_client_retry_integration_test.dart | 237 ++++++++ .../src/network/parse_network_retry_test.dart | 530 ++++++++++++++++++ 8 files changed, 1265 insertions(+), 170 deletions(-) create mode 100644 packages/dart/lib/src/network/parse_network_retry.dart create mode 100644 packages/dart/test/src/network/parse_client_retry_integration_test.dart create mode 100644 packages/dart/test/src/network/parse_network_retry_test.dart diff --git a/packages/dart/lib/parse_server_sdk.dart b/packages/dart/lib/parse_server_sdk.dart index cec24692a..c5ebde79f 100644 --- a/packages/dart/lib/parse_server_sdk.dart +++ b/packages/dart/lib/parse_server_sdk.dart @@ -36,6 +36,7 @@ part 'src/network/options.dart'; part 'src/network/parse_client.dart'; part 'src/network/parse_connectivity.dart'; part 'src/network/parse_live_query.dart'; +part 'src/network/parse_network_retry.dart'; part 'src/network/parse_query.dart'; part 'src/objects/parse_acl.dart'; part 'src/objects/parse_array.dart'; @@ -118,6 +119,7 @@ class Parse { Map? registeredSubClassMap, ParseUserConstructor? parseUserConstructor, ParseFileConstructor? parseFileConstructor, + List? restRetryIntervals, List? liveListRetryIntervals, ParseConnectivityProvider? connectivityProvider, String? fileDirectory, @@ -144,6 +146,7 @@ class Parse { registeredSubClassMap: registeredSubClassMap, parseUserConstructor: parseUserConstructor, parseFileConstructor: parseFileConstructor, + restRetryIntervals: restRetryIntervals, liveListRetryIntervals: liveListRetryIntervals, connectivityProvider: connectivityProvider, fileDirectory: fileDirectory, diff --git a/packages/dart/lib/src/data/parse_core_data.dart b/packages/dart/lib/src/data/parse_core_data.dart index aaf2ced58..37bdef0ef 100644 --- a/packages/dart/lib/src/data/parse_core_data.dart +++ b/packages/dart/lib/src/data/parse_core_data.dart @@ -32,6 +32,7 @@ class ParseCoreData { Map? registeredSubClassMap, ParseUserConstructor? parseUserConstructor, ParseFileConstructor? parseFileConstructor, + List? restRetryIntervals, List? liveListRetryIntervals, ParseConnectivityProvider? connectivityProvider, String? fileDirectory, @@ -52,6 +53,8 @@ class ParseCoreData { _instance.sessionId = sessionId; _instance.autoSendSessionId = autoSendSessionId; _instance.securityContext = securityContext; + _instance.restRetryIntervals = + restRetryIntervals ?? [0, 250, 500, 1000, 2000]; _instance.liveListRetryIntervals = liveListRetryIntervals ?? (parseIsWeb @@ -89,6 +92,7 @@ class ParseCoreData { late bool debug; late CoreStore storage; late ParseSubClassHandler _subClassHandler; + late List restRetryIntervals; late List liveListRetryIntervals; ParseConnectivityProvider? connectivityProvider; String? fileDirectory; diff --git a/packages/dart/lib/src/network/parse_dio_client.dart b/packages/dart/lib/src/network/parse_dio_client.dart index f339c6137..e0db7bc57 100644 --- a/packages/dart/lib/src/network/parse_dio_client.dart +++ b/packages/dart/lib/src/network/parse_dio_client.dart @@ -3,6 +3,20 @@ import 'package:parse_server_sdk/parse_server_sdk.dart'; import 'dio_adapter_io.dart' if (dart.library.js) 'dio_adapter_js.dart'; +/// HTTP client implementation for Parse Server using the Dio package. +/// +/// Coverage Note: +/// +/// This file typically shows low test coverage (4-5%) in LCOV reports because: +/// - Integration tests use MockParseClient which bypasses actual HTTP operations +/// - The retry logic (tested at 100% in parse_network_retry_test.dart) wraps +/// these HTTP methods but isn't exercised when using mocks +/// - This is architecturally correct: retry operates at the HTTP layer, +/// while mocks operate at the ParseClient interface layer above it +/// +/// The core retry mechanism has 100% coverage in its dedicated unit tests. +/// This file's primary responsibility is thin wrapper code around executeWithRetry(). + class ParseDioClient extends ParseClient { // securityContext is SecurityContext ParseDioClient({bool sendSessionId = false, dynamic securityContext}) { @@ -22,22 +36,26 @@ class ParseDioClient extends ParseClient { ParseNetworkOptions? options, ProgressCallback? onReceiveProgress, }) async { - try { - final dio.Response dioResponse = await _client.get( - path, - options: _Options(headers: options?.headers), - ); + return executeWithRetry( + operation: () async { + try { + final dio.Response dioResponse = await _client.get( + path, + options: _Options(headers: options?.headers), + ); - return ParseNetworkResponse( - data: dioResponse.data!, - statusCode: dioResponse.statusCode!, - ); - } on dio.DioException catch (error) { - return ParseNetworkResponse( - data: error.response?.data ?? _fallbackErrorData, - statusCode: error.response?.statusCode ?? ParseError.otherCause, - ); - } + return ParseNetworkResponse( + data: dioResponse.data!, + statusCode: dioResponse.statusCode!, + ); + } on dio.DioException catch (error) { + return ParseNetworkResponse( + data: error.response?.data ?? _fallbackErrorData, + statusCode: error.response?.statusCode ?? ParseError.otherCause, + ); + } + }, + ); } @override @@ -47,34 +65,39 @@ class ParseDioClient extends ParseClient { ProgressCallback? onReceiveProgress, dynamic cancelToken, }) async { - try { - final dio.Response> dioResponse = await _client.get>( - path, - cancelToken: cancelToken, - onReceiveProgress: onReceiveProgress, - options: _Options( - headers: options?.headers, - responseType: dio.ResponseType.bytes, - ), - ); - return ParseNetworkByteResponse( - bytes: dioResponse.data, - statusCode: dioResponse.statusCode!, - ); - } on dio.DioException catch (error) { - if (error.response != null) { - return ParseNetworkByteResponse( - data: error.response?.data ?? _fallbackErrorData, - statusCode: error.response?.statusCode ?? ParseError.otherCause, - ); - } else { - return ParseNetworkByteResponse( - data: - "{\"code\":${ParseError.otherCause},\"error\":\"${error.error.toString()}\"}", - statusCode: ParseError.otherCause, - ); - } - } + return executeWithRetry( + operation: () async { + try { + final dio.Response> dioResponse = await _client + .get>( + path, + cancelToken: cancelToken, + onReceiveProgress: onReceiveProgress, + options: _Options( + headers: options?.headers, + responseType: dio.ResponseType.bytes, + ), + ); + return ParseNetworkByteResponse( + bytes: dioResponse.data, + statusCode: dioResponse.statusCode!, + ); + } on dio.DioException catch (error) { + if (error.response != null) { + return ParseNetworkByteResponse( + data: error.response?.data ?? _fallbackErrorData, + statusCode: error.response?.statusCode ?? ParseError.otherCause, + ); + } else { + return ParseNetworkByteResponse( + data: + "{\"code\":${ParseError.otherCause},\"error\":\"${error.error.toString()}\"}", + statusCode: ParseError.otherCause, + ); + } + } + }, + ); } @override @@ -83,23 +106,27 @@ class ParseDioClient extends ParseClient { String? data, ParseNetworkOptions? options, }) async { - try { - final dio.Response dioResponse = await _client.put( - path, - data: data, - options: _Options(headers: options?.headers), - ); + return executeWithRetry( + operation: () async { + try { + final dio.Response dioResponse = await _client.put( + path, + data: data, + options: _Options(headers: options?.headers), + ); - return ParseNetworkResponse( - data: dioResponse.data!, - statusCode: dioResponse.statusCode!, - ); - } on dio.DioException catch (error) { - return ParseNetworkResponse( - data: error.response?.data ?? _fallbackErrorData, - statusCode: error.response?.statusCode ?? ParseError.otherCause, - ); - } + return ParseNetworkResponse( + data: dioResponse.data!, + statusCode: dioResponse.statusCode!, + ); + } on dio.DioException catch (error) { + return ParseNetworkResponse( + data: error.response?.data ?? _fallbackErrorData, + statusCode: error.response?.statusCode ?? ParseError.otherCause, + ); + } + }, + ); } @override @@ -108,23 +135,27 @@ class ParseDioClient extends ParseClient { String? data, ParseNetworkOptions? options, }) async { - try { - final dio.Response dioResponse = await _client.post( - path, - data: data, - options: _Options(headers: options?.headers), - ); + return executeWithRetry( + operation: () async { + try { + final dio.Response dioResponse = await _client.post( + path, + data: data, + options: _Options(headers: options?.headers), + ); - return ParseNetworkResponse( - data: dioResponse.data!, - statusCode: dioResponse.statusCode!, - ); - } on dio.DioException catch (error) { - return ParseNetworkResponse( - data: error.response?.data ?? _fallbackErrorData, - statusCode: error.response?.statusCode ?? ParseError.otherCause, - ); - } + return ParseNetworkResponse( + data: dioResponse.data!, + statusCode: dioResponse.statusCode!, + ); + } on dio.DioException catch (error) { + return ParseNetworkResponse( + data: error.response?.data ?? _fallbackErrorData, + statusCode: error.response?.statusCode ?? ParseError.otherCause, + ); + } + }, + ); } @override @@ -135,31 +166,35 @@ class ParseDioClient extends ParseClient { ProgressCallback? onSendProgress, dynamic cancelToken, }) async { - try { - final dio.Response dioResponse = await _client.post( - path, - data: data, - cancelToken: cancelToken, - options: _Options(headers: options?.headers), - onSendProgress: onSendProgress, - ); + return executeWithRetry( + operation: () async { + try { + final dio.Response dioResponse = await _client.post( + path, + data: data, + cancelToken: cancelToken, + options: _Options(headers: options?.headers), + onSendProgress: onSendProgress, + ); - return ParseNetworkResponse( - data: dioResponse.data!, - statusCode: dioResponse.statusCode!, - ); - } on dio.DioException catch (error) { - if (error.response != null) { - return ParseNetworkResponse( - data: error.response?.data ?? _fallbackErrorData, - statusCode: error.response?.statusCode ?? ParseError.otherCause, - ); - } else { - return _getOtherCaseErrorForParseNetworkResponse( - error.error.toString(), - ); - } - } + return ParseNetworkResponse( + data: dioResponse.data!, + statusCode: dioResponse.statusCode!, + ); + } on dio.DioException catch (error) { + if (error.response != null) { + return ParseNetworkResponse( + data: error.response?.data ?? _fallbackErrorData, + statusCode: error.response?.statusCode ?? ParseError.otherCause, + ); + } else { + return _getOtherCaseErrorForParseNetworkResponse( + error.error.toString(), + ); + } + } + }, + ); } ParseNetworkResponse _getOtherCaseErrorForParseNetworkResponse(String error) { @@ -174,25 +209,30 @@ class ParseDioClient extends ParseClient { String path, { ParseNetworkOptions? options, }) async { - try { - final dio.Response dioResponse = await _client.delete( - path, - options: _Options(headers: options?.headers), - ); + return executeWithRetry( + operation: () async { + try { + final dio.Response dioResponse = await _client.delete( + path, + options: _Options(headers: options?.headers), + ); - return ParseNetworkResponse( - data: dioResponse.data!, - statusCode: dioResponse.statusCode!, - ); - } on dio.DioException catch (error) { - return ParseNetworkResponse( - data: error.response?.data ?? _fallbackErrorData, - statusCode: error.response?.statusCode ?? ParseError.otherCause, - ); - } + return ParseNetworkResponse( + data: dioResponse.data!, + statusCode: dioResponse.statusCode!, + ); + } on dio.DioException catch (error) { + return ParseNetworkResponse( + data: error.response?.data ?? _fallbackErrorData, + statusCode: error.response?.statusCode ?? ParseError.otherCause, + ); + } + }, + ); } - String get _fallbackErrorData => '{"$keyError":"NetworkError"}'; + String get _fallbackErrorData => + '{"code":${ParseError.otherCause},"error":"NetworkError"}'; } /// Creates a custom version of HTTP Client that has Parse Data Preset diff --git a/packages/dart/lib/src/network/parse_http_client.dart b/packages/dart/lib/src/network/parse_http_client.dart index b319b2820..8d1c0ee5d 100644 --- a/packages/dart/lib/src/network/parse_http_client.dart +++ b/packages/dart/lib/src/network/parse_http_client.dart @@ -7,6 +7,20 @@ import 'package:parse_server_sdk/parse_server_sdk.dart'; import 'http_client_io.dart' if (dart.library.js) 'http_client_js.dart'; +/// HTTP client implementation for Parse Server using the 'http' package. +/// +/// Coverage Note: +/// +/// This file typically shows low test coverage (4-5%) in LCOV reports because: +/// - Integration tests use MockParseClient which bypasses actual HTTP operations +/// - The retry logic (tested at 100% in parse_network_retry_test.dart) wraps +/// these HTTP methods but isn't exercised when using mocks +/// - This is architecturally correct: retry operates at the HTTP layer, +/// while mocks operate at the ParseClient interface layer above it +/// +/// The core retry mechanism has 100% coverage in its dedicated unit tests. +/// This file's primary responsibility is thin wrapper code around executeWithRetry(). + class ParseHTTPClient extends ParseClient { ParseHTTPClient({ bool sendSessionId = false, @@ -33,13 +47,25 @@ class ParseHTTPClient extends ParseClient { ParseNetworkOptions? options, ProgressCallback? onReceiveProgress, }) async { - final http.Response response = await _client.get( - Uri.parse(path), - headers: options?.headers, - ); - return ParseNetworkResponse( - data: response.body, - statusCode: response.statusCode, + return executeWithRetry( + operation: () async { + try { + final http.Response response = await _client.get( + Uri.parse(path), + headers: options?.headers, + ); + return ParseNetworkResponse( + data: response.body, + statusCode: response.statusCode, + ); + } catch (e) { + return ParseNetworkResponse( + data: + '{"code":${ParseError.otherCause},"error":"NetworkError","exception":"${e.toString()}"}', + statusCode: ParseError.otherCause, + ); + } + }, ); } @@ -50,13 +76,25 @@ class ParseHTTPClient extends ParseClient { ProgressCallback? onReceiveProgress, dynamic cancelToken, }) async { - final http.Response response = await _client.get( - Uri.parse(path), - headers: options?.headers, - ); - return ParseNetworkByteResponse( - bytes: response.bodyBytes, - statusCode: response.statusCode, + return executeWithRetry( + operation: () async { + try { + final http.Response response = await _client.get( + Uri.parse(path), + headers: options?.headers, + ); + return ParseNetworkByteResponse( + bytes: response.bodyBytes, + statusCode: response.statusCode, + ); + } catch (e) { + return ParseNetworkByteResponse( + data: + '{"code":${ParseError.otherCause},"error":"NetworkError","exception":"${e.toString()}"}', + statusCode: ParseError.otherCause, + ); + } + }, ); } @@ -66,14 +104,26 @@ class ParseHTTPClient extends ParseClient { String? data, ParseNetworkOptions? options, }) async { - final http.Response response = await _client.put( - Uri.parse(path), - body: data, - headers: options?.headers, - ); - return ParseNetworkResponse( - data: response.body, - statusCode: response.statusCode, + return executeWithRetry( + operation: () async { + try { + final http.Response response = await _client.put( + Uri.parse(path), + body: data, + headers: options?.headers, + ); + return ParseNetworkResponse( + data: response.body, + statusCode: response.statusCode, + ); + } catch (e) { + return ParseNetworkResponse( + data: + '{"code":${ParseError.otherCause},"error":"NetworkError","exception":"${e.toString()}"}', + statusCode: ParseError.otherCause, + ); + } + }, ); } @@ -83,14 +133,26 @@ class ParseHTTPClient extends ParseClient { String? data, ParseNetworkOptions? options, }) async { - final http.Response response = await _client.post( - Uri.parse(path), - body: data, - headers: options?.headers, - ); - return ParseNetworkResponse( - data: response.body, - statusCode: response.statusCode, + return executeWithRetry( + operation: () async { + try { + final http.Response response = await _client.post( + Uri.parse(path), + body: data, + headers: options?.headers, + ); + return ParseNetworkResponse( + data: response.body, + statusCode: response.statusCode, + ); + } catch (e) { + return ParseNetworkResponse( + data: + '{"code":${ParseError.otherCause},"error":"NetworkError","exception":"${e.toString()}"}', + statusCode: ParseError.otherCause, + ); + } + }, ); } @@ -102,18 +164,31 @@ class ParseHTTPClient extends ParseClient { ProgressCallback? onSendProgress, dynamic cancelToken, }) async { - final http.Response response = await _client.post( - Uri.parse(path), - //Convert the stream to a list - body: await data?.fold>( - [], - (List previous, List element) => previous..addAll(element), - ), - headers: options?.headers, - ); - return ParseNetworkResponse( - data: response.body, - statusCode: response.statusCode, + return executeWithRetry( + operation: () async { + try { + final http.Response response = await _client.post( + Uri.parse(path), + //Convert the stream to a list + body: await data?.fold>( + [], + (List previous, List element) => + previous..addAll(element), + ), + headers: options?.headers, + ); + return ParseNetworkResponse( + data: response.body, + statusCode: response.statusCode, + ); + } catch (e) { + return ParseNetworkResponse( + data: + '{"code":${ParseError.otherCause},"error":"NetworkError","exception":"${e.toString()}"}', + statusCode: ParseError.otherCause, + ); + } + }, ); } @@ -122,13 +197,25 @@ class ParseHTTPClient extends ParseClient { String path, { ParseNetworkOptions? options, }) async { - final http.Response response = await _client.delete( - Uri.parse(path), - headers: options?.headers, - ); - return ParseNetworkResponse( - data: response.body, - statusCode: response.statusCode, + return executeWithRetry( + operation: () async { + try { + final http.Response response = await _client.delete( + Uri.parse(path), + headers: options?.headers, + ); + return ParseNetworkResponse( + data: response.body, + statusCode: response.statusCode, + ); + } catch (e) { + return ParseNetworkResponse( + data: + '{"code":${ParseError.otherCause},"error":"NetworkError","exception":"${e.toString()}"}', + statusCode: ParseError.otherCause, + ); + } + }, ); } } diff --git a/packages/dart/lib/src/network/parse_network_retry.dart b/packages/dart/lib/src/network/parse_network_retry.dart new file mode 100644 index 000000000..071a161fb --- /dev/null +++ b/packages/dart/lib/src/network/parse_network_retry.dart @@ -0,0 +1,179 @@ +part of '../../parse_server_sdk.dart'; + +/// Executes a network operation with automatic retry on transient failures. +/// +/// This function will retry REST API requests that fail due to network issues, +/// such as receiving HTML error pages from proxies/load balancers instead of +/// JSON responses, or experiencing connection failures. +/// +/// Retries are performed based on [ParseCoreData.restRetryIntervals]. +/// Each retry is delayed according to the corresponding interval in milliseconds. +/// The maximum number of retries is enforced to prevent excessive retry attempts +/// (limited to 100 retries maximum). +/// +/// Retry Conditions: +/// +/// A request will be retried if: +/// - Status code is `-1` (indicates network/parsing error) +/// - Response body contains HTML markup (proxy/load balancer error) +/// - An exception is thrown during the request +/// +/// A request will NOT be retried for: +/// - Successful responses (status 200, 201) +/// - Valid Parse Server errors (e.g., 101 for object not found) +/// +/// Important Note on Non-Idempotent Methods (POST/PUT): +/// +/// This retry mechanism is applied to ALL HTTP methods including POST and PUT. +/// While GET and DELETE are generally safe to retry, POST and PUT operations +/// may cause duplicate operations if the original request succeeded but the +/// response was lost or corrupted. Consider this when using retry intervals +/// with operations that modify server state. +/// +/// In most Parse Server scenarios, this is acceptable because: +/// - Parse uses optimistic locking with object versions +/// - Many Parse operations are idempotent by design +/// - Retries only occur on network-level failures (status -1), not on +/// successful operations that return Parse error codes +/// +/// Example: +/// +/// ```dart +/// final response = await executeWithRetry( +/// operation: () async { +/// final result = await client.get(url); +/// return result; +/// }, +/// debug: true, +/// ); +/// ``` +/// +/// Parameters: +/// +/// - [operation]: The network operation to execute and potentially retry +/// - [debug]: Whether to log retry attempts (defaults to [ParseCoreData.debug]) +/// +/// Returns +/// +/// The final response (either [ParseNetworkResponse] or [ParseNetworkByteResponse]) +/// after all retry attempts are exhausted. +Future executeWithRetry({ + required Future Function() operation, + bool? debug, +}) async { + final List retryIntervals = ParseCoreData().restRetryIntervals; + final bool debugEnabled = debug ?? ParseCoreData().debug; + + // Enforce maximum retry limit to prevent excessive attempts + const int maxRetries = 100; + if (retryIntervals.length > maxRetries) { + throw ArgumentError( + 'restRetryIntervals cannot exceed $maxRetries retries. ' + 'Current length: ${retryIntervals.length}', + ); + } + + int attemptNumber = 0; + T? lastResponse; + + // Attempt initial request plus retries based on interval list + for (int i = 0; i <= retryIntervals.length; i++) { + attemptNumber = i + 1; + + try { + lastResponse = await operation(); + + // Check if we should retry this response + if (!_shouldRetryResponse(lastResponse)) { + // Success or non-retryable error - return immediately + if (debugEnabled && i > 0) { + print( + 'Parse REST retry: Attempt $attemptNumber succeeded after $i ${i == 1 ? 'retry' : 'retries'}', + ); + } + return lastResponse; + } + + // If this was the last attempt, return the failure + if (i >= retryIntervals.length) { + if (debugEnabled) { + print( + 'Parse REST retry: All $attemptNumber attempts failed, returning error', + ); + } + return lastResponse; + } + + // Wait before next retry + final int delayMs = retryIntervals[i]; + if (debugEnabled) { + print( + 'Parse REST retry: Attempt $attemptNumber failed (status: ${lastResponse.statusCode}), ' + 'retrying in ${delayMs}ms... (${i + 1}/${retryIntervals.length} retries)', + ); + } + await Future.delayed(Duration(milliseconds: delayMs)); + } catch (e) { + // If this was the last attempt, rethrow the exception + if (i >= retryIntervals.length) { + if (debugEnabled) { + print( + 'Parse REST retry: All $attemptNumber attempts failed with exception: $e', + ); + } + rethrow; + } + + // Wait before next retry + final int delayMs = retryIntervals[i]; + if (debugEnabled) { + print( + 'Parse REST retry: Attempt $attemptNumber threw exception: $e, ' + 'retrying in ${delayMs}ms... (${i + 1}/${retryIntervals.length} retries)', + ); + } + await Future.delayed(Duration(milliseconds: delayMs)); + } + } + + // Should never reach here, but return last response as fallback + return lastResponse!; +} + +/// Determines if a network response should be retried. +/// +/// Returns `true` if the response indicates a transient network error +/// that might succeed on retry, `false` otherwise. +/// +/// Retry Triggers: +/// +/// - Status code `-1` (network/parsing errors) +/// - Response body starts with HTML tags (proxy/load balancer errors) +/// +/// No Retry: +/// +/// - Status code 200 or 201 (success) +/// - Valid Parse Server error codes (e.g., 100-series errors) +bool _shouldRetryResponse(ParseNetworkResponse response) { + // Retry on status code -1 (network/parse errors) + if (response.statusCode == ParseError.otherCause) { + // Additional check: is it HTML instead of JSON? + final String trimmedData = response.data.trimLeft().toLowerCase(); + + // Check for common HTML patterns that indicate proxy/load balancer errors + // More robust than just checking for '<' which could be in valid JSON strings + if (trimmedData.startsWith(' responseData = json.decode(apiResponse.data); + try { + final Map responseData = json.decode(apiResponse.data); - response.error = ParseError( - code: responseData[keyCode] ?? ParseError.otherCause, - message: responseData[keyError].toString(), - ); + response.error = ParseError( + code: responseData[keyCode] ?? ParseError.otherCause, + message: responseData[keyError].toString(), + ); - response.statusCode = responseData[keyCode] ?? ParseError.otherCause; + response.statusCode = responseData[keyCode] ?? ParseError.otherCause; + } on FormatException catch (e) { + // Handle non-JSON responses (e.g., HTML from proxy/load balancer) + final String preview = apiResponse.data.length > 100 + ? '${apiResponse.data.substring(0, 100)}...' + : apiResponse.data; + + response.error = ParseError( + code: ParseError.otherCause, + message: 'Invalid response format (expected JSON): $preview', + exception: e, + ); + + response.statusCode = ParseError.otherCause; + } return response; } diff --git a/packages/dart/test/src/network/parse_client_retry_integration_test.dart b/packages/dart/test/src/network/parse_client_retry_integration_test.dart new file mode 100644 index 000000000..353aac5ba --- /dev/null +++ b/packages/dart/test/src/network/parse_client_retry_integration_test.dart @@ -0,0 +1,237 @@ +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:parse_server_sdk/parse_server_sdk.dart'; +import 'package:test/test.dart'; + +import '../../parse_query_test.mocks.dart'; +import '../../test_utils.dart'; + +/// Integration tests for retry mechanism using MockParseClient. +/// +/// Architectural Note: +/// +/// These tests demonstrate that mocking at the ParseClient level +/// bypasses the retry mechanism, since retry logic operates at the HTTP +/// client level (ParseHTTPClient/ParseDioClient). The retry mechanism +/// wraps the actual HTTP operations, not the ParseClient interface. +/// +/// Coverage Implications: +/// +/// These tests intentionally do NOT exercise the retry logic because: +/// - Mocks return responses directly without going through HTTP layer +/// - This is why ParseHTTPClient/ParseDioClient show ~4% coverage +/// - The retry mechanism itself has 100% coverage via parse_network_retry_test.dart +/// - This low HTTP client coverage is expected and architecturally correct +/// +/// Testing Strategy: +/// +/// - **Unit tests** (parse_network_retry_test.dart): Test retry logic in isolation (100%) +/// - **Integration tests** (this file): Verify ParseClient interface behavior +/// - Together these provide complete validation without redundant testing +/// +/// These tests verify the expected behavior when HTML/error responses +/// are returned directly from a mocked client (no retry occurs). +@GenerateMocks([ParseClient]) +void main() { + setUpAll(() async { + await initializeParse(); + }); + + group('MockClient Behavior (No Retry - Expected)', () { + late MockParseClient client; + + setUp(() { + client = MockParseClient(); + }); + + test( + 'HTML error response is processed without retry (mock bypasses HTTP layer)', + () async { + int callCount = 0; + + when( + client.get( + any, + options: anyNamed('options'), + onReceiveProgress: anyNamed('onReceiveProgress'), + ), + ).thenAnswer((_) async { + callCount++; + // Mock returns HTML error directly + return ParseNetworkResponse( + data: '502 Bad Gateway', + statusCode: -1, + ); + }); + + final query = QueryBuilder(ParseObject('TestObject', client: client)); + final response = await query.query(); + + // Retry does NOT occur at this level - mock client bypasses HTTP layer + expect(callCount, 1); + expect(response.success, false); + expect(response.statusCode, -1); + }, + ); + + test( + 'status -1 error is processed without retry (mock bypasses HTTP layer)', + () async { + int callCount = 0; + + when( + client.get( + any, + options: anyNamed('options'), + onReceiveProgress: anyNamed('onReceiveProgress'), + ), + ).thenAnswer((_) async { + callCount++; + return ParseNetworkResponse( + data: '{"code":-1,"error":"NetworkError"}', + statusCode: -1, + ); + }); + + final query = QueryBuilder(ParseObject('TestObject', client: client)); + final response = await query.query(); + + expect(callCount, 1); // No retry - mock client used + expect(response.success, false); + expect(response.statusCode, -1); + }, + ); + + test('ParseObject.save() with HTML error (no retry via mock)', () async { + int callCount = 0; + + when( + client.post(any, data: anyNamed('data'), options: anyNamed('options')), + ).thenAnswer((_) async { + callCount++; + return ParseNetworkResponse( + data: + 'ErrorService Unavailable', + statusCode: -1, + ); + }); + + final object = ParseObject('TestObject', client: client) + ..set('name', 'Test'); + final response = await object.save(); + + expect(callCount, 1); // No retry via mock + expect(response.success, false); + }); + + test( + 'ParseObject.fetch() processes network error (no retry via mock)', + () async { + int callCount = 0; + + when( + client.get( + any, + options: anyNamed('options'), + onReceiveProgress: anyNamed('onReceiveProgress'), + ), + ).thenAnswer((_) async { + callCount++; + return ParseNetworkResponse( + data: + '{"code":-1,"error":"NetworkError","exception":"Connection timeout"}', + statusCode: -1, + ); + }); + + final object = ParseObject('TestObject', client: client) + ..objectId = 'abc123'; + final fetchedObject = await object.fetch(); + + expect(callCount, 1); // No retry via mock + expect(fetchedObject.objectId, 'abc123'); + }, + ); + + test( + 'ParseObject.delete() with HTML response (no retry via mock)', + () async { + int callCount = 0; + + when(client.delete(any, options: anyNamed('options'))).thenAnswer(( + _, + ) async { + callCount++; + return ParseNetworkResponse( + data: 'Gateway Timeout', + statusCode: -1, + ); + }); + + final object = ParseObject('TestObject', client: client) + ..objectId = 'delete123'; + final response = await object.delete(); + + expect(callCount, 1); // No retry via mock + expect(response.success, false); + }, + ); + + test( + 'valid Parse Server errors are NOT retried (expected behavior)', + () async { + int callCount = 0; + + when( + client.get( + any, + options: anyNamed('options'), + onReceiveProgress: anyNamed('onReceiveProgress'), + ), + ).thenAnswer((_) async { + callCount++; + // Return Parse error 101 (object not found) + return ParseNetworkResponse( + data: '{"code":101,"error":"Object not found"}', + statusCode: 101, + ); + }); + + final query = QueryBuilder(ParseObject('TestObject', client: client)); + final response = await query.query(); + + expect(callCount, 1); // No retry on valid Parse errors + expect(response.success, false); + expect(response.error?.code, 101); + }, + ); + + test( + 'demonstrates HTML error handling at mock level (retry tested in unit tests)', + () async { + int callCount = 0; + + when( + client.get( + any, + options: anyNamed('options'), + onReceiveProgress: anyNamed('onReceiveProgress'), + ), + ).thenAnswer((_) async { + callCount++; + // Mock returns HTML error - no retry at this level + return ParseNetworkResponse( + data: 'Error', + statusCode: -1, + ); + }); + + final query = QueryBuilder(ParseObject('TestObject', client: client)); + final response = await query.query(); + + expect(callCount, 1); // Mock client doesn't trigger retry + expect(response.success, false); + }, + ); + }); +} diff --git a/packages/dart/test/src/network/parse_network_retry_test.dart b/packages/dart/test/src/network/parse_network_retry_test.dart new file mode 100644 index 000000000..c5aa8e026 --- /dev/null +++ b/packages/dart/test/src/network/parse_network_retry_test.dart @@ -0,0 +1,530 @@ +import 'package:parse_server_sdk/parse_server_sdk.dart'; +import 'package:test/test.dart'; + +import '../../test_utils.dart'; + +void main() { + setUpAll(() async { + await initializeParse(); + }); + + group('executeWithRetry', () { + test( + 'should return immediately on successful response (status 200)', + () async { + int callCount = 0; + final result = await executeWithRetry( + operation: () async { + callCount++; + return ParseNetworkResponse( + data: '{"result":"success"}', + statusCode: 200, + ); + }, + ); + + expect(callCount, 1); + expect(result.statusCode, 200); + expect(result.data, '{"result":"success"}'); + }, + ); + + test( + 'should return immediately on successful response (status 201)', + () async { + int callCount = 0; + final result = await executeWithRetry( + operation: () async { + callCount++; + return ParseNetworkResponse( + data: '{"created":true}', + statusCode: 201, + ); + }, + ); + + expect(callCount, 1); + expect(result.statusCode, 201); + }, + ); + + test('should not retry on valid Parse Server error codes', () async { + int callCount = 0; + final result = await executeWithRetry( + operation: () async { + callCount++; + return ParseNetworkResponse( + data: '{"code":101,"error":"Object not found"}', + statusCode: 101, + ); + }, + ); + + expect(callCount, 1); + expect(result.statusCode, 101); + }); + + test( + 'should retry on status code -1 and return after max retries', + () async { + int callCount = 0; + // Use minimal retry intervals for faster test + final oldIntervals = ParseCoreData().restRetryIntervals; + ParseCoreData().restRetryIntervals = [0, 10, 20]; // 3 retries total + + final result = await executeWithRetry( + operation: () async { + callCount++; + return ParseNetworkResponse( + data: '{"code":-1,"error":"NetworkError"}', + statusCode: -1, + ); + }, + ); + + // Should be called: initial + 3 retries = 4 times + expect(callCount, 4); + expect(result.statusCode, -1); + + // Restore original intervals + ParseCoreData().restRetryIntervals = oldIntervals; + }, + ); + + test( + 'should succeed after retries if operation eventually succeeds', + () async { + int callCount = 0; + final oldIntervals = ParseCoreData().restRetryIntervals; + ParseCoreData().restRetryIntervals = [0, 10, 20]; + + final result = await executeWithRetry( + operation: () async { + callCount++; + if (callCount < 3) { + return ParseNetworkResponse( + data: '{"code":-1,"error":"NetworkError"}', + statusCode: -1, + ); + } + return ParseNetworkResponse( + data: '{"result":"success"}', + statusCode: 200, + ); + }, + ); + + expect(callCount, 3); + expect(result.statusCode, 200); + expect(result.data, '{"result":"success"}'); + + ParseCoreData().restRetryIntervals = oldIntervals; + }, + ); + + test('should retry on HTML error response', () async { + int callCount = 0; + final oldIntervals = ParseCoreData().restRetryIntervals; + ParseCoreData().restRetryIntervals = [0, 10]; + + final result = await executeWithRetry( + operation: () async { + callCount++; + return ParseNetworkResponse( + data: 'Error', + statusCode: -1, + ); + }, + ); + + // Should retry: initial + 2 retries = 3 times + expect(callCount, 3); + expect(result.statusCode, -1); + + ParseCoreData().restRetryIntervals = oldIntervals; + }); + + test('should handle exceptions and retry', () async { + int callCount = 0; + final oldIntervals = ParseCoreData().restRetryIntervals; + ParseCoreData().restRetryIntervals = [0, 10]; + + expect( + () async => await executeWithRetry( + operation: () async { + callCount++; + throw Exception('Network timeout'); + }, + ), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('Network timeout'), + ), + ), + ); + + // Should retry on exceptions: initial + 2 retries = 3 times + await Future.delayed(Duration(milliseconds: 50)); // Wait for retries + expect(callCount, 3); + + ParseCoreData().restRetryIntervals = oldIntervals; + }); + + test('should succeed after exception if operation recovers', () async { + int callCount = 0; + final oldIntervals = ParseCoreData().restRetryIntervals; + ParseCoreData().restRetryIntervals = [0, 10, 20]; + + final result = await executeWithRetry( + operation: () async { + callCount++; + if (callCount < 2) { + throw Exception('Temporary failure'); + } + return ParseNetworkResponse( + data: '{"recovered":true}', + statusCode: 200, + ); + }, + ); + + expect(callCount, 2); + expect(result.statusCode, 200); + + ParseCoreData().restRetryIntervals = oldIntervals; + }); + + test('should respect retry delay intervals', () async { + final oldIntervals = ParseCoreData().restRetryIntervals; + ParseCoreData().restRetryIntervals = [100, 200]; // Measurable delays + + final startTime = DateTime.now(); + await executeWithRetry( + operation: () async { + return ParseNetworkResponse( + data: '{"code":-1,"error":"NetworkError"}', + statusCode: -1, + ); + }, + ); + final duration = DateTime.now().difference(startTime); + + // Should have at least 300ms delay (100 + 200) + // Allow some variance for test execution + expect(duration.inMilliseconds, greaterThan(250)); + + ParseCoreData().restRetryIntervals = oldIntervals; + }); + + test('should throw ArgumentError if retry intervals exceed 100', () { + final oldIntervals = ParseCoreData().restRetryIntervals; + final tooManyRetries = List.generate(101, (i) => 10); + ParseCoreData().restRetryIntervals = tooManyRetries; + + expect( + () => executeWithRetry( + operation: () async => + ParseNetworkResponse(data: '', statusCode: 200), + ), + throwsA(isA()), + ); + + ParseCoreData().restRetryIntervals = oldIntervals; + }); + + test('should work with empty retry intervals list', () async { + int callCount = 0; + final oldIntervals = ParseCoreData().restRetryIntervals; + ParseCoreData().restRetryIntervals = []; + + final result = await executeWithRetry( + operation: () async { + callCount++; + return ParseNetworkResponse( + data: '{"code":-1,"error":"NetworkError"}', + statusCode: -1, + ); + }, + ); + + // Should only call once (no retries) + expect(callCount, 1); + expect(result.statusCode, -1); + + ParseCoreData().restRetryIntervals = oldIntervals; + }); + + test('should work with ParseNetworkByteResponse', () async { + int callCount = 0; + final oldIntervals = ParseCoreData().restRetryIntervals; + ParseCoreData().restRetryIntervals = [0, 10]; + + final result = await executeWithRetry( + operation: () async { + callCount++; + if (callCount < 2) { + return ParseNetworkByteResponse( + data: '{"code":-1,"error":"NetworkError"}', + statusCode: -1, + ); + } + return ParseNetworkByteResponse(bytes: [1, 2, 3, 4], statusCode: 200); + }, + ); + + expect(callCount, 2); + expect(result.statusCode, 200); + expect(result.bytes, [1, 2, 3, 4]); + + ParseCoreData().restRetryIntervals = oldIntervals; + }); + }); + + group('_shouldRetryResponse', () { + test('should return true for status code -1', () { + final response = ParseNetworkResponse( + data: '{"code":-1,"error":"NetworkError"}', + statusCode: -1, + ); + + // We can't directly test the private function, but we can test via executeWithRetry + // This test documents the expected behavior + expect(response.statusCode, -1); + }); + + test('should detect HTML with Error', + statusCode: -1, + ); + }, + ); + + // Should retry (2 calls total) + expect(callCount, 2); + + ParseCoreData().restRetryIntervals = oldIntervals; + }); + + test('should detect HTML with Error', + statusCode: -1, + ); + }, + ); + + expect(callCount, 2); + + ParseCoreData().restRetryIntervals = oldIntervals; + }); + + test('should detect HTML with Error', + statusCode: -1, + ); + }, + ); + + expect(callCount, 2); + + ParseCoreData().restRetryIntervals = oldIntervals; + }); + + test('should detect HTML with Error message', + statusCode: -1, + ); + }, + ); + + expect(callCount, 2); + + ParseCoreData().restRetryIntervals = oldIntervals; + }); + + test('should not retry JSON responses with status 200', () async { + int callCount = 0; + final result = await executeWithRetry( + operation: () async { + callCount++; + return ParseNetworkResponse( + data: '{"result":"success"}', + statusCode: 200, + ); + }, + ); + + expect(callCount, 1); + expect(result.statusCode, 200); + }); + + test('should not retry JSON responses with status 201', () async { + int callCount = 0; + final result = await executeWithRetry( + operation: () async { + callCount++; + return ParseNetworkResponse( + data: '{"objectId":"abc123"}', + statusCode: 201, + ); + }, + ); + + expect(callCount, 1); + expect(result.statusCode, 201); + }); + + test('should not retry Parse Server error codes (101, 200, etc)', () async { + int callCount = 0; + final result = await executeWithRetry( + operation: () async { + callCount++; + return ParseNetworkResponse( + data: '{"code":101,"error":"Object not found"}', + statusCode: 101, + ); + }, + ); + + expect(callCount, 1); + expect(result.statusCode, 101); + }); + + test('should handle whitespace before HTML tags', () async { + int callCount = 0; + final oldIntervals = ParseCoreData().restRetryIntervals; + ParseCoreData().restRetryIntervals = [0]; + + await executeWithRetry( + operation: () async { + callCount++; + return ParseNetworkResponse( + data: ' \n\t ', + statusCode: -1, + ); + }, + ); + + expect(callCount, 2); + + ParseCoreData().restRetryIntervals = oldIntervals; + }); + }); + + group('Configuration', () { + test('should use default retry intervals', () { + final intervals = ParseCoreData().restRetryIntervals; + expect(intervals, [0, 250, 500, 1000, 2000]); + }); + + test('should allow custom retry intervals', () async { + final oldIntervals = ParseCoreData().restRetryIntervals; + ParseCoreData().restRetryIntervals = [5, 10, 15]; + + int callCount = 0; + await executeWithRetry( + operation: () async { + callCount++; + return ParseNetworkResponse( + data: '{"code":-1,"error":"NetworkError"}', + statusCode: -1, + ); + }, + ); + + // Initial + 3 retries = 4 calls + expect(callCount, 4); + + ParseCoreData().restRetryIntervals = oldIntervals; + }); + + test('should validate max retry limit on each call', () { + final oldIntervals = ParseCoreData().restRetryIntervals; + + // Set to exactly 100 - should work + ParseCoreData().restRetryIntervals = List.generate(100, (i) => 10); + expect( + () => executeWithRetry( + operation: () async => + ParseNetworkResponse(data: '', statusCode: 200), + ), + returnsNormally, + ); + + // Set to 101 - should throw + ParseCoreData().restRetryIntervals = List.generate(101, (i) => 10); + expect( + () => executeWithRetry( + operation: () async => + ParseNetworkResponse(data: '', statusCode: 200), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('cannot exceed 100 retries'), + ), + ), + ); + + ParseCoreData().restRetryIntervals = oldIntervals; + }); + }); + + group('Error Format Consistency', () { + test('should handle error response with code field', () async { + final result = await executeWithRetry( + operation: () async { + return ParseNetworkResponse( + data: '{"code":-1,"error":"NetworkError","exception":"timeout"}', + statusCode: -1, + ); + }, + ); + + expect(result.data, contains('"code"')); + expect(result.data, contains('"error"')); + }); + + test('should work with both HTTP client implementations', () async { + // This test documents that both ParseDioClient and ParseHTTPClient + // should use the same error format + final errorFormat = '{"code":-1,"error":"NetworkError"}'; + expect(errorFormat, contains('"code":')); + expect(errorFormat, contains('"error":')); + }); + }); +} From bb98ddd34c06dd9173af340fb6afbaabd9afbc30 Mon Sep 17 00:00:00 2001 From: Kirk Morrow <9563562+kirkmorrow@users.noreply.github.com> Date: Sat, 6 Dec 2025 17:24:23 -0600 Subject: [PATCH 02/10] Update packages/dart/lib/src/network/parse_network_retry.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/dart/lib/src/network/parse_network_retry.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dart/lib/src/network/parse_network_retry.dart b/packages/dart/lib/src/network/parse_network_retry.dart index 071a161fb..47890699d 100644 --- a/packages/dart/lib/src/network/parse_network_retry.dart +++ b/packages/dart/lib/src/network/parse_network_retry.dart @@ -53,7 +53,7 @@ part of '../../parse_server_sdk.dart'; /// - [operation]: The network operation to execute and potentially retry /// - [debug]: Whether to log retry attempts (defaults to [ParseCoreData.debug]) /// -/// Returns +/// Returns: /// /// The final response (either [ParseNetworkResponse] or [ParseNetworkByteResponse]) /// after all retry attempts are exhausted. From 5096a9ec8090223ecab84ad46f3b1a30b721cc73 Mon Sep 17 00:00:00 2001 From: Kirk Morrow <9563562+kirkmorrow@users.noreply.github.com> Date: Sat, 6 Dec 2025 17:37:29 -0600 Subject: [PATCH 03/10] PR feedback fixes --- .../lib/src/network/parse_dio_client.dart | 22 ++++++++--- .../lib/src/network/parse_http_client.dart | 31 +++++++++------ .../lib/src/network/parse_network_retry.dart | 38 +++++++------------ .../parse_client_retry_integration_test.dart | 2 +- .../src/network/parse_network_retry_test.dart | 15 ++------ 5 files changed, 53 insertions(+), 55 deletions(-) diff --git a/packages/dart/lib/src/network/parse_dio_client.dart b/packages/dart/lib/src/network/parse_dio_client.dart index e0db7bc57..9b9faaeb4 100644 --- a/packages/dart/lib/src/network/parse_dio_client.dart +++ b/packages/dart/lib/src/network/parse_dio_client.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:dio/dio.dart' as dio; import 'package:parse_server_sdk/parse_server_sdk.dart'; @@ -90,8 +92,7 @@ class ParseDioClient extends ParseClient { ); } else { return ParseNetworkByteResponse( - data: - "{\"code\":${ParseError.otherCause},\"error\":\"${error.error.toString()}\"}", + data: _buildErrorJson(error.error.toString()), statusCode: ParseError.otherCause, ); } @@ -199,11 +200,23 @@ class ParseDioClient extends ParseClient { ParseNetworkResponse _getOtherCaseErrorForParseNetworkResponse(String error) { return ParseNetworkResponse( - data: "{\"code\":${ParseError.otherCause},\"error\":\"$error\"}", + data: _buildErrorJson(error), statusCode: ParseError.otherCause, ); } + /// Builds a properly escaped JSON error payload. + /// + /// This helper ensures error messages are safely escaped to prevent + /// malformed JSON when the message contains quotes or special characters. + String _buildErrorJson(String errorMessage) { + final Map errorPayload = { + 'code': ParseError.otherCause, + 'error': errorMessage, + }; + return jsonEncode(errorPayload); + } + @override Future delete( String path, { @@ -231,8 +244,7 @@ class ParseDioClient extends ParseClient { ); } - String get _fallbackErrorData => - '{"code":${ParseError.otherCause},"error":"NetworkError"}'; + String get _fallbackErrorData => _buildErrorJson('NetworkError'); } /// Creates a custom version of HTTP Client that has Parse Data Preset diff --git a/packages/dart/lib/src/network/parse_http_client.dart b/packages/dart/lib/src/network/parse_http_client.dart index 8d1c0ee5d..be78f4e75 100644 --- a/packages/dart/lib/src/network/parse_http_client.dart +++ b/packages/dart/lib/src/network/parse_http_client.dart @@ -60,8 +60,7 @@ class ParseHTTPClient extends ParseClient { ); } catch (e) { return ParseNetworkResponse( - data: - '{"code":${ParseError.otherCause},"error":"NetworkError","exception":"${e.toString()}"}', + data: _buildErrorJson(e.toString()), statusCode: ParseError.otherCause, ); } @@ -89,8 +88,7 @@ class ParseHTTPClient extends ParseClient { ); } catch (e) { return ParseNetworkByteResponse( - data: - '{"code":${ParseError.otherCause},"error":"NetworkError","exception":"${e.toString()}"}', + data: _buildErrorJson(e.toString()), statusCode: ParseError.otherCause, ); } @@ -118,8 +116,7 @@ class ParseHTTPClient extends ParseClient { ); } catch (e) { return ParseNetworkResponse( - data: - '{"code":${ParseError.otherCause},"error":"NetworkError","exception":"${e.toString()}"}', + data: _buildErrorJson(e.toString()), statusCode: ParseError.otherCause, ); } @@ -147,8 +144,7 @@ class ParseHTTPClient extends ParseClient { ); } catch (e) { return ParseNetworkResponse( - data: - '{"code":${ParseError.otherCause},"error":"NetworkError","exception":"${e.toString()}"}', + data: _buildErrorJson(e.toString()), statusCode: ParseError.otherCause, ); } @@ -183,8 +179,7 @@ class ParseHTTPClient extends ParseClient { ); } catch (e) { return ParseNetworkResponse( - data: - '{"code":${ParseError.otherCause},"error":"NetworkError","exception":"${e.toString()}"}', + data: _buildErrorJson(e.toString()), statusCode: ParseError.otherCause, ); } @@ -210,14 +205,26 @@ class ParseHTTPClient extends ParseClient { ); } catch (e) { return ParseNetworkResponse( - data: - '{"code":${ParseError.otherCause},"error":"NetworkError","exception":"${e.toString()}"}', + data: _buildErrorJson(e.toString()), statusCode: ParseError.otherCause, ); } }, ); } + + /// Builds a properly escaped JSON error payload. + /// + /// This helper ensures error messages are safely escaped to prevent + /// malformed JSON when the message contains quotes or special characters. + String _buildErrorJson(String exceptionMessage) { + final Map errorPayload = { + 'code': ParseError.otherCause, + 'error': 'NetworkError', + 'exception': exceptionMessage, + }; + return jsonEncode(errorPayload); + } } /// Creates a custom version of HTTP Client that has Parse Data Preset diff --git a/packages/dart/lib/src/network/parse_network_retry.dart b/packages/dart/lib/src/network/parse_network_retry.dart index 47890699d..acde49312 100644 --- a/packages/dart/lib/src/network/parse_network_retry.dart +++ b/packages/dart/lib/src/network/parse_network_retry.dart @@ -68,7 +68,8 @@ Future executeWithRetry({ const int maxRetries = 100; if (retryIntervals.length > maxRetries) { throw ArgumentError( - 'restRetryIntervals cannot exceed $maxRetries retries. ' + 'restRetryIntervals cannot exceed $maxRetries elements ' + '(which allows up to ${maxRetries + 1} total attempts). ' 'Current length: ${retryIntervals.length}', ); } @@ -136,8 +137,11 @@ Future executeWithRetry({ } } - // Should never reach here, but return last response as fallback - return lastResponse!; + // This should never be reached due to the loop logic above + throw StateError( + 'Retry loop completed without returning or rethrowing. ' + 'This indicates a logic error.', + ); } /// Determines if a network response should be retried. @@ -148,32 +152,16 @@ Future executeWithRetry({ /// Retry Triggers: /// /// - Status code `-1` (network/parsing errors) -/// - Response body starts with HTML tags (proxy/load balancer errors) +/// - HTML responses from proxies/load balancers (502, 503, 504 errors) +/// - Socket exceptions, timeouts, DNS failures +/// - JSON parse errors from malformed responses /// /// No Retry: /// /// - Status code 200 or 201 (success) /// - Valid Parse Server error codes (e.g., 100-series errors) +/// - These are application-level errors that won't resolve with retries bool _shouldRetryResponse(ParseNetworkResponse response) { - // Retry on status code -1 (network/parse errors) - if (response.statusCode == ParseError.otherCause) { - // Additional check: is it HTML instead of JSON? - final String trimmedData = response.data.trimLeft().toLowerCase(); - - // Check for common HTML patterns that indicate proxy/load balancer errors - // More robust than just checking for '<' which could be in valid JSON strings - if (trimmedData.startsWith(' await executeWithRetry( + await expectLater( + executeWithRetry( operation: () async { callCount++; throw Exception('Network timeout'); @@ -166,7 +166,6 @@ void main() { ); // Should retry on exceptions: initial + 2 retries = 3 times - await Future.delayed(Duration(milliseconds: 50)); // Wait for retries expect(callCount, 3); ParseCoreData().restRetryIntervals = oldIntervals; @@ -495,7 +494,7 @@ void main() { isA().having( (e) => e.message, 'message', - contains('cannot exceed 100 retries'), + contains('cannot exceed 100 elements'), ), ), ); @@ -518,13 +517,5 @@ void main() { expect(result.data, contains('"code"')); expect(result.data, contains('"error"')); }); - - test('should work with both HTTP client implementations', () async { - // This test documents that both ParseDioClient and ParseHTTPClient - // should use the same error format - final errorFormat = '{"code":-1,"error":"NetworkError"}'; - expect(errorFormat, contains('"code":')); - expect(errorFormat, contains('"error":')); - }); }); } From 26d407dee83b73a500cd281396476452373326d7 Mon Sep 17 00:00:00 2001 From: Kirk Morrow <9563562+kirkmorrow@users.noreply.github.com> Date: Sat, 6 Dec 2025 17:50:24 -0600 Subject: [PATCH 04/10] PR feedback --- .../lib/src/network/parse_network_retry.dart | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/dart/lib/src/network/parse_network_retry.dart b/packages/dart/lib/src/network/parse_network_retry.dart index acde49312..6a0e5f6ff 100644 --- a/packages/dart/lib/src/network/parse_network_retry.dart +++ b/packages/dart/lib/src/network/parse_network_retry.dart @@ -27,14 +27,21 @@ part of '../../parse_server_sdk.dart'; /// This retry mechanism is applied to ALL HTTP methods including POST and PUT. /// While GET and DELETE are generally safe to retry, POST and PUT operations /// may cause duplicate operations if the original request succeeded but the -/// response was lost or corrupted. Consider this when using retry intervals -/// with operations that modify server state. -/// -/// In most Parse Server scenarios, this is acceptable because: -/// - Parse uses optimistic locking with object versions -/// - Many Parse operations are idempotent by design -/// - Retries only occur on network-level failures (status -1), not on -/// successful operations that return Parse error codes +/// response was lost or corrupted. +/// +/// **Parse Server does not provide automatic optimistic locking or built-in +/// idempotency guarantees for POST/PUT operations.** Retrying these methods +/// can result in duplicate data creation or unintended state changes. +/// +/// To mitigate retry risks for critical operations: +/// - Implement application-level idempotency keys or version tracking +/// - Disable retries for create/update operations by setting +/// `ParseCoreData().restRetryIntervals = []` before critical calls +/// - Use Parse's experimental `X-Parse-Request-Id` header (if available) +/// with explicit duplicate detection in your application logic +/// +/// Note: Retries only occur on network-level failures (status -1), not on +/// successful operations that return Parse error codes /// /// Example: /// From aab707e60339354c152cb124736d3e40e09a0df9 Mon Sep 17 00:00:00 2001 From: Kirk Morrow <9563562+kirkmorrow@users.noreply.github.com> Date: Sat, 6 Dec 2025 17:53:31 -0600 Subject: [PATCH 05/10] CodeRabbitAI nitpicks --- .../lib/src/network/parse_network_retry.dart | 7 +++---- .../parse_client_retry_integration_test.dart | 1 + .../src/network/parse_network_retry_test.dart | 16 ++++------------ 3 files changed, 8 insertions(+), 16 deletions(-) diff --git a/packages/dart/lib/src/network/parse_network_retry.dart b/packages/dart/lib/src/network/parse_network_retry.dart index 6a0e5f6ff..d30a733a9 100644 --- a/packages/dart/lib/src/network/parse_network_retry.dart +++ b/packages/dart/lib/src/network/parse_network_retry.dart @@ -158,10 +158,9 @@ Future executeWithRetry({ /// /// Retry Triggers: /// -/// - Status code `-1` (network/parsing errors) -/// - HTML responses from proxies/load balancers (502, 503, 504 errors) -/// - Socket exceptions, timeouts, DNS failures -/// - JSON parse errors from malformed responses +/// - Status code `-1` (network/parsing errors from the HTTP client layer) +/// Note: HTML responses, socket exceptions, timeouts, and parse errors +/// are converted to status -1 by the HTTP client before reaching here. /// /// No Retry: /// diff --git a/packages/dart/test/src/network/parse_client_retry_integration_test.dart b/packages/dart/test/src/network/parse_client_retry_integration_test.dart index 774a94446..c0abcd0ac 100644 --- a/packages/dart/test/src/network/parse_client_retry_integration_test.dart +++ b/packages/dart/test/src/network/parse_client_retry_integration_test.dart @@ -150,6 +150,7 @@ void main() { expect(callCount, 1); // No retry via mock expect(fetchedObject.objectId, 'abc123'); // Original objectId preserved + // Note: fetch() returns ParseObject, not ParseResponse - success check not applicable }, ); diff --git a/packages/dart/test/src/network/parse_network_retry_test.dart b/packages/dart/test/src/network/parse_network_retry_test.dart index 4327a2a03..06a11c949 100644 --- a/packages/dart/test/src/network/parse_network_retry_test.dart +++ b/packages/dart/test/src/network/parse_network_retry_test.dart @@ -211,8 +211,8 @@ void main() { final duration = DateTime.now().difference(startTime); // Should have at least 300ms delay (100 + 200) - // Allow some variance for test execution - expect(duration.inMilliseconds, greaterThan(250)); + // Allow more variance for CI environments with resource contention + expect(duration.inMilliseconds, greaterThan(200)); ParseCoreData().restRetryIntervals = oldIntervals; }); @@ -282,16 +282,8 @@ void main() { }); group('_shouldRetryResponse', () { - test('should return true for status code -1', () { - final response = ParseNetworkResponse( - data: '{"code":-1,"error":"NetworkError"}', - statusCode: -1, - ); - - // We can't directly test the private function, but we can test via executeWithRetry - // This test documents the expected behavior - expect(response.statusCode, -1); - }); + // Note: _shouldRetryResponse is a private function tested indirectly via executeWithRetry. + // The retry behavior for status code -1 is validated by tests in the executeWithRetry group. test('should detect HTML with Date: Sat, 6 Dec 2025 18:07:38 -0600 Subject: [PATCH 06/10] Change default to no retry for write operations --- packages/dart/lib/parse_server_sdk.dart | 2 + .../dart/lib/src/data/parse_core_data.dart | 4 + .../lib/src/network/parse_dio_client.dart | 3 + .../lib/src/network/parse_http_client.dart | 3 + .../lib/src/network/parse_network_retry.dart | 40 ++++-- .../src/network/parse_network_retry_test.dart | 127 ++++++++++++++++++ 6 files changed, 168 insertions(+), 11 deletions(-) diff --git a/packages/dart/lib/parse_server_sdk.dart b/packages/dart/lib/parse_server_sdk.dart index c5ebde79f..847f928e5 100644 --- a/packages/dart/lib/parse_server_sdk.dart +++ b/packages/dart/lib/parse_server_sdk.dart @@ -120,6 +120,7 @@ class Parse { ParseUserConstructor? parseUserConstructor, ParseFileConstructor? parseFileConstructor, List? restRetryIntervals, + List? restRetryIntervalsForWrites, List? liveListRetryIntervals, ParseConnectivityProvider? connectivityProvider, String? fileDirectory, @@ -147,6 +148,7 @@ class Parse { parseUserConstructor: parseUserConstructor, parseFileConstructor: parseFileConstructor, restRetryIntervals: restRetryIntervals, + restRetryIntervalsForWrites: restRetryIntervalsForWrites, liveListRetryIntervals: liveListRetryIntervals, connectivityProvider: connectivityProvider, fileDirectory: fileDirectory, diff --git a/packages/dart/lib/src/data/parse_core_data.dart b/packages/dart/lib/src/data/parse_core_data.dart index 37bdef0ef..2a5555303 100644 --- a/packages/dart/lib/src/data/parse_core_data.dart +++ b/packages/dart/lib/src/data/parse_core_data.dart @@ -33,6 +33,7 @@ class ParseCoreData { ParseUserConstructor? parseUserConstructor, ParseFileConstructor? parseFileConstructor, List? restRetryIntervals, + List? restRetryIntervalsForWrites, List? liveListRetryIntervals, ParseConnectivityProvider? connectivityProvider, String? fileDirectory, @@ -55,6 +56,8 @@ class ParseCoreData { _instance.securityContext = securityContext; _instance.restRetryIntervals = restRetryIntervals ?? [0, 250, 500, 1000, 2000]; + _instance.restRetryIntervalsForWrites = + restRetryIntervalsForWrites ?? []; _instance.liveListRetryIntervals = liveListRetryIntervals ?? (parseIsWeb @@ -93,6 +96,7 @@ class ParseCoreData { late CoreStore storage; late ParseSubClassHandler _subClassHandler; late List restRetryIntervals; + late List restRetryIntervalsForWrites; late List liveListRetryIntervals; ParseConnectivityProvider? connectivityProvider; String? fileDirectory; diff --git a/packages/dart/lib/src/network/parse_dio_client.dart b/packages/dart/lib/src/network/parse_dio_client.dart index 9b9faaeb4..1ee2f97ec 100644 --- a/packages/dart/lib/src/network/parse_dio_client.dart +++ b/packages/dart/lib/src/network/parse_dio_client.dart @@ -108,6 +108,7 @@ class ParseDioClient extends ParseClient { ParseNetworkOptions? options, }) async { return executeWithRetry( + isWriteOperation: true, operation: () async { try { final dio.Response dioResponse = await _client.put( @@ -137,6 +138,7 @@ class ParseDioClient extends ParseClient { ParseNetworkOptions? options, }) async { return executeWithRetry( + isWriteOperation: true, operation: () async { try { final dio.Response dioResponse = await _client.post( @@ -168,6 +170,7 @@ class ParseDioClient extends ParseClient { dynamic cancelToken, }) async { return executeWithRetry( + isWriteOperation: true, operation: () async { try { final dio.Response dioResponse = await _client.post( diff --git a/packages/dart/lib/src/network/parse_http_client.dart b/packages/dart/lib/src/network/parse_http_client.dart index be78f4e75..d729733af 100644 --- a/packages/dart/lib/src/network/parse_http_client.dart +++ b/packages/dart/lib/src/network/parse_http_client.dart @@ -103,6 +103,7 @@ class ParseHTTPClient extends ParseClient { ParseNetworkOptions? options, }) async { return executeWithRetry( + isWriteOperation: true, operation: () async { try { final http.Response response = await _client.put( @@ -131,6 +132,7 @@ class ParseHTTPClient extends ParseClient { ParseNetworkOptions? options, }) async { return executeWithRetry( + isWriteOperation: true, operation: () async { try { final http.Response response = await _client.post( @@ -161,6 +163,7 @@ class ParseHTTPClient extends ParseClient { dynamic cancelToken, }) async { return executeWithRetry( + isWriteOperation: true, operation: () async { try { final http.Response response = await _client.post( diff --git a/packages/dart/lib/src/network/parse_network_retry.dart b/packages/dart/lib/src/network/parse_network_retry.dart index d30a733a9..f15f8f47a 100644 --- a/packages/dart/lib/src/network/parse_network_retry.dart +++ b/packages/dart/lib/src/network/parse_network_retry.dart @@ -24,21 +24,33 @@ part of '../../parse_server_sdk.dart'; /// /// Important Note on Non-Idempotent Methods (POST/PUT): /// -/// This retry mechanism is applied to ALL HTTP methods including POST and PUT. -/// While GET and DELETE are generally safe to retry, POST and PUT operations -/// may cause duplicate operations if the original request succeeded but the -/// response was lost or corrupted. -/// /// **Parse Server does not provide automatic optimistic locking or built-in -/// idempotency guarantees for POST/PUT operations.** Retrying these methods -/// can result in duplicate data creation or unintended state changes. +/// idempotency guarantees for POST/PUT operations.** To prevent duplicate +/// data creation or unintended state changes, this SDK defaults to **no retries** +/// for write operations (POST/PUT/postBytes). +/// +/// Default Behavior: +/// - **Write operations (POST/PUT)**: No retries (`restRetryIntervalsForWrites = []`) +/// - **Read operations (GET)**: Retries enabled (`restRetryIntervals = [0, 250, 500, 1000, 2000]`) +/// - **DELETE operations**: Retries enabled (generally safe to retry) /// -/// To mitigate retry risks for critical operations: +/// If you need to enable retries for write operations, configure +/// `restRetryIntervalsForWrites` during initialization. Consider these mitigations: /// - Implement application-level idempotency keys or version tracking -/// - Disable retries for create/update operations by setting -/// `ParseCoreData().restRetryIntervals = []` before critical calls /// - Use Parse's experimental `X-Parse-Request-Id` header (if available) /// with explicit duplicate detection in your application logic +/// - Use conservative retry intervals (e.g., `[1000, 2000]`) to allow time +/// for server-side processing before retrying +/// +/// Example: +/// ```dart +/// await Parse().initialize( +/// 'appId', +/// 'serverUrl', +/// // Enable retries for writes (use with caution) +/// restRetryIntervalsForWrites: [1000, 2000], +/// ); +/// ``` /// /// Note: Retries only occur on network-level failures (status -1), not on /// successful operations that return Parse error codes @@ -58,6 +70,9 @@ part of '../../parse_server_sdk.dart'; /// Parameters: /// /// - [operation]: The network operation to execute and potentially retry +/// - [isWriteOperation]: Whether this is a write operation (POST/PUT). +/// Defaults to `false`. When `true`, uses [ParseCoreData.restRetryIntervalsForWrites] +/// which defaults to no retries to prevent duplicate creates/updates. /// - [debug]: Whether to log retry attempts (defaults to [ParseCoreData.debug]) /// /// Returns: @@ -66,9 +81,12 @@ part of '../../parse_server_sdk.dart'; /// after all retry attempts are exhausted. Future executeWithRetry({ required Future Function() operation, + bool isWriteOperation = false, bool? debug, }) async { - final List retryIntervals = ParseCoreData().restRetryIntervals; + final List retryIntervals = isWriteOperation + ? ParseCoreData().restRetryIntervalsForWrites + : ParseCoreData().restRetryIntervals; final bool debugEnabled = debug ?? ParseCoreData().debug; // Enforce maximum retry limit to prevent excessive attempts diff --git a/packages/dart/test/src/network/parse_network_retry_test.dart b/packages/dart/test/src/network/parse_network_retry_test.dart index 06a11c949..f6306da0c 100644 --- a/packages/dart/test/src/network/parse_network_retry_test.dart +++ b/packages/dart/test/src/network/parse_network_retry_test.dart @@ -510,4 +510,131 @@ void main() { expect(result.data, contains('"error"')); }); }); + + group('Write Operations (POST/PUT) Retry Behavior', () { + test( + 'should not retry write operations by default (restRetryIntervalsForWrites is empty)', + () async { + int callCount = 0; + final result = await executeWithRetry( + isWriteOperation: true, + operation: () async { + callCount++; + return ParseNetworkResponse( + data: '{"code":-1,"error":"NetworkError"}', + statusCode: -1, + ); + }, + ); + + // Should only be called once (no retries) + expect(callCount, 1); + expect(result.statusCode, -1); + }, + ); + + test( + 'should use restRetryIntervalsForWrites when configured for write operations', + () async { + int callCount = 0; + final oldWriteIntervals = ParseCoreData().restRetryIntervalsForWrites; + ParseCoreData().restRetryIntervalsForWrites = [0, 10]; // 2 retries + + final result = await executeWithRetry( + isWriteOperation: true, + operation: () async { + callCount++; + return ParseNetworkResponse( + data: '{"code":-1,"error":"NetworkError"}', + statusCode: -1, + ); + }, + ); + + // Should be called: initial + 2 retries = 3 times + expect(callCount, 3); + expect(result.statusCode, -1); + + ParseCoreData().restRetryIntervalsForWrites = oldWriteIntervals; + }, + ); + + test( + 'should use restRetryIntervals for read operations (isWriteOperation=false)', + () async { + int callCount = 0; + final oldIntervals = ParseCoreData().restRetryIntervals; + ParseCoreData().restRetryIntervals = [0, 10]; // 2 retries + + final result = await executeWithRetry( + isWriteOperation: false, + operation: () async { + callCount++; + return ParseNetworkResponse( + data: '{"code":-1,"error":"NetworkError"}', + statusCode: -1, + ); + }, + ); + + // Should be called: initial + 2 retries = 3 times + expect(callCount, 3); + expect(result.statusCode, -1); + + ParseCoreData().restRetryIntervals = oldIntervals; + }, + ); + + test( + 'write operations succeed immediately on success without retries', + () async { + int callCount = 0; + final result = await executeWithRetry( + isWriteOperation: true, + operation: () async { + callCount++; + return ParseNetworkResponse( + data: '{"objectId":"abc123"}', + statusCode: 201, + ); + }, + ); + + expect(callCount, 1); + expect(result.statusCode, 201); + }, + ); + + test( + 'write operations can be configured with custom retry intervals', + () async { + int callCount = 0; + final oldWriteIntervals = ParseCoreData().restRetryIntervalsForWrites; + ParseCoreData().restRetryIntervalsForWrites = [5, 10, 15]; + + final result = await executeWithRetry( + isWriteOperation: true, + operation: () async { + callCount++; + if (callCount < 3) { + return ParseNetworkResponse( + data: '{"code":-1,"error":"NetworkError"}', + statusCode: -1, + ); + } + return ParseNetworkResponse( + data: '{"objectId":"abc123"}', + statusCode: 201, + ); + }, + ); + + // Should succeed on third attempt + expect(callCount, 3); + expect(result.statusCode, 201); + + ParseCoreData().restRetryIntervalsForWrites = oldWriteIntervals; + }, + ); + }); } From 8135d14a282242f63fca42c602e7a3eb1462abf7 Mon Sep 17 00:00:00 2001 From: Kirk Morrow <9563562+kirkmorrow@users.noreply.github.com> Date: Sat, 6 Dec 2025 18:48:02 -0600 Subject: [PATCH 07/10] Update packages/dart/lib/src/network/parse_dio_client.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/dart/lib/src/network/parse_dio_client.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/dart/lib/src/network/parse_dio_client.dart b/packages/dart/lib/src/network/parse_dio_client.dart index 1ee2f97ec..35fb86efb 100644 --- a/packages/dart/lib/src/network/parse_dio_client.dart +++ b/packages/dart/lib/src/network/parse_dio_client.dart @@ -215,7 +215,8 @@ class ParseDioClient extends ParseClient { String _buildErrorJson(String errorMessage) { final Map errorPayload = { 'code': ParseError.otherCause, - 'error': errorMessage, + 'error': 'NetworkError', + 'exception': errorMessage, }; return jsonEncode(errorPayload); } From 7e8623de77ff90a7dde680c4d258be848f82ff93 Mon Sep 17 00:00:00 2001 From: Kirk Morrow <9563562+kirkmorrow@users.noreply.github.com> Date: Sat, 6 Dec 2025 18:53:56 -0600 Subject: [PATCH 08/10] Add parameters to comments for initialize --- packages/dart/lib/parse_server_sdk.dart | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/dart/lib/parse_server_sdk.dart b/packages/dart/lib/parse_server_sdk.dart index 847f928e5..7842b1bfb 100644 --- a/packages/dart/lib/parse_server_sdk.dart +++ b/packages/dart/lib/parse_server_sdk.dart @@ -101,6 +101,16 @@ class Parse { /// debug: true, /// liveQuery: true); /// ``` + /// + /// Parameters: + /// + /// * [restRetryIntervals] - Optional list of retry delay intervals (in milliseconds) + /// for read operations (GET, DELETE). Defaults to [0, 250, 500, 1000, 2000]. + /// * [restRetryIntervalsForWrites] - Optional list of retry delay intervals for + /// write operations (POST, PUT). Defaults to [] (no retries) to prevent duplicate + /// data creation. Configure only if you have idempotency guarantees in place. + /// * [liveListRetryIntervals] - Optional list of retry delay intervals for + /// LiveQuery operations. Future initialize( String appId, String serverUrl, { From a7271bbba7bfb1629fc32677b02638140015f3bc Mon Sep 17 00:00:00 2001 From: Kirk Morrow <9563562+kirkmorrow@users.noreply.github.com> Date: Sat, 6 Dec 2025 19:03:44 -0600 Subject: [PATCH 09/10] Nitpick comment fix --- packages/dart/lib/parse_server_sdk.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/dart/lib/parse_server_sdk.dart b/packages/dart/lib/parse_server_sdk.dart index 7842b1bfb..d86cfcd6b 100644 --- a/packages/dart/lib/parse_server_sdk.dart +++ b/packages/dart/lib/parse_server_sdk.dart @@ -105,10 +105,12 @@ class Parse { /// Parameters: /// /// * [restRetryIntervals] - Optional list of retry delay intervals (in milliseconds) - /// for read operations (GET, DELETE). Defaults to [0, 250, 500, 1000, 2000]. + /// for read operations. Applies to: GET, DELETE, and getBytes methods. + /// Defaults to [0, 250, 500, 1000, 2000]. /// * [restRetryIntervalsForWrites] - Optional list of retry delay intervals for - /// write operations (POST, PUT). Defaults to [] (no retries) to prevent duplicate - /// data creation. Configure only if you have idempotency guarantees in place. + /// write operations. Applies to: POST, PUT, and postBytes methods. + /// Defaults to [] (no retries) to prevent duplicate data creation. + /// Configure only if you have idempotency guarantees in place. /// * [liveListRetryIntervals] - Optional list of retry delay intervals for /// LiveQuery operations. Future initialize( From 6b1ecb435908cd6829e36291fc1917178471f02d Mon Sep 17 00:00:00 2001 From: Kirk Morrow <9563562+kirkmorrow@users.noreply.github.com> Date: Sun, 7 Dec 2025 09:40:28 -0600 Subject: [PATCH 10/10] Update packages/dart/lib/src/network/parse_network_retry.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/dart/lib/src/network/parse_network_retry.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/dart/lib/src/network/parse_network_retry.dart b/packages/dart/lib/src/network/parse_network_retry.dart index f15f8f47a..772bd31f4 100644 --- a/packages/dart/lib/src/network/parse_network_retry.dart +++ b/packages/dart/lib/src/network/parse_network_retry.dart @@ -14,8 +14,8 @@ part of '../../parse_server_sdk.dart'; /// Retry Conditions: /// /// A request will be retried if: -/// - Status code is `-1` (indicates network/parsing error) -/// - Response body contains HTML markup (proxy/load balancer error) +/// - Status code is `-1` (indicates network/parsing error, including HTML responses from proxies/load balancers) +/// (HTML detection and conversion to status -1 happens in the HTTP client layer) /// - An exception is thrown during the request /// /// A request will NOT be retried for: