Skip to content

fix(actions): surface image size limit errors instead of the generic message - #3447

Closed
pacocartones wants to merge 1 commit into
theopenco:mainfrom
pacocartones:fix/surface-image-size-limit-errors
Closed

pacocartones wants to merge 1 commit into
theopenco:mainfrom
pacocartones:fix/surface-image-size-limit-errors

Conversation

@pacocartones

@pacocartones pacocartones commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary

processImageUrl() never tells a caller that their image was too big. The size-limit passthrough in the catch block sniffs the error message for "Image size exceeds":

if (error instanceof Error && error.message.includes("Image size exceeds")) {
	throw error; // Re-throw size limit errors as-is
}

but the message getImageSizeErrorMessage() actually produces is

Image size (32.0MB) exceeds your current limit of 1MB.

The size parenthetical sits between Image size and exceeds, so the substring never matches. That branch is dead. Both remote-URL size checks — the Content-Length pre-check and the post-download body check — are swallowed and rethrown as the generic "Failed to process image from URL".

The asymmetry is what gives it away: the data URL branch throws from outside the try, so it always reported the real message. Same rejection, two different answers depending on how the image arrived.

Fix

Replace the message sniffing with a typed ImageSizeLimitError. The passthrough then keys off the type, so it cannot rot again the next time the wording changes — which is exactly how it broke.

The other two passthroughs in the same catch ("Failed to fetch image: HTTP" and "URL does not point to a valid image") also use .includes(), but those substrings do match the messages that are actually thrown, so they work today. I left them alone rather than widen this PR.

Tests

New packages/actions/src/process-image-url.spec.ts, 4 tests:

  • Content-Length exceeds the limit — the pre-check path.
  • downloaded body exceeds the limit — a streamed response carries no Content-Length, so the post-download check is the one that rejects.
  • data URL and remote URL report the same way — pins the asymmetry that is the whole bug.
  • non-size failures are still sanitized — the passthrough must stay narrow, so upstream/network detail does not leak to the caller.

In each size test the message is asserted before the type, on purpose: that is the line that fails on main, and it fails for the reason a user experiences rather than because a new export is missing.

Without the fix (source reverted to main, tests kept):

 FAIL  process-image-url.spec.ts > surfaces the size message when Content-Length exceeds the limit
AssertionError: expected 'Failed to process image from URL' to contain 'Image size (32.0MB) exceeds your curr…'

 FAIL  process-image-url.spec.ts > surfaces the size message when the downloaded body exceeds the limit
AssertionError: expected 'Failed to process image from URL' to contain 'Image size (2.0MB) exceeds your curre…'

 FAIL  process-image-url.spec.ts > reports data URL and remote URL rejections the same way
AssertionError: expected 'Failed to process image from URL' to match /^Image size \(\d+\.\d+MB\) exceeds yo…/

 Tests  3 failed | 1 passed (4)

With the fix: Tests 4 passed (4).

What I did not verify

pnpm exec vitest run packages/actions is 534 passed, 4 failed; the 4 failures are provider-key/env-inventory.spec.ts hitting ECONNREFUSED 127.0.0.1:6379. I re-ran that file on a clean main and it fails identically, so it is a missing local Redis and not this change — but I have not run the suite anywhere with the service up. tsc --noEmit, eslint and prettier --check are clean on both changed files.

Summary by CodeRabbit

  • Bug Fixes
    • Improved image size-limit handling for remote and data URL images.
    • Added consistent, clearly typed errors when images exceed the allowed size.
    • Prevented unrelated image download failures from being misidentified as size-limit errors.

The catch block in processImageUrl() re-threw size-limit errors only when
the message contained "Image size exceeds", but getImageSizeErrorMessage()
produces "Image size (32.0MB) exceeds your current limit of 1MB." — the
size parenthetical breaks the substring, so the branch never fired. Both
remote-URL size checks (Content-Length pre-check and post-download body
check) were swallowed and rethrown as the generic "Failed to process
image from URL", leaving users with no idea their image was too big,
while the data URL branch (which throws outside the try) reported the
real message.

Replace the message sniffing with a typed ImageSizeLimitError so the
passthrough cannot silently rot when the wording changes again, and add
regression tests covering both remote paths, data-URL/remote parity, and
that non-size failures are still sanitized.
@coderabbitai

coderabbitai Bot commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d90839eb-5080-4195-9547-e958efdf7e7f

📥 Commits

Reviewing files that changed from the base of the PR and between af282cf and f2377fa.

📒 Files selected for processing (2)
  • packages/actions/src/process-image-url.spec.ts
  • packages/actions/src/process-image-url.ts

Walkthrough

Changes

Image size-limit error handling

Layer / File(s) Summary
Typed size-limit error flow
packages/actions/src/process-image-url.ts, packages/actions/src/process-image-url.spec.ts
ImageSizeLimitError now identifies data URL, Content-Length, and downloaded-body size violations. processImageUrl rethrows this error type and sanitizes other fetch failures. Tests cover the error type, messages, response handling, and cleanup.

Estimated code review effort: 2 (Simple) | ~10 minutes

Suggested reviewers: steebchen

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: preserving image size limit errors instead of replacing them with a generic message.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@smakosh
smakosh requested a review from steebchen August 8, 2026 11:28

@smakosh smakosh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review

Typing the error is the right call, but as written the fix lands on /v1/images and /v1/videos only — the chat-completions path still flattens it to a 500. Findings below, most important first.

1. ImageSizeLimitError extends Error instead of RequestError, so chat completions still return a generic 500

*/
export class ImageSizeLimitError extends Error {
public constructor(message: string) {
super(message);
this.name = "ImageSizeLimitError";
}
}

RequestError is already imported in this file. Because the new class isn't one, on /v1/chat/completions:

  • transformGoogleMessages only preserves RequestError and otherwise rethrows a plain Error:
    error instanceof Error ? error.message : "Unknown error";
    // Preserve the RequestError type (and its status code) so the
    // gateway returns a 4xx and logs a client_error row instead of
    // treating a client-caused image failure as an unhandled 500.
    if (error instanceof RequestError) {
    throw new RequestError(
    `Failed to process image: ${errorMsg}`,
    error.statusCode,
    );
    }
    throw new Error(`Failed to process image: ${errorMsg}`);
  • the chat handler's typed-client-error branch doesn't match, so no client_error log row is written:
    );
    } catch (e) {
    // Surface typed pre-upstream input errors in the activity feed as a
    // client_error. Without this, app.onError returns a 400 but no log row
    // is written, so the user never sees the rejected request in history.
    if (
    e instanceof InvalidFileContentError ||
    e instanceof UnsupportedAudioFormatError ||
    e instanceof UnsupportedDocumentFormatError ||
    e instanceof RequestError
    ) {
    const statusCode = e instanceof RequestError ? e.statusCode : 400;
  • app.onError falls through to 500 "Internal Server Error" plus an Unhandled error log:
    // For any other errors (non-HTTPException), return 500 Internal Server Error
    logger.error("Unhandled error", toError(error));
    return renderGatewayError(c, 500, "Internal Server Error");

So the user still gets an opaque 500 with the size message discarded, the request never appears in the activity feed, and every oversized user image raises a false server-error alert. extends RequestError with statusCode 400 fixes all of these at once (and also covers the aws-mantle path in prepare-request-body.ts, which has no try/catch).

2. On Anthropic and Bedrock the size error is swallowed into a placeholder, so the fix is a no-op there

};
} catch (error) {
logger.error(`Failed to fetch image ${part.image_url.url}`, {
err: error instanceof Error ? error : new Error(String(error)),
});
// Fallback to text representation
return {
type: "text",
text: `[Image failed to load: ${part.image_url.url}]`,
} as TextContent;
}

Both call sites catch every processImageUrl error and substitute [Image failed to load: <url>] (Bedrock does the same in prepare-request-body.ts). The request returns 200, the image is silently ignored, and the user is billed for the tokens without ever learning the image was too big — the same symptom, on the two chat providers that actually download images. Worth re-throwing ImageSizeLimitError from those catches.

3. The newly-surfaced message states a limit the caller doesn't have on /v1/images and /v1/videos

if (error instanceof ImageSizeLimitError) {
throw error; // Re-throw size limit errors as-is
}

Both call sites use the default maxSizeMB = 20 and userPlan = null:

A Pro org (100MB on chat) posting a 30MB image to /v1/images/edits now gets Image size (30.0MB) exceeds your current limit of 20MB. — a number that org doesn't have, and one that contradicts its chat limit. With userPlan null, an enterprise org also gets the non-enterprise upsell sentence. Before this change the generic message hid the wrong number; now it is user-visible, so these call sites should pass the real limit and plan.

4. Enterprise orgs are bucketed into the free cap on chat

// Get image size limits from environment variables or use defaults
const freeLimitMB = Number(process.env.IMAGE_SIZE_LIMIT_FREE_MB) || 50;
const proLimitMB = Number(process.env.IMAGE_SIZE_LIMIT_PRO_MB) || 100;
// Determine max image size based on plan
const userPlan = organization?.plan ?? "free";
const maxImageSizeMB = userPlan === "pro" ? proLimitMB : freeLimitMB;

userPlan === "pro" ? proLimitMB : freeLimitMB puts enterprise on the free limit, and both values are forwarded to processImageUrl. Combined with the enterprise branch of getImageSizeErrorMessage, an enterprise org can now be told exceeds your current limit of 50MB. Contact us to increase your Enterprise plan limits. Pre-existing, but this PR is what makes it reach the user.

5. toFixed(1) can print a limit that equals the reported size

let message = `Image size (${actualSizeMB.toFixed(1)}MB) exceeds your current limit of ${maxSizeMB}MB.`;

The actual size is rounded to one decimal while the limit prints as a bare integer, so anything in the ~52KB band above the limit reads Image size (1.0MB) exceeds your current limit of 1MB. ((1048577/(1024*1024)).toFixed(1) === "1.0"). Users barely over the threshold — exactly the ones who hit this — get a self-contradictory message. Rounding up would avoid it.

6. logger.error fires for what is now an ordinary client rejection

} catch (error) {
// Log the full error internally but sanitize the thrown error
logger.error("Error processing image URL", {
err: error instanceof Error ? error : new Error(String(error)),
url: url.substring(0, 50) + "...",
});
if (error instanceof ImageSizeLimitError) {
throw error; // Re-throw size limit errors as-is
}

The error-severity log runs before the new instanceof passthrough, so every plan-limit rejection lands in error dashboards next to genuine fetch/SSRF/decode failures — and it is already logged at warn at the throw sites. Moving the logger.error below the passthrough would keep it to unexpected failures.

7. Size is checked before content type, so oversized non-images now get the wrong diagnosis

// Check content length
const contentLength = response.headers.get("content-length");
if (contentLength && parseInt(contentLength, 10) > maxSizeBytes) {
const actualSizeMB = parseInt(contentLength, 10) / (1024 * 1024);
logger.warn("Image size exceeds limit via Content-Length", {
contentLength,
maxSizeMB,
actualSizeMB,
});
throw new ImageSizeLimitError(
getImageSizeErrorMessage(maxSizeMB, actualSizeMB, userPlan),
);
}
const contentType = response.headers.get("content-type");
if (!contentType || !contentType.startsWith("image/")) {
logger.warn("Invalid content type for image URL", {
contentType,
url: url.substring(0, 50) + "...",
});
throw new Error("URL does not point to a valid image");

A 60MB application/pdf URL now reports "Image size … exceeds your current limit" rather than "URL does not point to a valid image", so the user shrinks the file and hits a completely different error on the retry. Previously both collapsed to the generic message, so the misleading specificity is new. Checking content type first resolves it.

Tests

  • The fully anchored regex couples the assertion to ambient env: getImageSizeErrorMessage appends the upsell sentence when HOSTED and PAID_MODE are both "true", and neither is pinned by the vitest setup or CI, so HOSTED=true PAID_MODE=true pnpm test:unit turns this red. Dropping the $ (or clearing both vars in the test) makes it deterministic.
    const sizeMessage =
    /^Image size \(\d+\.\d+MB\) exceeds your current limit of 1MB\.$/;
    // Data URLs already reported correctly (they throw outside the try), so
    // this asymmetry is the whole bug: same rejection, two different answers
    // depending on how the image arrived.
    expect((dataUrl as Error).message).toMatch(sizeMessage);
    expect((remote as Error).message).toMatch(sizeMessage);
    expect(remote).toBeInstanceOf(ImageSizeLimitError);
  • throw new Error("expected throw") sits inside the try it guards, so it is caught by its own catch and a regression where processImageUrl stops throwing reports expected 'expected throw' to contain … instead of naming the real failure. await expect(...).rejects.toThrow(...) — used correctly in the last test — avoids the misdirection. Same shape at L68.
    try {
    await processImageUrl(REMOTE_URL, false, MAX_SIZE_MB);
    throw new Error("expected throw");
    } catch (err) {
    // The message is asserted before the type on purpose: this is the line
    // that fails on the unfixed code, and it fails for the reason the user
    // experiences ("Failed to process image from URL") rather than because
    // a new export is missing.
    expect((err as Error).message).toContain(
    "Image size (32.0MB) exceeds your current limit of 1MB.",
    );
    expect(err).toBeInstanceOf(ImageSizeLimitError);
    }
  • Several comments narrate the pre-fix bug rather than current behaviour ("The catch block used to sniff for…", "this is the line that fails on the unfixed code"), including the class doc comment. AGENTS.md/CLAUDE.md asks for no unnecessary comments; this archaeology already lives in the PR description.
    // The catch block used to sniff for "Image size exceeds", which the generated
    // message ("Image size (32.0MB) exceeds your current limit of 1MB.") never
    // contains, so every remote-URL size rejection surfaced as the generic
    // "Failed to process image from URL" and users were never told why.
    it("surfaces the size message when Content-Length exceeds the limit", async () => {

Pre-existing, not this PR's job — flagging for a follow-up

Two memory hazards in the same function that this PR's tests now pin in place: the body is fully buffered before the post-download size check, so a response with no (or an understated) Content-Length bypasses the cap entirely, and the base64 conversion allocates one JS string per byte before joining.

const arrayBuffer = await response.arrayBuffer();
// Check actual size after download
if (arrayBuffer.byteLength > maxSizeBytes) {
const actualSizeMB = arrayBuffer.byteLength / (1024 * 1024);
logger.warn("Image size exceeds limit after download", {
size: arrayBuffer.byteLength,
maxSizeMB,
actualSizeMB,
});
throw new ImageSizeLimitError(
getImageSizeErrorMessage(maxSizeMB, actualSizeMB, userPlan),
);
}
// Convert arrayBuffer to base64 using browser-compatible API
const uint8Array = new Uint8Array(arrayBuffer);
const binaryString = Array.from(uint8Array, (byte) =>
String.fromCharCode(byte),
).join("");
const base64 = btoa(binaryString);

Generated with Claude Code

@smakosh

smakosh commented Aug 8, 2026

Copy link
Copy Markdown
Member

Opened #3480 which builds directly on this branch — your commit is preserved there, plus a Co-authored-by trailer on the follow-up.

The main addition is making ImageSizeLimitError extend RequestError so the fix reaches /v1/chat/completions too, along with the message-accuracy and test points from the review above. Leaving this open for you; happy to close it as superseded once #3480 lands if you'd rather not rework it.

Copy link
Copy Markdown
Member

Thanks for the fix and the thorough write-up, @pacocartones — the diagnosis was spot on. Superseded by #3481, which carries your commit as-is and additionally addresses the review findings above (the error is now a RequestError so chat completions return a 400 instead of a 500, Anthropic/Bedrock no longer swallow it into a placeholder, and the image/video call sites report the correct limits). Closing this one in favor of #3481.


Generated by Claude Code

@steebchen steebchen closed this Aug 8, 2026
steebchen pushed a commit that referenced this pull request Aug 8, 2026
Builds on #3447 by @pacocartones (their commit is preserved here).

## Problem

`processImageUrl`'s catch block sniffed the message for `"Image size
exceeds"`, which the generated text (`"Image size (32.0MB) exceeds your
current limit of 1MB."`) never contains, so every remote-URL size
rejection was flattened into the generic `"Failed to process image from
URL"`. #3447 fixes that by throwing a typed `ImageSizeLimitError`, but
as a plain `Error` it only helps `/v1/images` and `/v1/videos`, which
wrap errors themselves. On `/v1/chat/completions` — the path users
actually hit — `transformGoogleMessages`, the chat handler's
typed-client-error branch and `app.onError` all key on `RequestError`,
so the message was still discarded and the user got a 500 with no
`client_error` log row and a false server-error alert.

## Approach

`ImageSizeLimitError extends RequestError` (400). That single change
lands the fix on every path at once, including the `aws-mantle` branch
in `prepare-request-body.ts`, which has no try/catch at all.

Alongside it, in the same function:

- Anthropic and Bedrock caught every `processImageUrl` error and
substituted an `[Image failed to load]` placeholder, so an oversized
image returned 200 with the image silently ignored and the turn billed.
Size rejections are now re-thrown; network flakiness still degrades to
the placeholder.
- The message no longer claims a plan limit when the caller has no plan
context. `/v1/images/edits` and `/v1/videos` call with the default 20MB
cap and `userPlan: null`, so a Pro org was told `exceeds your current
limit of 20MB` — a limit it does not have — plus a non-enterprise
upsell. Those callers now get `exceeds the 20MB limit for image inputs`.
- Enterprise orgs were bucketed into the free branch (`userPlan ===
"pro" ? proLimitMB : freeLimitMB`), so an enterprise org was rejected at
the free cap and then told to contact us about raising its Enterprise
limits. Enterprise now reads `IMAGE_SIZE_LIMIT_ENTERPRISE_MB`,
defaulting to the Pro limit.
- The body is read with a running cap instead of `arrayBuffer()`, so a
response with no (or an understated) `Content-Length` can no longer be
buffered in full before the check runs, and base64 goes through `Buffer`
instead of one JS string per byte.
- Non-base64 data URLs are percent-decoded instead of `btoa`-ing the
escapes, which silently corrupted SVG payloads and threw an uncaught
`InvalidCharacterError` on any non-Latin-1 character.
- Content type is checked before size, so an oversized PDF is no longer
reported as an image that is too big.
- The reported size rounds up, so it can never print `Image size (1.0MB)
exceeds your current limit of 1MB.`
- Size rejections log at `warn` only; they were also hitting
`logger.error` on every plan-limit rejection.

## Deliberate omissions

The 20MB cap on image/video inputs is left as-is. Threading the org plan
into `processVideoImageInput` means touching call sites in the atlas and
avalanche upload helpers that have no organization in scope, and raising
the cap could push payloads past provider limits — so the message was
made accurate rather than the cap made plan-aware. A follow-up can do
the plumbing.

## Verification

- `packages/actions` suite: 545 tests pass, including 11 in the
rewritten `process-image-url.spec.ts` (streaming cap abandons an endless
body, `statusCode` is 400, content-type precedence, data-URL round-trip,
plan-neutral message).
- The spec no longer depends on ambient env: it passes both with and
without `HOSTED=true PAID_MODE=true` exported, which turned the previous
anchored regex red.
- `pnpm build` and lint clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved image upload error messages to clearly identify applicable
20MB or plan-based limits.
* Prevented oversized images from being silently replaced with
placeholders during processing.
* Added safer handling for remote and data URL images, including content
validation, download limits, and clearer failure messages.
  * Improved handling of malformed or unavailable image URLs.
* Enterprise image limits can now be configured independently, with a
sensible default.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Manuel Sánchez <mpktmpktmpktmpkt@gmail.com>
Co-authored-by: pacocartones <pacocartones@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants