mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
fix(server): keep classified provider quota runs out of Sentry (#16029)
A run that stops because the user's provider account hit its usage limit is an expected wait, not a Paperclip defect. The adapter already classifies it (errorCode and resultJson.errorFamily are both provider_quota) and recovery schedules a retry at the reset time. Skip the Sentry report only when both adapter-owned fields agree, the run status is failed, no signal stopped it, and the failure did not happen during setup. Timeouts, cancellations, signals, other codes and missing or mismatched families still report. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Dotta <cryppadotta@users.noreply.github.com>
This commit is contained in:
2 files changed
+44
No files matched your search
@@ -301,6 +301,39 @@ describeEmbeddedPostgres("reportRunFailure", () => {
|
||||
expect(mockCaptureRunFailure).toHaveBeenCalledWith(expect.objectContaining({ agentAdapter: "unknown" }));
|
||||
});
|
||||
|
||||
function providerQuotaRun(overrides: Partial<typeof heartbeatRuns.$inferSelect> = {}) {
|
||||
return buildRun({
|
||||
error: "ACP agent reported a terminal limit failure.\nYou've hit your session limit · resets 2am (UTC)",
|
||||
errorCode: "provider_quota", executionStage: "dispatching", exitCode: 1, signal: null,
|
||||
resultJson: { errorFamily: "provider_quota", stopReason: "adapter_failed" }, ...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
it.each(["claude_local", "codex_local"])("keeps a classified %s provider quota wait local", async (adapterType) => {
|
||||
await seedCompanyAndAgent({ adapterType });
|
||||
const [run] = await db.insert(heartbeatRuns).values(providerQuotaRun()).returning();
|
||||
const before = structuredClone(run);
|
||||
for (const phase of [undefined, "execute"] as const) await reportRunFailure(db, run!, { phase });
|
||||
expect(mockCaptureRunFailure).not.toHaveBeenCalled();
|
||||
expect(await db.select().from(heartbeatRuns)).toEqual([before]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ status: "timed_out" }, { status: "cancelled", startedAt: new Date(0) }, { signal: "SIGTERM" },
|
||||
{ errorCode: "adapter_failed" }, { errorCode: "claude_transient_upstream" },
|
||||
{ resultJson: {} }, { resultJson: null }, { resultJson: { errorFamily: "transient_upstream" } },
|
||||
])("reports a provider quota outcome without matching adapter evidence: %j", async (overrides) => {
|
||||
await seedCompanyAndAgent({ adapterType: "claude_local" });
|
||||
await reportRunFailure(db, providerQuotaRun(overrides), { phase: "execute" });
|
||||
expect(mockCaptureRunFailure).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps a provider quota code reportable during setup", async () => {
|
||||
await seedCompanyAndAgent({ adapterType: "claude_local" });
|
||||
await reportRunFailure(db, providerQuotaRun(), { phase: "setup" });
|
||||
expect(mockCaptureRunFailure).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
function aiSelectionRun(overrides: Partial<typeof heartbeatRuns.$inferSelect> = {}) {
|
||||
return buildRun({
|
||||
errorCode: "configuration_incomplete",
|
||||
|
||||
@@ -164,6 +164,16 @@ function isHermesConnectionFailure(run: HeartbeatRun, options: RunFailureReportO
|
||||
(connection.reason === "remote_unavailable" && code === "hermes_gateway_upstream_error");
|
||||
}
|
||||
|
||||
/**
|
||||
* The adapter proved the user's provider account is out of quota, and recovery
|
||||
* waits for its reset. Both fields come from the adapter's own classifier.
|
||||
*/
|
||||
function isProviderQuotaFailure(run: HeartbeatRun, options: RunFailureReportOptions): boolean {
|
||||
return run.status === "failed" && run.errorCode === "provider_quota" &&
|
||||
asRecord(run.resultJson)?.errorFamily === "provider_quota" &&
|
||||
options.phase !== "setup" && run.signal == null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a terminal run failure to Sentry. Returns at once for any status
|
||||
* other than failures and unexpected started cancellations. Never throws — a Sentry failure or a
|
||||
@@ -182,6 +192,7 @@ export function reportRunFailure(db: Db, run: HeartbeatRun, options: RunFailureR
|
||||
if (isAiConnectionSelectionBlocker(run, options)) return Promise.resolve();
|
||||
if (isLocalPathWorkspaceConfigurationBlocker(run, options)) return Promise.resolve();
|
||||
if (isGitConnectionFailure(run, options)) return Promise.resolve();
|
||||
if (isProviderQuotaFailure(run, options)) return Promise.resolve();
|
||||
const runStatus = run.status;
|
||||
const report = captureTerminalRunFailure(db, run, runStatus, options);
|
||||
pendingRunFailureReports.add(report);
|
||||
|
||||
Reference in new issue
Block a user