fix: retry ACP websocket after plain-text 404

This commit is contained in:
Haitao Pan 2026-04-08 18:28:16 +08:00
parent 173ccd83d7
commit cedf4c481e
2 changed files with 64 additions and 1 deletions

View File

@ -321,7 +321,22 @@ class GatewayAcpClient {
);
} catch (error) {
if (error is GatewayAcpException) {
rethrow;
if (!_shouldRetryViaWebSocketAfterHttpFailure(
error,
endpoint: resolvedEndpoint,
)) {
rethrow;
}
try {
return await _requestViaWebSocket(
request,
onNotification: onNotification,
endpointOverride: resolvedEndpoint,
authorizationOverride: authorizationOverride,
);
} catch (_) {
rethrow;
}
}
return _requestViaWebSocket(
request,
@ -544,6 +559,22 @@ class GatewayAcpClient {
return base;
}
bool _shouldRetryViaWebSocketAfterHttpFailure(
GatewayAcpException error, {
required Uri? endpoint,
}) {
if (resolveAcpWebSocketEndpoint(endpoint) == null) {
return false;
}
final details = asMap(error.details);
final statusCode = intValue(details['statusCode']);
final contentType = (stringValue(details['contentType']) ?? '')
.trim()
.toLowerCase();
return statusCode == HttpStatus.notFound &&
(contentType.isEmpty || contentType.contains('text/plain'));
}
bool _contentTypeLooksJsonOrSse(String contentType) {
return contentType.contains('application/json') ||
contentType.contains('application/problem+json') ||

View File

@ -117,6 +117,27 @@ void main() {
},
);
test(
'falls back to websocket when HTTP bridge returns plain-text 404',
() async {
final server = await _AcpFakeServer.start(
respondWithPlainTextNotFound: true,
);
addTearDown(server.close);
final client = GatewayAcpClient(
endpointResolver: () => server.baseHttpUri,
);
final capabilities = await client.loadCapabilities(forceRefresh: true);
expect(capabilities.singleAgent, isTrue);
expect(server.lastHttpRequestPath, '/acp/rpc');
expect(server.lastWebSocketRequestPath, '/acp');
expect(server.rpcMethods, contains('acp.capabilities'));
},
);
test(
'forwards ACP authorization resolver headers over websocket',
() async {
@ -234,12 +255,14 @@ class _AcpFakeServer {
this._server, {
required this.disableWebSocket,
required this.respondWithHtmlError,
required this.respondWithPlainTextNotFound,
required this.pathPrefix,
});
final HttpServer _server;
final bool disableWebSocket;
final bool respondWithHtmlError;
final bool respondWithPlainTextNotFound;
final String pathPrefix;
final List<String> rpcMethods = <String>[];
String? lastWebSocketAuthorization;
@ -253,6 +276,7 @@ class _AcpFakeServer {
static Future<_AcpFakeServer> start({
bool disableWebSocket = false,
bool respondWithHtmlError = false,
bool respondWithPlainTextNotFound = false,
String pathPrefix = '',
}) async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
@ -260,6 +284,7 @@ class _AcpFakeServer {
server,
disableWebSocket: disableWebSocket,
respondWithHtmlError: respondWithHtmlError,
respondWithPlainTextNotFound: respondWithPlainTextNotFound,
pathPrefix: _normalizePathPrefix(pathPrefix),
);
unawaited(fake._listen());
@ -333,6 +358,13 @@ class _AcpFakeServer {
await request.response.close();
return;
}
if (respondWithPlainTextNotFound) {
request.response.statusCode = HttpStatus.notFound;
request.response.headers.set(HttpHeaders.contentTypeHeader, 'text/plain');
request.response.write('not found');
await request.response.close();
return;
}
final body = await utf8.decodeStream(request);
final envelope = _decodeMap(body);
final id = envelope['id'];