-
Notifications
You must be signed in to change notification settings - Fork 1.1k
fix(devin): map Connect trailer codes onto the status core acts on #4423
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -969,6 +969,45 @@ export class CloudChatError extends Error { | |
|
|
||
| const TRACE_ID_RE = /\(trace ID: ([0-9a-f]+)\)/i; | ||
|
|
||
| /** | ||
| * A quota refusal Cognition delivers as `permission_denied`. | ||
| * | ||
| * "Your limit will reset in 13 minutes" and "Reached overall message rate | ||
| * limit" are caps, not authorization failures. Classified as 403 they invite | ||
| * the client to retry straight into a live cap; as 429 the proxy backs off and | ||
| * can rotate. | ||
| */ | ||
| const TRAILER_QUOTA_RE = /\b(?:limit will reset|rate limit|quota exceeded|out of credits)\b/i; | ||
|
|
||
| /** | ||
| * Connect error code to HTTP status. | ||
| * | ||
| * Without this only the HTTP status line reached the adapter, so a cap or an | ||
| * expired credential delivered as an EOS trailer fell through to | ||
| * `inferHttpStatusFromAdapterMessage` and became a generic 502 — which is not | ||
| * retryable-with-backoff, not an auth prompt, and not something core's failover | ||
| * acts on. | ||
| */ | ||
| export function connectTrailerHttpStatus(code: string | undefined, message: string): number | undefined { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This changes the Devin adapter's streaming error and failover contract, but the commit updates none of the structure documents that AGENTS.md reference: src/AGENTS.md:L11-L11 Useful? React with 👍 / 👎. |
||
| if (code === 'permission_denied' && TRAILER_QUOTA_RE.test(message)) return 429; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For the measured AGENTS.md reference: src/AGENTS.md:L19-L19 Useful? React with 👍 / 👎. |
||
| switch (code) { | ||
| case 'unauthenticated': return 401; | ||
| case 'permission_denied': return 403; | ||
| case 'resource_exhausted': return 429; | ||
| case 'not_found': return 404; | ||
| case 'unavailable': return 503; | ||
| case 'deadline_exceeded': return 504; | ||
| case 'unimplemented': return 501; | ||
| case 'invalid_argument': | ||
| case 'failed_precondition': | ||
| case 'out_of_range': return 400; | ||
| case 'internal': | ||
| case 'unknown': | ||
| case 'data_loss': return 502; | ||
| default: return undefined; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Stream chat events from the cloud. Yields CloudChatEvent (text deltas, tool | ||
| * call deltas, finish reason). Use `streamChatText` for legacy text-only iteration. | ||
|
|
@@ -1085,10 +1124,9 @@ export async function* streamChatEvents(req: CloudChatRequest): AsyncGenerator<C | |
| // error event and /api/logs, and a Connect error can quote the request that | ||
| // produced it - which is the request holding the api_key. | ||
| // | ||
| // Only the HTTP status line is carried here. A Connect EOS trailer that | ||
| // reports resource_exhausted or unavailable still arrives without a status, | ||
| // so a cap delivered that way keeps the older message-inference path. | ||
| // Mapping trailer codes onto HTTP statuses is deliberately a follow-up. | ||
| // The status line is carried on the error. A cap or an expired credential | ||
| // delivered instead as a Connect EOS trailer is mapped by | ||
| // connectTrailerHttpStatus at the trailer sites below. | ||
| throw new CloudChatError(`GetChatMessage failed (HTTP ${resp.status})`, undefined, undefined, resp.status); | ||
| } | ||
| if (!resp.body) { | ||
|
|
@@ -1316,7 +1354,12 @@ export async function* streamChatEvents(req: CloudChatRequest): AsyncGenerator<C | |
| `service accepts. If the request is unchanged and this is new, the ` + | ||
| `account's model access is the next thing to check. ` + | ||
| `(cloud trace ID: ${trailerError.traceId ?? 'n/a'})`; | ||
| throw new CloudChatError(enriched, trailerError.code, trailerError.traceId); | ||
| throw new CloudChatError( | ||
| enriched, | ||
| trailerError.code, | ||
| trailerError.traceId, | ||
| connectTrailerHttpStatus(trailerError.code, trailerError.message), | ||
| ); | ||
| } | ||
| // Cognition also returns `permission_denied` when a tool description | ||
| // contains a blocklisted phrase that the sanitizer above did not catch | ||
|
|
@@ -1336,9 +1379,19 @@ export async function* streamChatEvents(req: CloudChatRequest): AsyncGenerator<C | |
| // was a phrase match at all. | ||
| `(cloud message: ${trailerError.message}) ` + | ||
| `(cloud trace ID: ${trailerError.traceId ?? 'n/a'})`; | ||
| throw new CloudChatError(enriched, trailerError.code, trailerError.traceId); | ||
| throw new CloudChatError( | ||
| enriched, | ||
| trailerError.code, | ||
| trailerError.traceId, | ||
| connectTrailerHttpStatus(trailerError.code, trailerError.message), | ||
| ); | ||
| } | ||
| throw new CloudChatError(trailerError.message, trailerError.code, trailerError.traceId); | ||
| throw new CloudChatError( | ||
| trailerError.message, | ||
| trailerError.code, | ||
| trailerError.traceId, | ||
| connectTrailerHttpStatus(trailerError.code, trailerError.message), | ||
| ); | ||
| } | ||
| // Truncation detection: the cloud always terminates a successful stream | ||
| // with an EOS trailer. If we hit `done` from the body reader without one, | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -7,6 +7,7 @@ import { anySignal } from "../../src/lib/abort"; | |||||||||||||||||||||||||||||||||||||||||||||||||||
| import { buildGetChatMessageRequestForTests } from "../../src/adapters/devin/cloud-direct/chat"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { decodeModelUsageStats } from "../../src/adapters/devin/cloud-direct/chat"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { CloudChatError, decodeChatFrame } from "../../src/adapters/devin/cloud-direct/chat"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { connectTrailerHttpStatus } from "../../src/adapters/devin/cloud-direct/chat"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { devinErrorClassification, mergeDevinUsage } from "../../src/adapters/devin"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { iterFields } from "../../src/adapters/devin/cloud-direct/wire"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { buildMetadata, normalizeDevinSessionToken } from "../../src/adapters/devin/cloud-direct/metadata"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -398,3 +399,48 @@ describe("devin usage merging and error classification", () => { | |||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(devinErrorClassification(new CloudChatError("x", "resource_exhausted"))).toEqual({}); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| describe("connect trailer to HTTP status", () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| test("a cap delivered as permission_denied is a 429, not a 403", () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Cognition sends the account cap through the same code as an ACL denial. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Classified 403 the client retries straight into a live cap. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(connectTrailerHttpStatus("permission_denied", "Your limit will reset in 13 minutes")).toBe(429); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(connectTrailerHttpStatus("permission_denied", "Reached overall message rate limit")).toBe(429); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // An ordinary denial stays a denial. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(connectTrailerHttpStatus("permission_denied", "an internal error occurred")).toBe(403); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| test("the remaining Connect codes map to the status core acts on", () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(connectTrailerHttpStatus("unauthenticated", "")).toBe(401); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(connectTrailerHttpStatus("resource_exhausted", "")).toBe(429); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(connectTrailerHttpStatus("unavailable", "")).toBe(503); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(connectTrailerHttpStatus("deadline_exceeded", "")).toBe(504); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(connectTrailerHttpStatus("invalid_argument", "")).toBe(400); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(connectTrailerHttpStatus("internal", "")).toBe(502); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // An unknown code keeps the older message-inference path rather than | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // asserting a status nobody measured. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(connectTrailerHttpStatus("some_new_code", "")).toBeUndefined(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(connectTrailerHttpStatus(undefined, "")).toBeUndefined(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+413
to
+424
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Add a direct assertion for the
Proposed test test("the remaining Connect codes map to the status core acts on", () => {
expect(connectTrailerHttpStatus("unauthenticated", "")).toBe(401);
+ expect(connectTrailerHttpStatus("unimplemented", "")).toBe(501);
expect(connectTrailerHttpStatus("resource_exhausted", "")).toBe(429);📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSources: Coding guidelines, Path instructions |
||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| test("a trailer status reaches the adapter's structured classification", () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const err = new CloudChatError("capped", "permission_denied", "abc", connectTrailerHttpStatus("permission_denied", "Your limit will reset in 3 minutes")); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(devinErrorClassification(err)).toEqual({ status: 429, errorType: "rate_limit_error", retryable: true }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| describe("devin status classification across the newly reachable trailer codes", () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const cls = (status: number) => devinErrorClassification(new CloudChatError("x", undefined, undefined, status)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| test("a request the service will not accept is never retried", () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(cls(400)).toEqual({ status: 400, retryable: false }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(cls(404)).toEqual({ status: 404, retryable: false }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 501 is the one 5xx a second attempt cannot change. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(cls(501)).toEqual({ status: 501, retryable: false }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| test("a timeout or an unavailable service is retryable", () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(cls(503)).toEqual({ status: 503, retryable: true }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(cls(504)).toEqual({ status: 504, retryable: true }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When Cognition returns
permission_deniedwithout of credits, this regex maps it to 429, after whichdevinErrorClassificationemitsrate_limit_errorwithretryable: true. Credit exhaustion cannot recover through rate-limit backoff, so Codex and combo clients are encouraged to retry indefinitely instead of prompting for billing or another provider. Split permanent credit/quota wording into a non-retryable insufficient-quota classification rather than grouping it with resettable rate limits.AGENTS.md reference: src/AGENTS.md:L19-L19
Useful? React with 👍 / 👎.