fix: reject HTTP error responses in InputFile downloads - #972
KnightNiwrem wants to merge 7 commits into
Conversation
`InputFile` streamed the body of `fetch()` responses into Telegram uploads without inspecting the HTTP status. Since `fetch()` resolves normally for 4xx/5xx responses, a 404 or 503 error page could be sent as the file contents, or cause a misleading Telegram media error. All three URL helpers (Deno, Node, web) and the Deno `Response` branch now reject responses with status 400-599, releasing the body where possible and throwing a plain `Error` that includes the status. Closes grammyjs#971 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Address review feedback on the HTTP error status check: - Awaiting `body.cancel()` could reject (locked stream) or never settle (custom cancel algorithm), replacing or blocking the intended status error. The Deno `Response` branch now cancels on a best-effort basis without awaiting. - The three URL helpers now pass an `AbortSignal` to `fetch()` and abort on an error status. This releases the connection immediately without draining a potentially huge error body, which `body.resume()` on Node would have done. Node attaches a no-op error listener first because node-fetch emits an error event on the body when aborting. - Node imports `AbortController` from `abort-controller`, matching the shim used by the rest of the backport. Verified against a local server streaming an endless 404 body on both Deno and Node: the download rejects within milliseconds, the server sees the connection closed, and no unhandled rejections or uncaught exceptions occur. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
node-fetch's `Body` constructor unconditionally attaches an error listener to every stream body, so the error event it emits on abort can never surface as an uncaught exception. Remove the no-op listener and document the actual reason instead. Also clear the timeout in the hanging-cancel test so it does not leave a pending timer after the race settles. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The cast was added when `AbortController` still resolved to the global one, whose signal type does not satisfy node-fetch's own `AbortSignal` interface. With `AbortController` imported from `abort-controller`, the signal type checks against node-fetch directly, as verified by `npm run backport`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Drop the explanatory comments around the abort calls and reduce the added tests to the three cases the issue asks for: 4xx/5xx rejection with the range boundaries, an unchanged non-error stream, and the Response input path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #972 +/- ##
==========================================
- Coverage 45.52% 41.70% -3.82%
==========================================
Files 19 19
Lines 5520 7296 +1776
Branches 360 466 +106
==========================================
+ Hits 2513 3043 +530
- Misses 3003 4168 +1165
- Partials 4 85 +81 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
MobinAskari
left a comment
There was a problem hiding this comment.
I tested out various URLs in different scenarios in my own project and it behaved exactly as I expected it to.
The fetch status check function implementation is kinda inconsistent. We have isErrorStatus in .deno.ts but we have a function in place when it comes to .node.ts and .web.ts. Not sure if it even matters since they do the same thing but I just wanted to point it out.
Also I used Node and installed it through npm+git, so I have no idea if it's behaving correctly on Deno, again it'll probably work just fine, I just wanted to notify.
This is more of a question for myself as I'm not really familiar with ins and outs of AbortController, so apologies for my ignorance, but isn't it used to cancel the request mid flight? if the request is already completed and we have the status, why are we calling abort at that point?
When fetch has resolved with a Response, it does not mean that the connection has closed yet, or that the body bytes have all been downloaded to client. If that was the case, there would be no such thing as streaming - since all the body bytes would be in memory already. Aborting a request, therefore, isn't just used to cancel a request mid-flight, but also to close the connection against the server, and signal that we don't want or care about the body bytes any more so it shouldn't bother sending any more, or tracking what is left to send. |
Per maintainer feedback on grammyjs#971, reject any response for which `response.ok` is false, which is what all three fetch implementations define as outside HTTP 200-299. Tests now cover the 2xx boundaries (299 versus 300). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The caller owns the Response they pass into InputFile. Cancelling its body on their behalf destroys an error body they may want to read for diagnostics, and they can release it themselves. Only throw. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Note
The fix was written by Claude Fable 5.1 high on Claude Code CLI.
The PR body and fix verification were done by GPT-5.6 Sol xhigh on Codex CLI.
Summary
Reject unsuccessful responses in
InputFiledownloads using!response.ok, before their bodies can be uploaded as file contents. Only HTTP 200–299 responses pass this check.Fixes #971.
This change:
response.okin the Deno, Node, and web URL helpers, and in Deno's existingResponseinput branchResponseobjects without consuming or cancelling their bodies; callers remain responsible for consuming or cancelling those bodiesErrorcontaining the HTTP status through the existing lazy upload error pathResponseinputsWhy
fetch()resolves normally for HTTP error responses. Previously, a URL returning 404 or 503 could therefore produce a successfully delivered document containing the HTML error page, or a misleading Telegram media error such asIMAGE_PROCESS_FAILED.Checking
response.okconsistently requires a successful HTTP response across all three fetch implementations. URL downloads retain the existing redirect behavior and check the final response'sokvalue. A redirect ending at an HTTP 200 login page still passes this check.Aborting a URL fetch started by grammY releases the connection without draining a potentially huge or endless unsuccessful response body. For a supplied
Response, grammY rejects the unsuccessful status and leaves the body untouched. The caller decides whether to consume or cancel that body.Successful responses retain streaming and the existing missing-body checks, including the failure for a bodyless 204 response.
Verification
The regression tests in
test/types.test.tscover the updated success criterion:fetch()is aborted.Responsewith status 404 rejects with an error containing the HTTP status.The current patch is at commit
ce623338c0bdfba9acbbbe7e1bddbcf8d4f82c81; its source is available through pinned jsDelivr imports.Earlier independent sandbox verification on Deno 2.9.6 exercised commit
5aa566824a4179a493bfb6177d700e4093d7d307, before the updates toresponse.okand handling of suppliedResponsebodies:{ url }, and DenoResponseinputs rejected withHttpError.errorcontaining the original plain status error. Multipart inspection confirmed zero file-body bytes were uploaded.Those sandbox results apply to the earlier commit; the current patch has not been rerun in that sandbox. They do not establish cancellation of supplied
Responsebodies under the current patch, which leaves that responsibility to the caller. Runtime verification there covered Deno and the webInputFileimplementation running in Deno, rather than actual Node or browser runtimes. Valid video/animation controls were not tested.The fix preserves lazy multipart ordering: the Telegram fetch may start before an unsuccessful source is rejected. The existing
HttpErrorwrapper also means auto-retry v2.0.2 retries these failures by default; the earlier sandbox run confirmed thatrethrowHttpErrors: truepropagates them immediately. Retry-policy changes are outside this fix.