Skip to content
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

Add 400 response when boundary is missing #1617

Merged
merged 12 commits into from
May 19, 2022
Merged

Conversation

Kludex
Copy link
Sponsor Member

@Kludex Kludex commented May 3, 2022

Behavior of this PR:

  • Custom exception in case "app" not in scope.
  • HTTPException(status_code=400) in case "app" in scope.

Changes:

  • Move HTTPException to http_exception.py to avoid circular import.
  • Add custom exception MultiPartException on missing boundary.
  • Handle MultiPartException on form() with the behavior mentioned above.

Alternatives/thoughts related to this PR:

  • Check Add 400 response on MultiParser exceptions #1593 - my thoughts are listed there in a timely manner...
  • The handling of MultiPartException can also be on an upper level:
    async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
    if self.methods and scope["method"] not in self.methods:
    headers = {"Allow": ", ".join(self.methods)}
    if "app" in scope:
    raise HTTPException(status_code=405, headers=headers)
    else:
    response = PlainTextResponse(
    "Method Not Allowed", status_code=405, headers=headers
    )
    await response(scope, receive, send)
    else:
    await self.app(scope, receive, send)

    This alternative would avoid creating the http_exception.py... 🤔

@Kludex Kludex changed the title 400 on missing boundary 2 Add 400 response when boundary is missing May 3, 2022
@Kludex Kludex requested review from tomchristie and a team May 3, 2022 11:48
starlette/http_exception.py Outdated Show resolved Hide resolved
starlette/formparsers.py Outdated Show resolved Hide resolved
starlette/formparsers.py Outdated Show resolved Hide resolved
starlette/formparsers.py Outdated Show resolved Hide resolved
@Kludex Kludex force-pushed the 400-on-missing-boundary-2 branch from f4a3ab0 to d52dd9a Compare May 5, 2022 05:43
@Kludex Kludex force-pushed the 400-on-missing-boundary-2 branch from 9a3d6ad to f1eb5ba Compare May 5, 2022 05:52
@Kludex Kludex added this to the Version 0.21.0 milestone May 5, 2022

async def sender(message: Message) -> None:
nonlocal response_started
def __getattr__(name: str) -> typing.Any: # pragma: no cover
Copy link
Sponsor Member Author

Choose a reason for hiding this comment

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

I need to add a test for this.

Copy link
Member

Choose a reason for hiding this comment

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

Didn't realise you could do this at a module level. 😎

tests/test_formparsers.py Show resolved Hide resolved

async def sender(message: Message) -> None:
nonlocal response_started
def __getattr__(name: str) -> typing.Any: # pragma: no cover
Copy link
Member

Choose a reason for hiding this comment

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

What's the plan to remove all of this deprecation code? I get why we need it, but it'd be nice to know when we're going to remove it so that:

  1. We can put it in the code for our knowledge down the road.
  2. We can put it in the error message.

I suppose this depends on #1623 , but I think we should make an executive decision (maybe @tomchristie ?) on when / if the deprecation shim is ever going to be removed.

Personally, I feel like 3 minor releases or 1 year, whichever comes first, sounds reasonable?

Copy link
Sponsor Member Author

Choose a reason for hiding this comment

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

I was more into 6 months and 3 minors... But any number is good for me... I'd just like to have a number 👍 But let's mention those stuff on that discussion.

For here, there's no plan yet. The plan should be defined on that discussion. If this is merged, it's just common sense i.e. in some arbitrary time we just remove it...

Copy link
Member

@adriangb adriangb left a comment

Choose a reason for hiding this comment

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

This is an interesting change.

Overall the change (returning a proper exception to the client instead of a 500) makes absolute sense.

Unfortunately you ended up needing to do a bunch of surgery to the codebase to make it happen because raising an HTTPexception from within a Request requires that Request reference HTTPException, but ExceptionMiddleware was in the same module as HTTPException, which caused circular references. This makes the diff super ugly and huge relative to the small straightforward feature.

But I think that's okay: we are paying the price to fix an issue that already existed in our codebase that might have caused us issues down the road some other time. And personally I think moving the middleware into middlewares/ really cleans things up nicely.

I'm approving this, I do however want 1 more approver and some consensus on the deprecation policy because this is a breaking change for our downstream dependencies.

@Kludex
Copy link
Sponsor Member Author

Kludex commented May 12, 2022

[...] I do however want 1 more approver [...]

Yep 👍

Thanks for the review! 🙏

Copy link
Member

@tomchristie tomchristie left a comment

Choose a reason for hiding this comment

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

Some questions which could be addressed, but they're not blockers so will leave this in your hands at this point @Kludex.

Really nice switch around with moving the middleware. 👍


async def sender(message: Message) -> None:
nonlocal response_started
def __getattr__(name: str) -> typing.Any: # pragma: no cover
Copy link
Member

Choose a reason for hiding this comment

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

Didn't realise you could do this at a module level. 😎

from starlette.types import ASGIApp, Message, Receive, Scope, Send


class ExceptionMiddleware:
Copy link
Member

Choose a reason for hiding this comment

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

👍 Yup - much better to have this here.

Have confirmed to myself that the class here and the class when it was in exceptions.py are identical.

multipart_parser = MultiPartParser(self.headers, self.stream())
self._form = await multipart_parser.parse()
except MultiPartException as exc:
if "app" in self.scope:
Copy link
Member

Choose a reason for hiding this comment

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

Do we need to have the switch here? Can we just always coerce to an HTTPException here instead? Are we already using this pattern, and if so, where?

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Sponsor Member Author

Choose a reason for hiding this comment

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

Yep, we use the pattern in the code, as @adriangb pointed out.

Always coerce to an HTTPException is an option. I opted to not do it because we only use HTTPExceptions when we are in Starlette (as application) e.g. "app" in self.scope.

Copy link
Member

Choose a reason for hiding this comment

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

Realistically, I think we could remove that pattern everywhere. IMO it's not worth supporting (nice) usage of Starlette's Request object outside of a Starlette app. If someone wants to do that, they can handle the HTTPException.

Copy link
Sponsor Member Author

@Kludex Kludex May 19, 2022

Choose a reason for hiding this comment

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

If someone wants to do that, they can handle the HTTPException.

You have a point, but I think that's another discussion.

I really don't think it's a burden to maintain that, but I wonder if someone really uses that code outside Starlette (app)... 🤔

client = test_client_factory(app)
with pytest.raises(KeyError, match="boundary"):
client.post(
with expectation:
Copy link
Member

Choose a reason for hiding this comment

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

I'd personally probably nudge towards the simpler pytest.raises(KeyError, match="boundary") case here, just because it's more obviously readable to me.

The parameterised expectation is neat, but also more complex to understand.

Just my personal perspective tho, happy to go either way.

Copy link
Sponsor Member Author

Choose a reason for hiding this comment

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

The idea was to avoid the creation of two very similar tests. Do you think is clear if I create two tests instead?

Copy link
Member

Choose a reason for hiding this comment

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

I think your way or combining an exception and non-exception test into 1 is the cleanest I've seen for that pattern, but if it's
only 2 tests and not 10 I also think making it more explicit would be nice

@adriangb

This comment was marked as off-topic.

@Kludex
Copy link
Sponsor Member Author

Kludex commented May 19, 2022

I'm going to merge this.

Some things left unanswered:

  • Deprecation policy, how do we proceed?
  • Should we check if someone is using parts of Starlette without the Starlette application itself? i.e. if "app" in scope:.

@Kludex Kludex merged commit 3453fd6 into master May 19, 2022
@Kludex Kludex deleted the 400-on-missing-boundary-2 branch May 19, 2022 18:33
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.

Send 400 on missing boundary
3 participants