feat(ai): 创意助手出图链路收口——去重试、图片模式包装提示词、输出形状兼容(issue 19)

- 删除 CREATIVE_ASSISTANT_MAX_RETRY / executeCreativeAssistantWithRetry /
  isRetryableCreativeFailure,失败即失败;日志仍带 failedNode 与上游 error。
- 图片模式(mode=image)把 user_content 包装成「【任务类型】生成图片…」意图声明,
  避免上游「问题分类器」把画面描述判成文案/脚本分支;不带 mode 的入口保持原话直送。
- outputs 形状兼容:image7 这类数组里的 markdown 图片按内容归一到 photo_files
  ({url,name,type}),findTextValue 支持 List 中的图片字符串;raw 保持上游原样。
- 测试:新增 AiCreativeNoRetryIntegrationTest(2)、AiCreativeImagePromptTest(4)、
  AiCreativeImageOutputTest(5),删除两个重试用例;全量 148 跑 0 failures。
This commit is contained in:
2026-10-21 15:42:54 +08:00
parent 42655ecd83
commit f4590001d8
5 changed files with 570 additions and 271 deletions
@@ -42,8 +42,10 @@ import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
@@ -76,17 +78,20 @@ public class AiController extends BaseController {
private static final String CREATIVE_BUSY_MESSAGE = "生成服务繁忙,请稍后重试"; private static final String CREATIVE_BUSY_MESSAGE = "生成服务繁忙,请稍后重试";
/** /**
* 创意助手工作流因上游瞬时故障失败后的自动重试次数。 * 图片模式的提示词包装。
* *
* <p>一次出图要连调两次「即梦AI-图像生成」,单次约 1/5 概率撞上上游容量不足, * <p>端上把「用户选了图片模式」这个事实以 {@code mode=image} 传上来,但上游开始节点
* 不重试时整体失败率约三成(2026-10-13 实测:同一 prompt 连跑 6 次,2 次失败)。 * 只有 {@code user_content} 一个文本变量;用户的原话常常只是画面描述(如
* 重试一次可把失败率压到约一成,代价是多等一次工作流(实测 45~105 秒), * 「毕业季,图书馆门口,暖色」),在「问题分类器」里很容易被判成文案或脚本分支,
* 距离 {@link #AI_REQUEST_TIMEOUT} 还有充足余量。 * 于是同一个需求有时出图、有时出一段文字。
* *
* <p>只对 {@link #isRetryableCreativeFailure(String)} 认定的瞬时故障重试; * <p>这里在用户原话前加一段**固定的意图声明**,把「这是生图任务」摆在分类器
* 配置类失败重试无用,重试反而让用户白等一倍时间。 * 最先看到的位置;用户原话原样保留,不替用户改写画面描述。
*/ */
private static final int CREATIVE_ASSISTANT_MAX_RETRY = 1; private static final String CREATIVE_IMAGE_PROMPT_TEMPLATE =
"【任务类型】生成图片\n"
+ "【用户需求】%s\n"
+ "【输出要求】直接生成图片,不要输出文案、脚本或视频。";
/** /**
* 写进日志的上游错误原文长度上限。 * 写进日志的上游错误原文长度上限。
@@ -110,6 +115,31 @@ public class AiController extends BaseController {
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s"); private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s");
/**
* markdown 图片语法:{@code ![alt](https://...)}。
*
* <p>出图工作流的产出就是这种字符串(实测 {@code image7} 里两个元素都是
* {@code ![](https://ark-acg-cn-beijing.tos-cn-beijing.volces.com/...jpeg?X-Tos-...)})。
*/
private static final Pattern MARKDOWN_IMAGE_PATTERN =
Pattern.compile("!\\[[^\\]]*\\]\\((https?://[^)\\s]+)\\)");
/**
* 裸图片 URL(按扩展名判定)。签名参数里的长串会误伤
* {@link #LONG_TOKEN_PATTERN},图片地址必须单独认。
*/
private static final Pattern IMAGE_URL_PATTERN = Pattern.compile(
"https?://[^\\s)]+\\.(?:png|jpe?g|gif|webp|bmp|svg)(?:\\?[^\\s)]*)?",
Pattern.CASE_INSENSITIVE);
/**
* 一组图片最多取几张。
*
* <p>上游换过的出图节点一次给 2 张;给数组的条数设上限,避免某个把整批素材
* 都塞进 outputs 的工作流把聊天气泡撑成图片墙。
*/
private static final int CREATIVE_IMAGE_LIST_MAX_ITEMS = 12;
/** /**
* loading 气泡文案的长度上限。 * loading 气泡文案的长度上限。
* *
@@ -474,7 +504,7 @@ public class AiController extends BaseController {
if (isWorkflowFailed(workflowStatus(data))) { if (isWorkflowFailed(workflowStatus(data))) {
// 用 error 而非 warn:生产日志级别是 com.gxwebsoft: ERROR, // 用 error 而非 warn:生产日志级别是 com.gxwebsoft: ERROR,
// warn 会被整个吞掉,就成了「端上说生成失败、后端日志里什么都没有」 // warn 会被整个吞掉,就成了「端上说生成失败、后端日志里什么都没有」
// (同 executeCreativeAssistantWithRetry 上方的注释)。 // (同 executeCreativeAssistant 上方的注释)。
logger.error("manuscript workflow failed, taskId={}, workflowRunId={}, {}", logger.error("manuscript workflow failed, taskId={}, workflowRunId={}, {}",
taskId, workflowRunId, failure.describe()); taskId, workflowRunId, failure.describe());
workflowFailed = true; workflowFailed = true;
@@ -1047,69 +1077,43 @@ public class AiController extends BaseController {
} }
} }
/**
* 创意助手执行:**只跑一次**,不自动重试。
*
* <p>历史上这里对上游瞬时故障({@code 50500 no more instances to retry} 等)
* 重跑过一次,用来把出图整体失败率从约三成压到约一成。但出图工作流一次要等
* 45~105 秒,重试会把用户的一次失败拖成两次等待,且失败后没有任何界面能告诉
* 用户"还在重试"。现在失败即失败:直接给可读文案,由用户自己决定要不要再来一次。
*
* <p>失败以 ERROR 记录失败节点与上游错误原文:生产日志级别是
* {@code com.gxwebsoft: ERROR},用 {@code logger.warn} 会被整个吞掉,
* 就成了"端上说生成失败、后端日志里却什么都没有"。
*
* @param requestBody 端上请求体({@code user_content} / {@code user_files} / {@code mode})
* @param loginUserId 用户 id
* @return 成功结果为工作流产出;失败时为失败态结果(HTTP 仍是 200,靠 status 表达)
*/
private ApiResult<?> executeCreativeAssistant(Map<String, Object> requestBody, Integer loginUserId) { private ApiResult<?> executeCreativeAssistant(Map<String, Object> requestBody, Integer loginUserId) {
String userContent = stringValue(requestBody == null ? null : requestBody.get("user_content")); String userContent = stringValue(requestBody == null ? null : requestBody.get("user_content"));
if (StrUtil.isBlank(userContent)) { if (StrUtil.isBlank(userContent)) {
return fail("user_content不能为空"); return fail("user_content不能为空");
} }
return executeCreativeAssistantWithRetry(buildCreativeAssistantInputs(requestBody), loginUserId); CreativeRun run;
} try {
run = runCreativeAssistantOnce(buildCreativeAssistantInputs(requestBody), loginUserId);
/** } catch (CreativeApiFailureException e) {
* 创意助手执行外壳:失败可重试时原样重跑一次工作流。 logger.error("creative assistant request rejected, message={}", e.getMessage());
* return e.result;
* <p>为什么需要它:出图分支要连调两次「即梦AI-图像生成」,而上游火山引擎会 } catch (Exception e) {
* 偶发 {@code 50500 no more instances to retry}(实例不足,3 秒内即拒绝), logger.error("creative assistant execute failed", e);
* 实测整体失败率约三成。这类失败与内容、参数、凭证都无关,重跑一次通常就成。 return fail("出错了,请晚点再试~");
*
* <p>每次失败都以 ERROR 记录失败节点与上游错误原文:生产日志级别是
* {@code com.gxwebsoft: ERROR},原先这里用 {@code logger.warn},于是出现
* "端上说生成失败、后端日志里却什么都没有"的情况。
*
* @param inputs 已构造好的工作流入参
* @param loginUserId 用户 id
* @return 成功结果为工作流产出;重试耗尽仍失败时为失败态结果(HTTP 仍是 200,靠 status 表达)
*/
private ApiResult<?> executeCreativeAssistantWithRetry(Map<String, Object> inputs, Integer loginUserId) {
int maxAttempts = CREATIVE_ASSISTANT_MAX_RETRY + 1;
Map<String, Object> failedResult = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
CreativeRun run;
try {
run = runCreativeAssistantOnce(inputs, loginUserId);
} catch (CreativeApiFailureException e) {
logger.error("creative assistant request rejected, attempt={}/{}, message={}",
attempt, maxAttempts, e.getMessage());
return e.result;
} catch (Exception e) {
logger.error("creative assistant execute failed, attempt={}/{}", attempt, maxAttempts, e);
if (attempt < maxAttempts) {
continue;
}
return fail("出错了,请晚点再试~");
}
if (!run.failed) {
if (attempt > 1) {
logger.warn("creative assistant recovered after retry, attempt={}/{}, workflowRunId={}, taskId={}",
attempt, maxAttempts, run.workflowRunId, run.taskId);
}
return success(run.result);
}
logger.error("creative assistant workflow failed, attempt={}/{}, workflowRunId={}, taskId={}, "
+ "failedNode={}, retryable={}, upstreamError={}",
attempt, maxAttempts, run.workflowRunId, run.taskId,
run.failedNode, run.retryable, run.failureReason);
failedResult = run.result;
if (!run.retryable || attempt >= maxAttempts) {
break;
}
logger.warn("creative assistant retrying after upstream failure, nextAttempt={}/{}, failedNode={}",
attempt + 1, maxAttempts, run.failedNode);
} }
return success(failedResult); if (run.failed) {
logger.error("creative assistant workflow failed, workflowRunId={}, taskId={}, "
+ "failedNode={}, upstreamError={}",
run.workflowRunId, run.taskId, run.failedNode, run.failureReason);
}
return success(run.result);
} }
/** /**
@@ -1132,7 +1136,7 @@ public class AiController extends BaseController {
/** /**
* 一次创意助手工作流的执行结论。 * 一次创意助手工作流的执行结论。
* *
* <p>{@code result} 是要下发端上的载荷;其余字段只用于记日志和决定是否重试, * <p>{@code result} 是要下发端上的载荷;其余字段只用于记日志,
* 绝不可混进 {@code result}——失败态一旦带出上游原文,就会泄露 * 绝不可混进 {@code result}——失败态一旦带出上游原文,就会泄露
* {@code workflow_id} / {@code req_id} 等内部标识符(见 issue 01)。 * {@code workflow_id} / {@code req_id} 等内部标识符(见 issue 01)。
*/ */
@@ -1143,32 +1147,29 @@ public class AiController extends BaseController {
private final String failedNode; private final String failedNode;
private final String failureReason; private final String failureReason;
private final boolean failed; private final boolean failed;
private final boolean retryable;
private CreativeRun(Map<String, Object> result, String workflowRunId, String taskId, private CreativeRun(Map<String, Object> result, String workflowRunId, String taskId,
String failedNode, String failureReason, boolean failed, boolean retryable) { String failedNode, String failureReason, boolean failed) {
this.result = result; this.result = result;
this.workflowRunId = workflowRunId; this.workflowRunId = workflowRunId;
this.taskId = taskId; this.taskId = taskId;
this.failedNode = failedNode; this.failedNode = failedNode;
this.failureReason = briefUpstreamError(failureReason); this.failureReason = briefUpstreamError(failureReason);
this.failed = failed; this.failed = failed;
this.retryable = retryable;
} }
private static CreativeRun succeeded(Map<String, Object> result, String workflowRunId, String taskId) { private static CreativeRun succeeded(Map<String, Object> result, String workflowRunId, String taskId) {
return new CreativeRun(result, workflowRunId, taskId, null, null, false, false); return new CreativeRun(result, workflowRunId, taskId, null, null, false);
} }
private static CreativeRun failed(Map<String, Object> result, String workflowRunId, String taskId, private static CreativeRun failed(Map<String, Object> result, String workflowRunId, String taskId,
String failedNode, String failureReason) { String failedNode, String failureReason) {
return new CreativeRun(result, workflowRunId, taskId, failedNode, failureReason, true, return new CreativeRun(result, workflowRunId, taskId, failedNode, failureReason, true);
isRetryableCreativeFailure(failureReason));
} }
} }
/** /**
* 跑一次创意助手工作流(单次尝试,不含重试)。 * 跑一次创意助手工作流(单次尝试,失败即返回)。
*/ */
private CreativeRun runCreativeAssistantOnce(Map<String, Object> inputs, Integer loginUserId) private CreativeRun runCreativeAssistantOnce(Map<String, Object> inputs, Integer loginUserId)
throws IOException { throws IOException {
@@ -1345,12 +1346,14 @@ public class AiController extends BaseController {
} }
/** /**
* 上游实例不足(重试有实际收益的那一类)。 * 上游实例不足。
* *
* <p>2026-10-13 内网实例实测:同一 prompt 连跑 6 次,2 次失败于 * <p>2026-10-13 内网实例实测:同一 prompt 连跑 6 次,2 次失败于
* 「即梦AI-图像生成」节点,原文为 * 「即梦AI-图像生成」节点,原文为
* {@code {"code":50500,...,"status_code":110001,"status_message":"no more instances to retry"}}, * {@code {"code":50500,...,"status_code":110001,"status_message":"no more instances to retry"}},
* 整节点仅耗时 3.1~3.5 秒即返回——即上游没有可用算力而立即拒绝,与内容无关。 * 整节点仅耗时 3.1~3.5 秒即返回——即上游没有可用算力而立即拒绝,与内容无关。
*
* <p>这只影响给用户的文案({@link #CREATIVE_BUSY_MESSAGE}),不再触发自动重试。
*/ */
private static boolean isUpstreamCapacityFailure(String reason) { private static boolean isUpstreamCapacityFailure(String reason) {
if (StrUtil.isBlank(reason)) { if (StrUtil.isBlank(reason)) {
@@ -1362,28 +1365,6 @@ public class AiController extends BaseController {
|| lower.contains("code: 50500"); || lower.contains("code: 50500");
} }
/**
* 这次失败值不值得原样重跑。
*
* <p>只认能明确归因到上游瞬时的故障。凭证失效({@code 50400 Access Denied})、
* 模型被停用({@code Model disabled})、内容审核拒绝重试无用,
* 重试只会把用户的一次失败拖成两次等待,必须排除。
*/
private static boolean isRetryableCreativeFailure(String reason) {
if (isUpstreamCapacityFailure(reason)) {
return true;
}
if (StrUtil.isBlank(reason)) {
return false;
}
String lower = reason.toLowerCase(Locale.ROOT);
return lower.contains("502 bad gateway")
|| lower.contains("503 service unavailable")
|| lower.contains("504 gateway")
|| lower.contains("connection reset")
|| lower.contains("timed out");
}
/** /**
* 把上游原文裁到可写进单行日志的长度。 * 把上游原文裁到可写进单行日志的长度。
*/ */
@@ -1757,7 +1738,12 @@ public class AiController extends BaseController {
} }
String userContent = stringValue(requestBody.get("user_content")); String userContent = stringValue(requestBody.get("user_content"));
if (StrUtil.isNotBlank(userContent)) { if (StrUtil.isNotBlank(userContent)) {
inputs.put("user_content", userContent); // mode=image 由端上在选择「图片」模式时带上;单页表单入口不带 mode,
// 保持原话直送(那类页面由用户自己写清指令)。
boolean imageMode = "image".equalsIgnoreCase(stringValue(requestBody.get("mode")));
inputs.put("user_content", imageMode
? String.format(CREATIVE_IMAGE_PROMPT_TEMPLATE, userContent)
: userContent);
} }
Object rawUserFiles = requestBody.get("user_files"); Object rawUserFiles = requestBody.get("user_files");
if (hasFileValue(rawUserFiles)) { if (hasFileValue(rawUserFiles)) {
@@ -2094,10 +2080,22 @@ public class AiController extends BaseController {
return result; return result;
} }
Object data = responseData.get("data");
Object outputs = data instanceof Map ? ((Map) data).get("outputs") : null;
if (outputs == null) {
outputs = responseData.get("outputs");
}
Map<String, Object> normalizedOutputs = outputs instanceof Map
? normalizeCreativeOutputs((Map) outputs)
: null;
String answer = extractWorkflowOutputs(responseData); String answer = extractWorkflowOutputs(responseData);
if (answer == null) { if (answer == null) {
answer = extractAnswer(responseData); answer = extractAnswer(responseData);
} }
if (answer == null && normalizedOutputs != null) {
answer = imageMarkdownFromOutputs(normalizedOutputs);
}
if (answer != null) { if (answer != null) {
result.put("answer", answer); result.put("answer", answer);
} }
@@ -2112,26 +2110,210 @@ public class AiController extends BaseController {
result.put("taskId", taskId); result.put("taskId", taskId);
} }
Object data = responseData.get("data");
if (data instanceof Map) { if (data instanceof Map) {
// status 已在方法开头经 workflowStatus 统一取出, // status 已在方法开头经 workflowStatus 统一取出,
// 这里只负责下发(含顶层 status 的兜底情况)。 // 这里只负责下发(含顶层 status 的兜底情况)。
if (status != null) { if (status != null) {
result.put("status", status); result.put("status", status);
} }
Object outputs = ((Map) data).get("outputs");
if (outputs != null) {
result.put("outputs", outputs);
}
} }
if (!result.containsKey("outputs") && responseData.get("outputs") != null) { if (normalizedOutputs != null) {
result.put("outputs", responseData.get("outputs")); result.put("outputs", normalizedOutputs);
} }
result.put("raw", responseData); result.put("raw", responseData);
return result; return result;
} }
/**
* 兼容上游 outputs 的形状漂移(不改上游报文,只做向上归一)。
*
* <p>2026-10-21 在 dev 实例实测:出图分支的终态是
* {@code {"image7": ["![](https://.../a.jpeg?...)", "![](https://.../b.jpeg?...)"]}}。
* 变量名({@code image7})和值的类型(数组)都不在端上白名单里,
* 于是四个页面全部显示「接口已返回,但未解析到可展示的内容」。
* 10-13 同一分支的产物还叫 {@code photo_message} / {@code photo_file},
* 节点叫「即梦AI-图像生成」;上游换过一轮节点,两端白名单就同时失效了。
*
* <p>与其把 {@code image7} 写进白名单(下次改名叫 {@code image8} 又要再来一遍),
* 不如按**内容**识别:任何 outputs 变量里出现的图片(markdown 图片语法或图片 URL,
* 字符串或数组)都收进规范的 {@code photo_files},元素统一成 {@code {url,name,type}}。
* 端上四个页面认的正是 {@code photo_files},因此不必逐个页面加字段。
*
* @param outputs 上游 outputs(不改动原对象,返回值是新 Map)
* @return 归一后的 outputs
*/
private Map<String, Object> normalizeCreativeOutputs(Map<String, Object> outputs) {
Map<String, Object> normalized = new LinkedHashMap<>(outputs);
List<Object> photoFiles = new ArrayList<>();
Set<String> knownUrls = new LinkedHashSet<>();
collectPhotoFiles(outputs.get("photo_files"), photoFiles, knownUrls);
collectPhotoFiles(outputs.get("photo_file"), photoFiles, knownUrls);
// 其余变量按内容兜底:老字段(photo_*)已收过的 URL 不重复收。
for (Map.Entry<String, Object> entry : outputs.entrySet()) {
String key = entry.getKey();
if ("photo_files".equals(key) || "photo_file".equals(key)) {
continue;
}
for (String url : extractImageUrls(entry.getValue())) {
if (knownUrls.add(url)) {
photoFiles.add(imageFileEntry(url));
}
}
}
if (!photoFiles.isEmpty()) {
normalized.put("photo_files", photoFiles);
}
return normalized;
}
/**
* 把 {@code photo_files} / {@code photo_file} 收进统一列表。
*
* <p>上游两种写法都出现过:元素是文件对象({@code {url,name,...}}),
* 或直接是 markdown 图片 / 图片 URL 字符串。字符串统一转成文件对象,
* 端上的 {@code getCreativeFileUrl(item)} 才取得到地址。
*/
private void collectPhotoFiles(Object raw, List<Object> target, Set<String> knownUrls) {
if (raw == null) {
return;
}
List<?> items = raw instanceof List ? (List<?>) raw : Collections.singletonList(raw);
for (Object item : items) {
if (item instanceof Map) {
Map<?, ?> file = (Map<?, ?>) item;
String url = creativeFileUrl(file);
if (StrUtil.isBlank(url)) {
continue;
}
target.add(file);
knownUrls.add(url);
continue;
}
for (String url : extractImageUrls(item)) {
if (knownUrls.add(url)) {
target.add(imageFileEntry(url));
}
}
}
}
/**
* 从一个任意形状的 outputs 值里抽出图片地址(保持出现顺序、允许重复)。
*/
private List<String> extractImageUrls(Object value) {
List<String> urls = new ArrayList<>();
collectImageUrls(value, urls);
return urls;
}
private void collectImageUrls(Object value, List<String> urls) {
if (value == null || urls.size() >= CREATIVE_IMAGE_LIST_MAX_ITEMS) {
return;
}
if (value instanceof String) {
String text = ((String) value).trim();
if (text.isEmpty()) {
return;
}
Matcher markdown = MARKDOWN_IMAGE_PATTERN.matcher(text);
boolean found = false;
while (markdown.find() && urls.size() < CREATIVE_IMAGE_LIST_MAX_ITEMS) {
urls.add(markdown.group(1));
found = true;
}
if (!found && IMAGE_URL_PATTERN.matcher(text).matches()) {
urls.add(text);
}
return;
}
if (value instanceof List) {
for (Object item : (List) value) {
collectImageUrls(item, urls);
if (urls.size() >= CREATIVE_IMAGE_LIST_MAX_ITEMS) {
return;
}
}
return;
}
if (value instanceof Map) {
// 嵌套结构(如 files/json)里只认能按扩展名判定为图片的字符串,避免把
// 文档/视频地址误当图片;键名不做猜测。
for (Object item : ((Map) value).values()) {
if (item instanceof String) {
String text = ((String) item).trim();
if (IMAGE_URL_PATTERN.matcher(text).matches()) {
urls.add(text);
}
} else if (item instanceof List) {
collectImageUrls(item, urls);
}
}
}
}
/**
* 取文件对象的可用地址,与端上的 {@code getCreativeFileUrl} 同一口径。
*/
private String creativeFileUrl(Map<?, ?> file) {
if (file == null) {
return "";
}
String[] keys = new String[]{"url", "remote_url", "downloadUrl", "download_url", "path"};
for (String key : keys) {
String value = stringValue(file.get(key));
if (StrUtil.isNotBlank(value)) {
return value.trim();
}
}
return "";
}
private Map<String, Object> imageFileEntry(String url) {
Map<String, Object> file = new LinkedHashMap<>();
file.put("url", url);
file.put("name", fileNameFromUrl(url));
file.put("type", "image");
return file;
}
private String fileNameFromUrl(String url) {
String path = url == null ? "" : url.trim();
int query = path.indexOf('?');
if (query >= 0) {
path = path.substring(0, query);
}
int slash = path.lastIndexOf('/');
String name = slash >= 0 ? path.substring(slash + 1) : path;
return StrUtil.isBlank(name) ? "生成图片" : name;
}
/**
* 归一后的 {@code photo_files} 转成 markdown 图片段落,作为 answer 的最后兜底。
*
* <p>{@code formatCreativeResult} 虽然会自己渲染图片,但历史会话/其他调用方
* 可能只看 {@code answer};这里保证"有图就一定有一段能渲染的正文"。
*/
private String imageMarkdownFromOutputs(Map<String, Object> outputs) {
Object raw = outputs.get("photo_files");
if (!(raw instanceof List)) {
return null;
}
List<String> lines = new ArrayList<>();
for (Object item : (List) raw) {
String url = item instanceof Map ? creativeFileUrl((Map<?, ?>) item) : "";
if (StrUtil.isNotBlank(url)) {
lines.add("![](" + url + ")");
}
if (lines.size() >= CREATIVE_IMAGE_LIST_MAX_ITEMS) {
break;
}
}
return lines.isEmpty() ? null : String.join("\n\n", lines);
}
/** /**
* 取 Dify 工作流事件里的执行状态。 * 取 Dify 工作流事件里的执行状态。
* *
@@ -2530,8 +2712,9 @@ public class AiController extends BaseController {
return preferred; return preferred;
} }
for (Object value : source.values()) { for (Object value : source.values()) {
if (isDisplayableText(value)) { String text = findDisplayableValueText(value);
return (String) value; if (text != null) {
return text;
} }
if (value instanceof Map) { if (value instanceof Map) {
String nestedValue = findTextValue((Map) value); String nestedValue = findTextValue((Map) value);
@@ -2540,11 +2723,65 @@ public class AiController extends BaseController {
} }
} }
} }
// 关键:这里的「任意 String 值」兜底**必须**过 isDisplayableText。 // 关键:这里的「任意 String 值」兜底**必须**过可展示判定。
// 修复前它无条件返回第一个 String,正是把 data.id(UUID)当正文的放大器(见 issue 06)。 // 修复前它无条件返回第一个 String,正是把 data.id(UUID)当正文的放大器(见 issue 06)。
return null; return null;
} }
/**
* 单个 outputs 值的取文。
*
* <p>除了 String(过可展示判定),还要认 **List**:上游出图节点把一组图片
* 直接放进一个数组(实测 {@code image7: ["![](...)", "![](...)"]}),
* 原来的实现只认 String / Map,List 被静默跳过,出图结果就整段丢了。
*
* <p>List 里**只取字符串元素**,不递归进 Map:{@code video_result} /
* {@code photo_files} 这类列表装的是文件对象,递归进去会把文件 URL 当正文,
* 挤掉真正的脚本/文案字段。
*/
private String findDisplayableValueText(Object value) {
if (isDisplayableText(value)) {
return (String) value;
}
if (value instanceof List) {
List<String> texts = new ArrayList<>();
for (Object item : (List) value) {
if (texts.size() >= CREATIVE_IMAGE_LIST_MAX_ITEMS) {
break;
}
if (isDisplayableImageText(item)) {
texts.add(((String) item).trim());
}
}
if (!texts.isEmpty()) {
return String.join("\n\n", texts);
}
}
return null;
}
/**
* 数组元素里的图片字符串也属于可展示内容。
*
* <p>markdown 图片({@code ![](https://...)})没有空格,且长签名 URL 会命中
* {@link #LONG_TOKEN_PATTERN} 的「无空格长 token」拦截;但它是用户要看的产物本身,
* 必须放行,否则「列表支持」等于白做。
*/
private boolean isDisplayableImageText(Object value) {
if (!(value instanceof String)) {
return false;
}
String text = ((String) value).trim();
if (text.isEmpty()) {
return false;
}
if (isDisplayableText(text)) {
return true;
}
return MARKDOWN_IMAGE_PATTERN.matcher(text).find()
|| IMAGE_URL_PATTERN.matcher(text).matches();
}
private String stringValue(Object value) { private String stringValue(Object value) {
return value == null ? null : value.toString(); return value == null ? null : value.toString();
} }
@@ -0,0 +1,130 @@
package com.gxwebsoft.ai.controller;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 出图 outputs 的形状兼容(见 .scratch/ai-assistant/issues/19)。
*
* <p>2026-10-21 在 dev 实例实测到的真实终态是
* {@code {"image7": ["![](https://.../a.jpeg?...)", "![](https://.../b.jpeg?...)"]}}:
* 变量名和类型都不在端上白名单里,四个页面一律显示「接口已返回,但未解析到可展示的内容」。
* 修复思路是**按内容识别图片**并归一到 {@code photo_files},而不是把 {@code image7}
* 写进白名单——上游把节点换一次名,白名单就会再失效一次。
*/
class AiCreativeImageOutputTest {
private static final String IMAGE_A =
"https://ark-acg-cn-beijing.tos-cn-beijing.volces.com/doubao-seedream-5-0-pro/2026/a.jpeg"
+ "?X-Tos-Algorithm=TOS4-HMAC-SHA256&X-Tos-Signature=0123456789abcdef0123456789abcdef";
private static final String IMAGE_B =
"https://ark-acg-cn-beijing.tos-cn-beijing.volces.com/doubao-seedream-5-0-pro/2026/b.jpeg"
+ "?X-Tos-Algorithm=TOS4-HMAC-SHA256&X-Tos-Signature=fedcba9876543210fedcba9876543210";
private static Map<String, Object> buildResult(JSONObject outputs) throws Exception {
JSONObject data = new JSONObject(true);
data.put("status", "succeeded");
data.put("outputs", outputs);
JSONObject responseData = new JSONObject(true);
responseData.put("event", "workflow_finished");
responseData.put("workflow_run_id", "b2e18332-68bd-49e6-90e2-bd9d16d428ff");
responseData.put("task_id", "0b1fdd88-cc6f-45b8-989d-be4f85bcff74");
responseData.put("data", data);
Method method = AiController.class.getDeclaredMethod(
"buildCreativeAssistantResult", Map.class, String.class);
method.setAccessible(true);
//noinspection unchecked
return (Map<String, Object>) method.invoke(new AiController(), responseData, "");
}
private static JSONObject imageArrayOutputs() {
JSONArray images = new JSONArray();
images.add("![](" + IMAGE_A + ")");
images.add("![](" + IMAGE_B + ")");
JSONObject outputs = new JSONObject(true);
outputs.put("image7", images);
return outputs;
}
@Test
void image7数组_归一到photo_files并出可渲染正文() throws Exception {
Map<String, Object> result = buildResult(imageArrayOutputs());
assertEquals("succeeded", result.get("status"));
Map<?, ?> outputs = (Map<?, ?>) result.get("outputs");
assertNotNull(outputs, "成功态必须下发 outputs");
List<?> photoFiles = (List<?>) outputs.get("photo_files");
assertNotNull(photoFiles, "图片必须归一到 photo_files,端上四个页面才认得:" + outputs);
assertEquals(2, photoFiles.size());
Map<?, ?> first = (Map<?, ?>) photoFiles.get(0);
assertEquals(IMAGE_A, first.get("url"));
assertEquals("a.jpeg", first.get("name"));
assertEquals("image", first.get("type"));
String answer = String.valueOf(result.get("answer"));
assertTrue(answer.contains("![](" + IMAGE_A + ")"), "answer 也要能直接渲染:" + answer);
assertTrue(answer.contains("![](" + IMAGE_B + ")"), "两张图都要在:" + answer);
}
@Test
void 归一只影响下发载荷_不动上游raw() throws Exception {
Map<String, Object> result = buildResult(imageArrayOutputs());
Map<?, ?> raw = (Map<?, ?>) result.get("raw");
Map<?, ?> rawOutputs = (Map<?, ?>) ((Map<?, ?>) raw.get("data")).get("outputs");
assertTrue(rawOutputs.containsKey("image7"), "raw 必须保持上游原样");
assertFalse(rawOutputs.containsKey("photo_files"),
"补出来的规范字段不能写回 raw,否则日志里的原始报文会被改写:" + rawOutputs);
}
@Test
void 老字段photo_file字符串_同样归一() throws Exception {
JSONObject outputs = new JSONObject(true);
outputs.put("photo_file", "![](" + IMAGE_A + ")");
List<?> photoFiles = (List<?>) ((Map<?, ?>) buildResult(outputs).get("outputs")).get("photo_files");
assertNotNull(photoFiles, "10-13 的老字段也要能收进规范字段:" + outputs);
assertEquals(1, photoFiles.size());
assertEquals(IMAGE_A, ((Map<?, ?>) photoFiles.get(0)).get("url"));
}
@Test
void photo_files里的裸URL字符串_转成文件对象() throws Exception {
JSONArray files = new JSONArray();
files.add(IMAGE_A);
files.add(IMAGE_B);
JSONObject outputs = new JSONObject(true);
outputs.put("photo_files", files);
List<?> photoFiles = (List<?>) ((Map<?, ?>) buildResult(outputs).get("outputs")).get("photo_files");
assertEquals(2, photoFiles.size(), "字符串元素要转成 {url,name,type}:" + photoFiles);
assertEquals(IMAGE_A, ((Map<?, ?>) photoFiles.get(0)).get("url"));
}
@Test
void 图片与脚本并存_不把文件地址当脚本() throws Exception {
JSONObject outputs = new JSONObject(true);
outputs.put("Script_result", "### 脚本\n第一幕:开场舞");
outputs.put("image7", new JSONArray());
Map<String, Object> result = buildResult(outputs);
assertEquals("### 脚本\n第一幕:开场舞", result.get("answer"));
assertFalse(((Map<?, ?>) result.get("outputs")).containsKey("photo_files"),
"没有图片时不应补出空的 photo_files");
}
}
@@ -0,0 +1,74 @@
package com.gxwebsoft.ai.controller;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 图片模式的提示词包装(见 .scratch/ai-assistant/issues/19)。
*
* <p>背景:上游创意助手工作流先经「问题分类器」分流。用户只写画面描述
* (「毕业季,图书馆门口,暖色」)时,分类器可能判成文案或脚本分支,
* 于是同一个需求有时出图、有时出一段文字。端上选了图片模式就已经知道意图,
* 后端把这份意图明确写进 {@code user_content},让分类器不必猜。
*/
class AiCreativeImagePromptTest {
private static Map<String, Object> buildInputs(Map<String, Object> requestBody) throws Exception {
Method method = AiController.class.getDeclaredMethod(
"buildCreativeAssistantInputs", Map.class);
method.setAccessible(true);
//noinspection unchecked
return (Map<String, Object>) method.invoke(new AiController(), requestBody);
}
private static Map<String, Object> imageRequest(String userContent) {
Map<String, Object> body = new HashMap<>();
body.put("user_content", userContent);
body.put("mode", "image");
return body;
}
@Test
void 图片模式_用户原话前带出图意图() throws Exception {
String query = "毕业季,图书馆门口,暖色";
String content = String.valueOf(buildInputs(imageRequest(query)).get("user_content"));
assertTrue(content.startsWith("【任务类型】生成图片"),
"出图意图必须放在最前面,分类器才会先看到它:" + content);
assertTrue(content.contains(query), "用户原话必须原样保留:" + content);
assertTrue(content.contains("不要输出文案、脚本或视频"),
"要明确排除另外几个分支,否则分类器仍可能走文案分支:" + content);
}
@Test
void 图片模式_只发user_content不带多余变量() throws Exception {
Map<String, Object> inputs = buildInputs(imageRequest("校园夜景海报"));
assertEquals(1, inputs.size(), "参考素材已下线,不该再往上游送 user_files:" + inputs);
assertFalse(inputs.containsKey("user_files"));
}
@Test
void 文字模式_原样透传不包装() throws Exception {
Map<String, Object> body = new HashMap<>();
body.put("user_content", "写一篇迎新晚会推文");
body.put("mode", "writing");
assertEquals("写一篇迎新晚会推文", buildInputs(body).get("user_content"));
}
@Test
void 未传模式_原样透传不包装() throws Exception {
Map<String, Object> body = new HashMap<>();
body.put("user_content", "海报排版");
assertEquals("海报排版", buildInputs(body).get("user_content"));
}
}
@@ -16,18 +16,19 @@ import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertFalse;
/** /**
* 创意助手自动重试的端到端验证(见 .scratch/ai-assistant/issues/11)。 * 创意助手**不自动重试**的端到端验证(见 .scratch/ai-assistant/issues/19)。
* *
* <p>真起一个本地 HTTP server 扮演 Dify:第一次返回上游容量不足的失败流, * <p>真起一个本地 HTTP server 扮演 Dify:返回的流里带上游容量不足的失败,
* 第二次返回成功流,然后确认 controller **真的重跑了工作流**并把第二次的产出回给端上。 * 然后确认 controller 只请求了一次、且把失败文案直接回给端上。
* *
* <p>单元测试只能证明"判定函数返回 true";这条用例证明的是"重试确实发生了、且结果被采用", * <p>历史背景:这里曾经是「重试一次」的用例(issue 11)。重试确实能把失败率
* 也就是用户看到的"有时候能出图、有时候失败"是否真的被修掉。 * 从约三成压到约一成,但出图一次要等 45~105 秒,用户看不到"正在重试",
* 体验上就是把一次失败拖成两次等待。现在改为失败即失败。
*/ */
class AiCreativeRetryIntegrationTest { class AiCreativeNoRetryIntegrationTest {
/** /**
* 上游容量不足的原文(2026-10-13 内网实测,request_id 为真实值)。 * 上游容量不足的原文(2026-10-13 内网实测,request_id 为真实值)。
@@ -40,37 +41,37 @@ class AiCreativeRetryIntegrationTest {
+ "\"request_id\":\"202610131604021580A5E0F3C983DD3D63\",\"status\":50500}'"; + "\"request_id\":\"202610131604021580A5E0F3C983DD3D63\",\"status\":50500}'";
/** /**
* 凭证失效:重试无用,必须只跑一次。 * 凭证失效:同样只跑一次,文案仍是统一的失败提示。
*/ */
private static final String ACCESS_DENIED_ERROR = private static final String ACCESS_DENIED_ERROR =
"req_id: dc688258de PluginInvokeError: API request failed with status code 50400," "req_id: dc688258de PluginInvokeError: API request failed with status code 50400,"
+ " message: Access Denied"; + " message: Access Denied";
@Test @Test
void 上游容量不足_自动重跑一次并把第二次产出回给端上() throws Exception { void 上游容量不足_不重跑且直接回可读文案() throws Exception {
AtomicInteger hits = new AtomicInteger(); AtomicInteger hits = new AtomicInteger();
try (MockDify dify = MockDify.start(exchange -> { try (MockDify dify = MockDify.start(exchange -> {
int attempt = hits.incrementAndGet(); hits.incrementAndGet();
String stream = attempt == 1 ? capacityFailureStream() : succeededStream(); MockDify.reply(exchange, capacityFailureStream());
MockDify.reply(exchange, stream);
})) { })) {
ApiResult<?> result = executeCreative(dify.baseUrl()); ApiResult<?> result = executeCreative(dify.baseUrl());
assertEquals(2, hits.get(), "第一次撞上上游容量不足后应自动重跑一次"); assertEquals(1, hits.get(), "不再自动重试,工作流只应被请求一次");
assertEquals(0, result.getCode(), "重试成功后对端仍是正常返回"); assertEquals(0, result.getCode(), "失败态仍以 HTTP 200 + status 表达");
Map<?, ?> payload = (Map<?, ?>) result.getData(); Map<?, ?> payload = (Map<?, ?>) result.getData();
assertEquals("succeeded", payload.get("status"), "第二次的状态应透出"); assertEquals("failed", payload.get("status"));
assertEquals("迎新晚会海报已生成", payload.get("answer"), "第二次的产出应回给端上"); assertEquals("生成服务繁忙,请稍后重试", payload.get("answer"));
assertFalse(payload.containsKey("raw"), "失败态不得下发整包");
} }
} }
@Test @Test
void 凭证失效_不重试且只跑一次() throws Exception { void 凭证失效_只跑一次且文案不变() throws Exception {
AtomicInteger hits = new AtomicInteger(); AtomicInteger hits = new AtomicInteger();
try (MockDify dify = MockDify.start(exchange -> { try (MockDify dify = MockDify.start(exchange -> {
hits.incrementAndGet(); hits.incrementAndGet();
MockDify.reply(exchange, failureStream(ACCESS_DENIED_ERROR)); MockDify.reply(exchange, failureBody(ACCESS_DENIED_ERROR));
})) { })) {
ApiResult<?> result = executeCreative(dify.baseUrl()); ApiResult<?> result = executeCreative(dify.baseUrl());
@@ -78,7 +79,7 @@ class AiCreativeRetryIntegrationTest {
Map<?, ?> payload = (Map<?, ?>) result.getData(); Map<?, ?> payload = (Map<?, ?>) result.getData();
assertEquals("failed", payload.get("status")); assertEquals("failed", payload.get("status"));
assertEquals("生成失败,请稍后重试", payload.get("answer")); assertEquals("生成失败,请稍后重试", payload.get("answer"));
assertTrue(!payload.containsKey("raw"), "失败态仍不得下发整包"); assertFalse(payload.containsKey("raw"), "失败态仍不得下发整包");
} }
} }
@@ -96,6 +97,7 @@ class AiCreativeRetryIntegrationTest {
Map<String, Object> requestBody = new HashMap<>(); Map<String, Object> requestBody = new HashMap<>();
requestBody.put("user_content", "生成一张迎新晚会海报"); requestBody.put("user_content", "生成一张迎新晚会海报");
requestBody.put("mode", "image");
Method method = AiController.class.getDeclaredMethod( Method method = AiController.class.getDeclaredMethod(
"executeCreativeAssistant", Map.class, Integer.class); "executeCreativeAssistant", Map.class, Integer.class);
@@ -111,10 +113,6 @@ class AiCreativeRetryIntegrationTest {
+ failureBody(CAPACITY_ERROR); + failureBody(CAPACITY_ERROR);
} }
private static String failureStream(String error) {
return failureBody(error);
}
private static String failureBody(String error) { private static String failureBody(String error) {
JSONObject data = new JSONObject(true); JSONObject data = new JSONObject(true);
data.put("status", "failed"); data.put("status", "failed");
@@ -129,22 +127,6 @@ class AiCreativeRetryIntegrationTest {
return sse(finished); return sse(finished);
} }
private static String succeededStream() {
JSONObject outputs = new JSONObject(true);
outputs.put("photo_message", "迎新晚会海报已生成");
JSONObject data = new JSONObject(true);
data.put("status", "succeeded");
data.put("outputs", outputs);
JSONObject finished = new JSONObject(true);
finished.put("event", "workflow_finished");
finished.put("workflow_run_id", "run-2");
finished.put("task_id", "task-2");
finished.put("data", data);
return node("即梦AI-图像生成", "succeeded", null) + sse(finished);
}
private static String node(String title, String status, String error) { private static String node(String title, String status, String error) {
JSONObject data = new JSONObject(true); JSONObject data = new JSONObject(true);
data.put("title", title); data.put("title", title);
@@ -1,124 +0,0 @@
package com.gxwebsoft.ai.controller;
import com.alibaba.fastjson.JSONObject;
import com.gxwebsoft.common.core.utils.JSONUtil;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 上游瞬时故障的重试判定与失败文案(见 .scratch/ai-assistant/issues/11)。
*
* <p>背景:内网实例上「即梦AI-图像生成」偶发
* {@code {"code":50500,...,"status_code":110001,"status_message":"no more instances to retry"}},
* 2026-10-13 同一 prompt 连跑 6 次有 2 次失败,整节点 3 秒即返回——是上游算力不足,
* 与内容、参数、凭证都无关,重试一次通常就成。
*
* <p>而凭证失效、模型停用、审核拒绝重试无用,必须排除,
* 否则会把用户的一次失败拖成两次等待。
*/
class AiCreativeRetryTest {
/**
* 2026-10-13 内网实测的上游原文(保留关键结构,request_id 为真实值)。
*/
private static final String CAPACITY_ERROR =
"An error occurred in the shaoruidong/volcengine_ai/volcengine_ai, please contact the"
+ " author of shaoruidong/volcengine_ai/volcengine_ai for help, error type:"
+ " ToolProviderCredentialValidationError, error details: b'{\"code\":50500,"
+ "\"data\":{\"algorithm_base_resp\":{\"status_code\":110001,\"status_message\":"
+ "\"no more instances to retry: last error: <nil>\"},\"binary_data_base64\":[]},"
+ "\"message\":\"Internal Error\","
+ "\"request_id\":\"202610131604021580A5E0F3C983DD3D63\",\"status\":50500}'";
private static final String ACCESS_DENIED_ERROR =
"req_id: dc688258de PluginInvokeError: [models] Error: API request failed with status"
+ " code 50400, message: Access Denied";
private static final String MODEL_DISABLED_ERROR =
"req_id: dc688258de PluginInvokeError: Model disabled.";
private static JSONObject failedEvent(String error) {
JSONObject data = new JSONObject(true);
data.put("status", "failed");
data.put("outputs", new JSONObject(true));
data.put("error", error);
JSONObject event = new JSONObject(true);
event.put("event", "workflow_finished");
event.put("workflow_run_id", "b2e18332-68bd-49e6-90e2-bd9d16d428ff");
event.put("task_id", "0b1fdd88-cc6f-45b8-989d-be4f85bcff74");
event.put("data", data);
return event;
}
private static Map<String, Object> buildFailedResult(String error) throws Exception {
// 直接构造 JSONObject 作为报文体,别绕 JSONUtil.toJSONString——它按值序列化,
// 对字符串不加引号,拼出来的报文非法,parseObject 会静默返回 null。
JSONObject responseData = failedEvent(error);
Method method = AiController.class.getDeclaredMethod(
"buildCreativeAssistantResult", Map.class, String.class);
method.setAccessible(true);
//noinspection unchecked
return (Map<String, Object>) method.invoke(new AiController(), responseData, "");
}
private static boolean retryable(String reason) throws Exception {
Method method = AiController.class.getDeclaredMethod("isRetryableCreativeFailure", String.class);
method.setAccessible(true);
return (Boolean) method.invoke(null, reason);
}
@Test
void 上游容量不足_值得重试() throws Exception {
assertTrue(retryable(CAPACITY_ERROR), "no more instances to retry 属于瞬时故障,应重试");
}
@Test
void 上游容量不足_文案换成服务繁忙() throws Exception {
Map<String, Object> result = buildFailedResult(CAPACITY_ERROR);
assertEquals("failed", result.get("status"), "状态仍须可被判为失败");
assertEquals("生成服务繁忙,请稍后重试", result.get("answer"));
}
@Test
void 上游容量不足_失败态仍不得下发上游原文() throws Exception {
String json = JSONUtil.toJSONString(buildFailedResult(CAPACITY_ERROR));
assertFalse(json.contains("50500"), "不得下发上游错误码:" + json);
assertFalse(json.contains("no more instances"), "不得下发上游错误原文:" + json);
assertFalse(json.contains("request_id"), "不得下发 req_id:" + json);
assertFalse(json.contains("volcengine"), "不得下发上游插件名:" + json);
assertFalse(json.contains("raw"), "失败态不得带 raw:" + json);
}
@Test
void 凭证失效_不重试且文案不变() throws Exception {
assertFalse(retryable(ACCESS_DENIED_ERROR), "凭证问题重试无用");
assertEquals("生成失败,请稍后重试", buildFailedResult(ACCESS_DENIED_ERROR).get("answer"));
}
@Test
void 模型停用_不重试且文案不变() throws Exception {
assertFalse(retryable(MODEL_DISABLED_ERROR), "模型被停用重试无用");
assertEquals("生成失败,请稍后重试", buildFailedResult(MODEL_DISABLED_ERROR).get("answer"));
}
@Test
void 没有错误信息_不重试() throws Exception {
String blank = "";
assertFalse(retryable(null), "错误信息缺失时无从判断,保守不重试");
assertFalse(retryable(blank), "错误信息为空时不重试");
}
@Test
void 网关瞬时故障_值得重试() throws Exception {
assertTrue(retryable("502 Bad Gateway"));
assertTrue(retryable("connection reset by peer"));
assertTrue(retryable("Read timed out"));
}
}