From a0502e072ac9706ad854bb9b18d7eac586421bd3 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 4 Apr 2026 15:29:04 +0800 Subject: [PATCH] Handle ACP HTML fallback errors --- lib/runtime/gateway_acp_client.dart | 67 ++++++++++++++++++++++ test/runtime/gateway_acp_client_suite.dart | 50 +++++++++++++++- 2 files changed, 114 insertions(+), 3 deletions(-) diff --git a/lib/runtime/gateway_acp_client.dart b/lib/runtime/gateway_acp_client.dart index 4ffc89c0..ea778f99 100644 --- a/lib/runtime/gateway_acp_client.dart +++ b/lib/runtime/gateway_acp_client.dart @@ -465,6 +465,21 @@ class GatewayAcpClient { .value(HttpHeaders.contentTypeHeader) ?.toLowerCase() ?? ''; + if (response.statusCode < 200 || response.statusCode >= 300) { + final body = await response.transform(utf8.decoder).join(); + throw GatewayAcpException( + _describeHttpError( + statusCode: response.statusCode, + contentType: contentType, + body: body, + ), + code: 'ACP_HTTP_${response.statusCode}', + details: { + 'statusCode': response.statusCode, + 'contentType': contentType, + }, + ); + } if (contentType.contains('text/event-stream')) { return _consumeSseRpcResponse( response: response, @@ -481,6 +496,58 @@ class GatewayAcpClient { } } + String _describeHttpError({ + required int statusCode, + required String contentType, + required String body, + }) { + final base = 'ACP HTTP request failed ($statusCode)'; + final normalizedType = contentType.trim(); + if (normalizedType.isNotEmpty && + !_contentTypeLooksJsonOrSse(normalizedType)) { + return '$base · unexpected content type: $normalizedType'; + } + + final detail = _extractErrorDetail(body); + if (detail.isNotEmpty) { + return '$base · $detail'; + } + return base; + } + + bool _contentTypeLooksJsonOrSse(String contentType) { + return contentType.contains('application/json') || + contentType.contains('application/problem+json') || + contentType.contains('text/json') || + contentType.contains('text/event-stream'); + } + + String _extractErrorDetail(String body) { + final trimmed = body.trim(); + if (trimmed.isEmpty) { + return ''; + } + try { + final decoded = _decodeMap(trimmed); + final error = asMap(decoded['error']); + return (stringValue(error['message']) ?? + stringValue(decoded['message']) ?? + stringValue(decoded['detail']) ?? + '') + .trim(); + } on FormatException { + // Fall through to textual snippet extraction below. + } + + final singleLine = trimmed.replaceAll(RegExp(r'\s+'), ' '); + if (singleLine.isEmpty) { + return ''; + } + return singleLine.length <= 160 + ? singleLine + : '${singleLine.substring(0, 157)}...'; + } + Future _resolveAuthorizationHeader( Uri endpoint, { String authorizationOverride = '', diff --git a/test/runtime/gateway_acp_client_suite.dart b/test/runtime/gateway_acp_client_suite.dart index 29fdb81a..aba5c369 100644 --- a/test/runtime/gateway_acp_client_suite.dart +++ b/test/runtime/gateway_acp_client_suite.dart @@ -43,6 +43,29 @@ void main() { expect(server.rpcMethods, contains('acp.capabilities')); }); + test('surfaces HTTP content-type errors without raw JSON parse failures', () async { + final server = await _AcpFakeServer.start( + disableWebSocket: true, + respondWithHtmlError: true, + ); + addTearDown(server.close); + + final client = GatewayAcpClient( + endpointResolver: () => server.baseHttpUri, + ); + + await expectLater( + () => client.loadCapabilities(forceRefresh: true), + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('unexpected content type: text/html'), + ), + ), + ); + }); + test( 'forwards ACP authorization resolver headers over websocket', () async { @@ -128,19 +151,31 @@ void main() { } class _AcpFakeServer { - _AcpFakeServer._(this._server, {required this.disableWebSocket}); + _AcpFakeServer._( + this._server, { + required this.disableWebSocket, + required this.respondWithHtmlError, + }); final HttpServer _server; final bool disableWebSocket; + final bool respondWithHtmlError; final List rpcMethods = []; String? lastWebSocketAuthorization; String? lastHttpAuthorization; Uri get baseHttpUri => Uri.parse('http://127.0.0.1:${_server.port}'); - static Future<_AcpFakeServer> start({bool disableWebSocket = false}) async { + static Future<_AcpFakeServer> start({ + bool disableWebSocket = false, + bool respondWithHtmlError = false, + }) async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - final fake = _AcpFakeServer._(server, disableWebSocket: disableWebSocket); + final fake = _AcpFakeServer._( + server, + disableWebSocket: disableWebSocket, + respondWithHtmlError: respondWithHtmlError, + ); unawaited(fake._listen()); return fake; } @@ -200,6 +235,15 @@ class _AcpFakeServer { lastHttpAuthorization = request.headers.value( HttpHeaders.authorizationHeader, ); + if (respondWithHtmlError) { + request.response.statusCode = HttpStatus.notFound; + request.response.headers.set(HttpHeaders.contentTypeHeader, 'text/html'); + request.response.write( + '', + ); + await request.response.close(); + return; + } final body = await utf8.decodeStream(request); final envelope = _decodeMap(body); final id = envelope['id'];