diff --git a/fastapi/applications.py b/fastapi/applications.py index 02193312b..889e14c93 100644 --- a/fastapi/applications.py +++ b/fastapi/applications.py @@ -18,10 +18,15 @@ from fastapi import routing from fastapi.datastructures import Default, DefaultPlaceholder from fastapi.exception_handlers import ( http_exception_handler, + request_entity_too_large_exception_handler, request_validation_exception_handler, websocket_request_validation_exception_handler, ) -from fastapi.exceptions import RequestValidationError, WebSocketRequestValidationError +from fastapi.exceptions import ( + RequestEntityTooLarge, + RequestValidationError, + WebSocketRequestValidationError, +) from fastapi.logger import logger from fastapi.middleware.asyncexitstack import AsyncExitStackMiddleware from fastapi.openapi.docs import ( @@ -992,6 +997,10 @@ class FastAPI(Starlette): # Starlette still has incorrect type specification for the handlers websocket_request_validation_exception_handler, # type: ignore ) + self.exception_handlers.setdefault( + RequestEntityTooLarge, + request_entity_too_large_exception_handler, + ) self.user_middleware: List[Middleware] = ( [] if middleware is None else list(middleware) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 23bca6f2a..1f7830688 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -36,7 +36,6 @@ from fastapi._compat import ( get_annotation_from_field_info, get_cached_model_fields, get_missing_field_error, - is_bytes_field, is_bytes_sequence_field, is_scalar_field, is_scalar_sequence_field, @@ -894,6 +893,9 @@ def _should_embed_body_fields(fields: List[ModelField]) -> bool: return False +from fastapi.exceptions import RequestEntityTooLarge + + async def _extract_form_body( body_fields: List[ModelField], received_body: FormData, @@ -903,12 +905,29 @@ async def _extract_form_body( for field in body_fields: value = _get_multidict_value(field, received_body) field_info = field.field_info - if ( - isinstance(field_info, (params.File, temp_pydantic_v1_params.File)) - and is_bytes_field(field) - and isinstance(value, UploadFile) - ): - value = await value.read() + if isinstance( + field_info, (params.File, temp_pydantic_v1_params.File) + ) and isinstance(value, UploadFile): + # If a file size limit is defined through max_size + max_size = getattr(field_info, "max_size", None) + if max_size is not None: + CHUNK = 8192 + total = 0 + content = bytearray() + + while True: + chunk = await value.read(CHUNK) + if not chunk: + break + total += len(chunk) + if total > max_size: + raise RequestEntityTooLarge( + f"Uploaded file '{field.alias}' exceeded max size={max_size} bytes" + ) + content.extend(chunk) + value = bytes(content) + else: + value = await value.read() elif ( is_bytes_sequence_field(field) and isinstance(field_info, (params.File, temp_pydantic_v1_params.File)) diff --git a/fastapi/exception_handlers.py b/fastapi/exception_handlers.py index 475dd7bdd..dadbf2680 100644 --- a/fastapi/exception_handlers.py +++ b/fastapi/exception_handlers.py @@ -1,5 +1,8 @@ from fastapi.encoders import jsonable_encoder -from fastapi.exceptions import RequestValidationError, WebSocketRequestValidationError +from fastapi.exceptions import ( + RequestValidationError, + WebSocketRequestValidationError, +) from fastapi.utils import is_body_allowed_for_status_code from fastapi.websockets import WebSocket from starlette.exceptions import HTTPException @@ -8,6 +11,13 @@ from starlette.responses import JSONResponse, Response from starlette.status import WS_1008_POLICY_VIOLATION +async def request_entity_too_large_exception_handler(request, exc: Exception): + return JSONResponse( + status_code=413, + content={"detail": str(exc) or "Uploaded file too large"}, + ) + + async def http_exception_handler(request: Request, exc: HTTPException) -> Response: headers = getattr(exc, "headers", None) if not is_body_allowed_for_status_code(exc.status_code): diff --git a/fastapi/exceptions.py b/fastapi/exceptions.py index 0620428be..e922ed948 100644 --- a/fastapi/exceptions.py +++ b/fastapi/exceptions.py @@ -182,3 +182,9 @@ class ResponseValidationError(ValidationException): for err in self._errors: message += f" {err}\n" return message + + +class RequestEntityTooLarge(Exception): + """Raised when uploaded content exceeds the configured max_size.""" + + pass diff --git a/fastapi/params.py b/fastapi/params.py index 6d07df35e..9ed5a5ba7 100644 --- a/fastapi/params.py +++ b/fastapi/params.py @@ -725,6 +725,7 @@ class File(Form): # type: ignore[misc] deprecated: Union[deprecated, str, bool, None] = None, include_in_schema: bool = True, json_schema_extra: Union[Dict[str, Any], None] = None, + max_size: Optional[int] = None, **extra: Any, ): super().__init__( @@ -760,6 +761,7 @@ class File(Form): # type: ignore[misc] json_schema_extra=json_schema_extra, **extra, ) + self.max_size = max_size @dataclass(frozen=True) diff --git a/main.py b/main.py new file mode 100644 index 000000000..970698837 --- /dev/null +++ b/main.py @@ -0,0 +1,41 @@ +from typing import Union + +from fastapi import FastAPI, File, UploadFile +from pydantic import BaseModel + +app = FastAPI() + + +class Item(BaseModel): + name: str + price: float + is_offer: Union[bool, None] = None + + +@app.get("/") +def read_root(): + return {"Hello": "World"} + + +@app.get("/items/{item_id}") +def read_item(item_id: int, q: Union[str, None] = None): + return {"item_id": item_id, "q": q} + + +@app.put("/items/{item_id}") +def update_item(item_id: int, item: Item): + return {"item_name": item.name, "item_id": item_id} + + +@app.post("/upload") +async def upload_file(file: UploadFile = File(max_size=500)): + total_bytes = 0 + + # Safest: process in chunks instead of reading whole file + while True: + chunk = await file.read(1024 * 1024) # 1MB + if not chunk: + break + total_bytes += len(chunk) + + return {"filename": file.filename, "size": total_bytes}