BE-879: Return Problem Details for public API request rejections - #9841
TimDiekmann wants to merge 11 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
3 Skipped Deployments
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #9841 +/- ##
==========================================
+ Coverage 65.56% 65.95% +0.38%
==========================================
Files 1896 1909 +13
Lines 201506 210322 +8816
Branches 8028 8232 +204
==========================================
+ Hits 132115 138708 +6593
- Misses 67841 70058 +2217
- Partials 1550 1556 +6
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Merging this PR will not alter performance
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
as_constant |
< 1 ns | < 1 ns | N/A | |
constant_equal |
< 1 ns | < 1 ns | N/A | |
constant_not_equal |
< 1 ns | < 1 ns | N/A | |
access |
< 1 ns | < 1 ns | N/A | |
runtime_equal |
< 1 ns | < 1 ns | N/A | |
runtime_not_equal |
< 1 ns | < 1 ns | N/A |
Comparing t/be-879-return-problem-details-for-public-api-request-rejections (b2c86d6) with main (1c14c9e)
…details extractors
… problem details when they fail to serialize
…ocs and test names
Benchmark results
|
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| resolve_policies_for_actor | user: empty, selectivity: high, policies: 2002 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: medium, policies: 1002 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: high, policies: 3314 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: medium, policies: 1527 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: high, policies: 2078 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: medium, policies: 1033 | Flame Graph |
policy_resolution_medium
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| resolve_policies_for_actor | user: empty, selectivity: high, policies: 102 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: medium, policies: 52 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: high, policies: 269 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: medium, policies: 108 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: high, policies: 133 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: medium, policies: 63 | Flame Graph |
policy_resolution_none
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| resolve_policies_for_actor | user: empty, selectivity: high, policies: 2 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: medium, policies: 2 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: high, policies: 8 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: medium, policies: 3 | Flame Graph |
policy_resolution_small
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| resolve_policies_for_actor | user: empty, selectivity: high, policies: 52 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: medium, policies: 26 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: high, policies: 94 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: medium, policies: 27 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: high, policies: 66 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: medium, policies: 29 | Flame Graph |
read_scaling_complete
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| entity_by_id;one_depth | 1 entities | Flame Graph | |
| entity_by_id;one_depth | 10 entities | Flame Graph | |
| entity_by_id;one_depth | 25 entities | Flame Graph | |
| entity_by_id;one_depth | 5 entities | Flame Graph | |
| entity_by_id;one_depth | 50 entities | Flame Graph | |
| entity_by_id;two_depth | 1 entities | Flame Graph | |
| entity_by_id;two_depth | 10 entities | Flame Graph | |
| entity_by_id;two_depth | 25 entities | Flame Graph | |
| entity_by_id;two_depth | 5 entities | Flame Graph | |
| entity_by_id;two_depth | 50 entities | Flame Graph | |
| entity_by_id;zero_depth | 1 entities | Flame Graph | |
| entity_by_id;zero_depth | 10 entities | Flame Graph | |
| entity_by_id;zero_depth | 25 entities | Flame Graph | |
| entity_by_id;zero_depth | 5 entities | Flame Graph | |
| entity_by_id;zero_depth | 50 entities | Flame Graph |
read_scaling_linkless
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| entity_by_id | 1 entities | Flame Graph | |
| entity_by_id | 10 entities | Flame Graph | |
| entity_by_id | 100 entities | Flame Graph | |
| entity_by_id | 1000 entities | Flame Graph | |
| entity_by_id | 10000 entities | Flame Graph |
representative_read_entity
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/block/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/book/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/building/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/organization/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/page/v/2
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/person/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/playlist/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/song/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/uk-address/v/1
|
Flame Graph |
representative_read_entity_type
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| get_entity_type_by_id | Account ID: bf5a9ef5-dc3b-43cf-a291-6210c0321eba
|
Flame Graph |
representative_read_multiple_entities
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| entity_by_property | traversal_paths=0 | 0 | |
| entity_by_property | traversal_paths=255 | 1,resolve_depths=inherit:1;values:255;properties:255;links:127;link_dests:126;type:true | |
| entity_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:0;links:0;link_dests:0;type:false | |
| entity_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:0;links:1;link_dests:0;type:true | |
| entity_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:2;links:1;link_dests:0;type:true | |
| entity_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:2;properties:2;links:1;link_dests:0;type:true | |
| link_by_source_by_property | traversal_paths=0 | 0 | |
| link_by_source_by_property | traversal_paths=255 | 1,resolve_depths=inherit:1;values:255;properties:255;links:127;link_dests:126;type:true | |
| link_by_source_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:0;links:0;link_dests:0;type:false | |
| link_by_source_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:0;links:1;link_dests:0;type:true | |
| link_by_source_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:2;links:1;link_dests:0;type:true | |
| link_by_source_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:2;properties:2;links:1;link_dests:0;type:true |
scenarios
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| full_test | query-limited | Flame Graph | |
| full_test | query-unlimited | Flame Graph | |
| linked_queries | query-limited | Flame Graph | |
| linked_queries | query-unlimited | Flame Graph |
PR SummaryMedium Risk Overview OpenAPI build now panics if operations document path/query/body/JSON responses without going through these extractors, if path/query shapes are not axum-readable, or if header/cookie parameters appear—enforcing consistent client-facing errors in the spec. Middleware/router: unsupported methods get a Dependencies: Reviewed by Cursor Bugbot for commit b2c86d6. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit b2c86d6. Configure here.

🌟 What is the purpose of this PR?
Clients of the public Graph API v1 receive its errors as problem documents, and the OpenAPI document lists the errors of each operation. Axum's extractors answered their rejections with a plain-text body that no operation documented: a body that is not JSON, a body not declared as
application/json, a body over the limit, and a path or query parameter that does not parse. A method a path does not serve was answered with an empty405.Json,PathandQueryinrest::extractnow reject with problem details and document these rejections on every operation that reads its request through them. A method a path does not serve is answered with a problem document, and axum still adds theAllowheader. Building the documentation checks that axum can read every path and query parameter an operation documents, and that these extractors read them.🔗 Related links
problematicaround per-operation public problems #9828 (BE-891), which this builds on🔍 What does this change?
rest::extractaddsJson,PathandQuery, which wrapaxum::Json,axum::extract::Pathandaxum_extra::extract::Query. Each rejects with aRejectionof its own problem set and documents that set on the operation throughOperationInput, so an operation lists these responses without further annotation.The variants answer these axum rejections:
JsonMalformedJson400JsonSyntaxErrorJsonUnreadableBody400UnknownBodyErrorJsonBodyTooLarge413LengthLimitErrorJsonUnsupportedMediaType415MissingJsonContentTypeJsonInvalidBody422JsonDataErrorPathMalformedPathParameter400PathRejectionwith a4xxstatusQueryMalformedQuery400FailedToDeserializeQueryStringEach variant has the type
about:blank, the reason phrase of RFC 9110 as its title and axum's text of the rejection as itsdetail. RFC 9110 renamed413and422toContent Too LargeandUnprocessable Content, and thehttpcrate still uses the older phrases. Each variant documents an example with adetail.JsonRejection,PathRejectionandQueryRejectionand the rejections they wrap are not exhaustive. A rejection an extractor does not name is logged and answered withInternalServerErrorfor a5xxstatus, and otherwise withUnreadableBody,MalformedPathParameterorMalformedQuery.Pathanswers withInternalServerErrorwhen the route and its handler disagree on the parameters, which axum rejects with a500.Queryreads the query string withserde_html_form, asaxum_extra::extract::Querydoes: a sequence collects every occurrence of its key, andkey=reads asNonefor anOptionfield. Axum's ownQueryrejects a sequence field and readskey=asSome(""), which fails for any type but a string.openapi::buildpanics for an operation whose documented path parameters are not exactly the placeholders of its path, or include one that is optional, a sequence or a map. It also panics for a query parameter that is a map or a sequence of anything but single values. It follows chains of references to component schemas, theallOfthat wraps a described reference, and the branches ofanyOfandoneOf. Axum fills path parameters by name, so a misnamed field would otherwise be a client error on every request, and it reads no map from a path segment or a query string.PathandQuerydocument these rules.Jsonis also the response body of the API. It answers with the status in its type,Json<T, const STATUS: u16 = 200>, and a check at compile time admits only success statuses that carry content. A body that fails to serialize is logged and answered withInternalServerError, which the operation documents.axum::Jsonanswers it with plain text, and a status set through a tuple would replace the500, so a response states its status in its type. The caller operations answer through it.Json,PathandQuerymark each operation with the part of the request or response they read or write.openapi::buildpanics for a documented path parameter, query parameter, request body or JSON response without that mark, and for any header or cookie parameter, then removes the marks from the document. Axum's own extractors,BytesandFormanswer a rejection with plain text, and no extractor reads a header or a cookie with problem details yet.Jsonremoves the description that aide copies from the body's schema onto the request body, since documentation viewers show both.The router answers a method a path does not serve with an
about:blankproblem document of status405, and axum adds theAllowheader. For the legacy routes and every API, the answer comes after authentication and the caller budget, since the route layers wrap it. The documentation routes answer it as well, and every such answer draws on the address budget. The legacy routes'405had an empty body. A client receives it only for a method the OpenAPI document does not list for the path, so no operation documents it.The router answers a request whose handling panics with
InternalServerErroras a problem document, where the connection used to close without an answer. Sentry's panic hook reports the panic. The health probe answers a method it does not serve with the405problem document, and still carries no budget, span or extension.problematicmoves from the dev-dependencies ofhash-graph-apito its dependencies, with theaideandaxumfeatures.axum-extrais a new dependency with only itsqueryfeature, and aide documents itsQuerythroughaxum-extra-query.tower-httpgains itscatch-panicfeature.No operation reads its request through the extractors yet, so outside tests
rest::extractexpects the lints for unused code. The entity and type operations will read their requests through them.A test API with one
echooperation reads a path parameter, a query string and a JSON body through the extractors. Its OpenAPI document is snapshotted insrc/rest/extract/snapshots/.Pre-Merge Checklist 🚀
🚢 Has this modified a publishable library?
This PR:
📜 Does this require a change to the docs?
The changes in this PR:
🕸️ Does this require a change to the Turbo Graph?
The changes in this PR:
about:blankuntil the variants get type URIs in a follow-up. Until then, a client tells the variants at400apart only by theirdetail, and the documentation labels them by their description and numbers their examplesabout:blank,about:blank (2)and so on. A client that handles these responses by status is unaffected when the variants get types.response_with::<415, …>, replaces the extractor's variants at that status without an error. Aide applies the transforms of an operation after it infers the responses of its inputs, and the transform replaces the response at that status. No operation does this today. A check would have to run after the transforms, when the replaced variants are no longer known.httpcrate, so the components for413and422arePayloadTooLargeandUnprocessableEntity, while their titles follow RFC 9110. The names appear only as keys undercomponents/responsesand in$refs, and documentation viewers such as Scalar show the titles.405and for a panic.PathandQuerydocument the rule instead.🐾 Next steps
Location, responses without content, and a check that every operation documents one. Aide documents no response for a tuple, a bareStatusCodeor a rawResponse.🛡 What tests cover this?
rest::extract::testschecks that a JSON response answers with the status of its type, that a body that fails to serialize answers a500problem document even for a201, that an operation documents both, and which statuses a JSON response admits.rest::extract::testssends each rejection through a router. One case checks the response status, the media typeapplication/problem+json, and the membersstatus,typeand a non-emptydetail. The others check the status for a body without the JSON content type, a mistyped body, a body over the limit and a body whose stream breaks off, thedetailfor an unparsable path or query parameter, and the genericdetailofInternalServerErrorwhen a handler reads more path parameters than its route names. A query string with a repeated key reaches the handler as a sequence.echoAPI.rest::middleware::testschecks the405problem document and itsAllowheader on an API route, the problem document on a legacy route and on a documentation route, that the legacy routes and the APIs authenticate before they answer405, that the answer draws on the address and the caller budget, and that a panicking handler answers the500problem document.rest::router::testschecks the405problem document of the health probe.rest::openapi::testsbuilds an API for each rule of the parameter check: a tuple, a misplaced, an optional, a sequence, a struct and a newtype-around-a-struct path parameter panic, and a newtype around a single value passes; a struct, an enum of structs and a sequence of structs as query parameter panic, and single values with a sequence of them pass. Reading through axum'sPathoraxum-extra'sQuery, reading aBytesbody, answering withaxum::Jsonand documenting a header or a cookie parameter panic as well.❓ How to test this?
cargo nextest run -p hash-graph-api --all-featureslibs/@local/graph/api/src/rest/extract/snapshots/extract-example.snap.jsonin a viewer such as Scalar