Skip to content

BE-879: Return Problem Details for public API request rejections - #9841

Open
TimDiekmann wants to merge 11 commits into
mainfrom
t/be-879-return-problem-details-for-public-api-request-rejections
Open

TimDiekmann wants to merge 11 commits into
mainfrom
t/be-879-return-problem-details-for-public-api-request-rejections

Conversation

@TimDiekmann

@TimDiekmann TimDiekmann commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

🌟 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 empty 405.

Json, Path and Query in rest::extract now 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 the Allow header. Building the documentation checks that axum can read every path and query parameter an operation documents, and that these extractors read them.

🔗 Related links

🔍 What does this change?

  • rest::extract adds Json, Path and Query, which wrap axum::Json, axum::extract::Path and axum_extra::extract::Query. Each rejects with a Rejection of its own problem set and documents that set on the operation through OperationInput, so an operation lists these responses without further annotation.

  • The variants answer these axum rejections:

    Extractor Variant Status axum rejection
    Json MalformedJson 400 JsonSyntaxError
    Json UnreadableBody 400 UnknownBodyError
    Json BodyTooLarge 413 LengthLimitError
    Json UnsupportedMediaType 415 MissingJsonContentType
    Json InvalidBody 422 JsonDataError
    Path MalformedPathParameter 400 a PathRejection with a 4xx status
    Query MalformedQuery 400 FailedToDeserializeQueryString
  • Each variant has the type about:blank, the reason phrase of RFC 9110 as its title and axum's text of the rejection as its detail. RFC 9110 renamed 413 and 422 to Content Too Large and Unprocessable Content, and the http crate still uses the older phrases. Each variant documents an example with a detail.

  • JsonRejection, PathRejection and QueryRejection and the rejections they wrap are not exhaustive. A rejection an extractor does not name is logged and answered with InternalServerError for a 5xx status, and otherwise with UnreadableBody, MalformedPathParameter or MalformedQuery. Path answers with InternalServerError when the route and its handler disagree on the parameters, which axum rejects with a 500.

  • Query reads the query string with serde_html_form, as axum_extra::extract::Query does: a sequence collects every occurrence of its key, and key= reads as None for an Option field. Axum's own Query rejects a sequence field and reads key= as Some(""), which fails for any type but a string.

  • openapi::build panics 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, the allOf that wraps a described reference, and the branches of anyOf and oneOf. 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. Path and Query document these rules.

  • Json is 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 with InternalServerError, which the operation documents. axum::Json answers it with plain text, and a status set through a tuple would replace the 500, so a response states its status in its type. The caller operations answer through it.

  • Json, Path and Query mark each operation with the part of the request or response they read or write. openapi::build panics 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, Bytes and Form answer a rejection with plain text, and no extractor reads a header or a cookie with problem details yet.

  • Json removes 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:blank problem document of status 405, and axum adds the Allow header. 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' 405 had 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 InternalServerError as 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 the 405 problem document, and still carries no budget, span or extension.

  • problematic moves from the dev-dependencies of hash-graph-api to its dependencies, with the aide and axum features. axum-extra is a new dependency with only its query feature, and aide documents its Query through axum-extra-query. tower-http gains its catch-panic feature.

  • No operation reads its request through the extractors yet, so outside tests rest::extract expects the lints for unused code. The entity and type operations will read their requests through them.

  • A test API with one echo operation reads a path parameter, a query string and a JSON body through the extractors. Its OpenAPI document is snapshotted in src/rest/extract/snapshots/.

Pre-Merge Checklist 🚀

🚢 Has this modified a publishable library?

This PR:

  • does not modify any publishable blocks or libraries, or modifications do not need publishing

📜 Does this require a change to the docs?

The changes in this PR:

  • are internal and do not require a docs change

🕸️ Does this require a change to the Turbo Graph?

The changes in this PR:

  • do not affect the execution graph

⚠️ Known issues

  • Every variant has the type about:blank until the variants get type URIs in a follow-up. Until then, a client tells the variants at 400 apart only by their detail, and the documentation labels them by their description and numbers their examples about:blank, about:blank (2) and so on. A client that handles these responses by status is unaffected when the variants get types.
  • An operation that documents a status an extractor documents through its own transform, such as 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.
  • The response components are named after the reason phrases of the http crate, so the components for 413 and 422 are PayloadTooLarge and UnprocessableEntity, while their titles follow RFC 9110. The names appear only as keys under components/responses and in $refs, and documentation viewers such as Scalar show the titles.
  • The legacy handlers still answer their own errors in the hash-status format, and so does the admin server. The legacy routes move to the new APIs, and BE-353 covers their errors; this PR gives them only the problem documents for 405 and for a panic.
  • A path or query struct cannot flatten another struct into itself: serde hands flattened fields their values as strings, which only a string field accepts. The schema of a flattened struct looks like any other, so building the documentation cannot catch it, and Path and Query document the rule instead.

🐾 Next steps

  • The problem variants get type URIs.
  • The entity and type operations of v1 read their requests through these extractors.
  • Success responses get their own design: their statuses, headers such as Location, responses without content, and a check that every operation documents one. Aide documents no response for a tuple, a bare StatusCode or a raw Response.

🛡 What tests cover this?

  • rest::extract::tests checks that a JSON response answers with the status of its type, that a body that fails to serialize answers a 500 problem document even for a 201, that an operation documents both, and which statuses a JSON response admits.
  • rest::extract::tests sends each rejection through a router. One case checks the response status, the media type application/problem+json, and the members status, type and a non-empty detail. 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, the detail for an unparsable path or query parameter, and the generic detail of InternalServerError when a handler reads more path parameters than its route names. A query string with a repeated key reaches the handler as a sequence.
  • The same module checks that each extractor documents its statuses as problem documents, and snapshots the OpenAPI document of the echo API.
  • rest::middleware::tests checks the 405 problem document and its Allow header 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 answer 405, that the answer draws on the address and the caller budget, and that a panicking handler answers the 500 problem document. rest::router::tests checks the 405 problem document of the health probe.
  • rest::openapi::tests builds 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's Path or axum-extra's Query, reading a Bytes body, answering with axum::Json and documenting a header or a cookie parameter panic as well.
  • The existing OpenAPI snapshots of the Graph API are unchanged.

❓ How to test this?

  1. Run cargo nextest run -p hash-graph-api --all-features
  2. Open libs/@local/graph/api/src/rest/extract/snapshots/extract-example.snap.json in a viewer such as Scalar

@TimDiekmann TimDiekmann self-assigned this Sep 25, 2026
@vercel

vercel Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
petrinaut Ready Ready Preview Sep 26, 2026 3:11pm UTC
3 Skipped Deployments
Project Deployment Actions Updated
hash Ignored Ignored Preview Sep 26, 2026 3:11pm UTC
hashdotdesign-tokens Ignored Ignored Preview Sep 26, 2026 3:11pm UTC
petrinaut-docs Ignored Ignored Preview Sep 26, 2026 3:11pm UTC

Request Review

@codecov

codecov Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.23867% with 29 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.95%. Comparing base (48e1582) to head (b2c86d6).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
libs/@local/graph/api/src/rest/openapi/mod.rs 92.80% 5 Missing and 5 partials ⚠️
libs/@local/graph/api/src/rest/extract/json.rs 90.10% 8 Missing and 1 partial ⚠️
libs/@local/graph/api/src/rest/extract/query.rs 80.00% 5 Missing ⚠️
libs/@local/graph/api/src/rest/extract/path.rs 88.00% 2 Missing and 1 partial ⚠️
libs/@local/graph/api/src/rest/router.rs 92.30% 2 Missing ⚠️
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     
Flag Coverage Δ
hash-graph 14.11% <ø> (+1.78%) ⬆️
hash-graph-api 36.29% <91.23%> (+4.08%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@codspeed

codspeed Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

⚠️ 6 benchmarks measured no execution time

Nothing ran under measurement, usually because the compiler removed the code under test. These results are not comparable, so they count as unchanged.

Preventing compiler optimizations

✅ 98 untouched benchmarks

Performance Changes

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)

Open in CodSpeed

… problem details when they fail to serialize
@github-actions

Copy link
Copy Markdown
Contributor

Benchmark results

hash-graph-benches – Integrations

policy_resolution_large

Function Value Mean Flame graphs
resolve_policies_for_actor user: empty, selectivity: high, policies: 2002 $$27.7 \mathrm{ms} \pm 341 \mathrm{μs}\left({\color{lightgreen}-7.277 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: low, policies: 1 $$3.54 \mathrm{ms} \pm 35.4 \mathrm{μs}\left({\color{lightgreen}-9.384 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: medium, policies: 1002 $$12.5 \mathrm{ms} \pm 141 \mathrm{μs}\left({\color{lightgreen}-16.722 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: high, policies: 3314 $$44.8 \mathrm{ms} \pm 556 \mathrm{μs}\left({\color{lightgreen}-7.491 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: low, policies: 1 $$14.6 \mathrm{ms} \pm 141 \mathrm{μs}\left({\color{lightgreen}-13.582 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: medium, policies: 1527 $$24.5 \mathrm{ms} \pm 258 \mathrm{μs}\left({\color{lightgreen}-12.098 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: high, policies: 2078 $$28.7 \mathrm{ms} \pm 241 \mathrm{μs}\left({\color{lightgreen}-8.039 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: low, policies: 1 $$3.84 \mathrm{ms} \pm 25.6 \mathrm{μs}\left({\color{lightgreen}-11.479 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: medium, policies: 1033 $$13.9 \mathrm{ms} \pm 144 \mathrm{μs}\left({\color{lightgreen}-14.070 \mathrm{\%}}\right) $$ Flame Graph

policy_resolution_medium

Function Value Mean Flame graphs
resolve_policies_for_actor user: empty, selectivity: high, policies: 102 $$4.25 \mathrm{ms} \pm 45.6 \mathrm{μs}\left({\color{gray}1.08 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: low, policies: 1 $$3.33 \mathrm{ms} \pm 31.7 \mathrm{μs}\left({\color{gray}-3.144 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: medium, policies: 52 $$3.74 \mathrm{ms} \pm 37.0 \mathrm{μs}\left({\color{gray}-2.196 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: high, policies: 269 $$5.81 \mathrm{ms} \pm 55.2 \mathrm{μs}\left({\color{gray}-2.606 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: low, policies: 1 $$3.80 \mathrm{ms} \pm 32.7 \mathrm{μs}\left({\color{lightgreen}-5.922 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: medium, policies: 108 $$4.68 \mathrm{ms} \pm 42.6 \mathrm{μs}\left({\color{gray}-1.747 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: high, policies: 133 $$4.97 \mathrm{ms} \pm 50.8 \mathrm{μs}\left({\color{gray}-2.423 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: low, policies: 1 $$3.88 \mathrm{ms} \pm 36.1 \mathrm{μs}\left({\color{gray}-1.207 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: medium, policies: 63 $$4.71 \mathrm{ms} \pm 59.6 \mathrm{μs}\left({\color{gray}1.45 \mathrm{\%}}\right) $$ Flame Graph

policy_resolution_none

Function Value Mean Flame graphs
resolve_policies_for_actor user: empty, selectivity: high, policies: 2 $$2.76 \mathrm{ms} \pm 22.0 \mathrm{μs}\left({\color{lightgreen}-11.081 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: low, policies: 1 $$2.74 \mathrm{ms} \pm 23.9 \mathrm{μs}\left({\color{lightgreen}-11.605 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: medium, policies: 2 $$2.87 \mathrm{ms} \pm 29.9 \mathrm{μs}\left({\color{lightgreen}-12.275 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: high, policies: 8 $$3.11 \mathrm{ms} \pm 23.3 \mathrm{μs}\left({\color{lightgreen}-10.605 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: low, policies: 1 $$2.91 \mathrm{ms} \pm 25.5 \mathrm{μs}\left({\color{lightgreen}-9.975 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: medium, policies: 3 $$3.19 \mathrm{ms} \pm 20.3 \mathrm{μs}\left({\color{lightgreen}-10.099 \mathrm{\%}}\right) $$ Flame Graph

policy_resolution_small

Function Value Mean Flame graphs
resolve_policies_for_actor user: empty, selectivity: high, policies: 52 $$3.23 \mathrm{ms} \pm 26.0 \mathrm{μs}\left({\color{lightgreen}-7.776 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: low, policies: 1 $$2.93 \mathrm{ms} \pm 25.6 \mathrm{μs}\left({\color{lightgreen}-6.737 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: medium, policies: 26 $$3.10 \mathrm{ms} \pm 27.7 \mathrm{μs}\left({\color{lightgreen}-6.740 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: high, policies: 94 $$3.76 \mathrm{ms} \pm 35.5 \mathrm{μs}\left({\color{lightgreen}-6.930 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: low, policies: 1 $$3.23 \mathrm{ms} \pm 31.5 \mathrm{μs}\left({\color{lightgreen}-7.144 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: medium, policies: 27 $$3.57 \mathrm{ms} \pm 33.2 \mathrm{μs}\left({\color{gray}-4.636 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: high, policies: 66 $$3.55 \mathrm{ms} \pm 29.3 \mathrm{μs}\left({\color{lightgreen}-9.418 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: low, policies: 1 $$3.17 \mathrm{ms} \pm 21.9 \mathrm{μs}\left({\color{lightgreen}-8.446 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: medium, policies: 29 $$3.46 \mathrm{ms} \pm 22.1 \mathrm{μs}\left({\color{lightgreen}-9.645 \mathrm{\%}}\right) $$ Flame Graph

read_scaling_complete

Function Value Mean Flame graphs
entity_by_id;one_depth 1 entities $$34.1 \mathrm{ms} \pm 277 \mathrm{μs}\left({\color{lightgreen}-5.052 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;one_depth 10 entities $$73.4 \mathrm{ms} \pm 627 \mathrm{μs}\left({\color{gray}-4.181 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;one_depth 25 entities $$35.8 \mathrm{ms} \pm 313 \mathrm{μs}\left({\color{lightgreen}-6.045 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;one_depth 5 entities $$42.0 \mathrm{ms} \pm 353 \mathrm{μs}\left({\color{gray}-2.979 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;one_depth 50 entities $$44.4 \mathrm{ms} \pm 390 \mathrm{μs}\left({\color{gray}-2.354 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 1 entities $$35.0 \mathrm{ms} \pm 375 \mathrm{μs}\left({\color{gray}-1.912 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 10 entities $$433 \mathrm{ms} \pm 1.67 \mathrm{ms}\left({\color{gray}0.490 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 25 entities $$95.7 \mathrm{ms} \pm 962 \mathrm{μs}\left({\color{gray}-3.020 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 5 entities $$82.3 \mathrm{ms} \pm 505 \mathrm{μs}\left({\color{gray}-2.602 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 50 entities $$314 \mathrm{ms} \pm 1.59 \mathrm{ms}\left({\color{red}5.51 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 1 entities $$11.0 \mathrm{ms} \pm 96.6 \mathrm{μs}\left({\color{gray}-4.960 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 10 entities $$11.1 \mathrm{ms} \pm 84.7 \mathrm{μs}\left({\color{gray}-4.870 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 25 entities $$11.1 \mathrm{ms} \pm 79.4 \mathrm{μs}\left({\color{gray}-4.613 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 5 entities $$11.1 \mathrm{ms} \pm 98.7 \mathrm{μs}\left({\color{gray}-4.424 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 50 entities $$10.7 \mathrm{ms} \pm 75.3 \mathrm{μs}\left({\color{lightgreen}-9.142 \mathrm{\%}}\right) $$ Flame Graph

read_scaling_linkless

Function Value Mean Flame graphs
entity_by_id 1 entities $$11.0 \mathrm{ms} \pm 86.6 \mathrm{μs}\left({\color{gray}-3.066 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id 10 entities $$10.9 \mathrm{ms} \pm 116 \mathrm{μs}\left({\color{gray}-4.337 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id 100 entities $$10.9 \mathrm{ms} \pm 92.7 \mathrm{μs}\left({\color{lightgreen}-5.463 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id 1000 entities $$10.6 \mathrm{ms} \pm 83.7 \mathrm{μs}\left({\color{lightgreen}-9.208 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id 10000 entities $$11.0 \mathrm{ms} \pm 92.0 \mathrm{μs}\left({\color{lightgreen}-8.857 \mathrm{\%}}\right) $$ 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 $$10.8 \mathrm{ms} \pm 75.4 \mathrm{μs}\left({\color{lightgreen}-10.570 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/book/v/1 $$10.9 \mathrm{ms} \pm 84.1 \mathrm{μs}\left({\color{lightgreen}-10.401 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/building/v/1 $$10.8 \mathrm{ms} \pm 82.3 \mathrm{μs}\left({\color{lightgreen}-10.688 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/organization/v/1 $$10.9 \mathrm{ms} \pm 82.6 \mathrm{μs}\left({\color{lightgreen}-10.230 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/page/v/2 $$10.9 \mathrm{ms} \pm 84.9 \mathrm{μs}\left({\color{lightgreen}-11.098 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/person/v/1 $$10.9 \mathrm{ms} \pm 84.1 \mathrm{μs}\left({\color{lightgreen}-10.988 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/playlist/v/1 $$10.8 \mathrm{ms} \pm 81.5 \mathrm{μs}\left({\color{lightgreen}-11.449 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/song/v/1 $$11.0 \mathrm{ms} \pm 77.2 \mathrm{μs}\left({\color{lightgreen}-9.994 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/uk-address/v/1 $$11.1 \mathrm{ms} \pm 78.5 \mathrm{μs}\left({\color{lightgreen}-9.021 \mathrm{\%}}\right) $$ Flame Graph

representative_read_entity_type

Function Value Mean Flame graphs
get_entity_type_by_id Account ID: bf5a9ef5-dc3b-43cf-a291-6210c0321eba $$8.25 \mathrm{ms} \pm 64.4 \mathrm{μs}\left({\color{gray}-2.043 \mathrm{\%}}\right) $$ Flame Graph

representative_read_multiple_entities

Function Value Mean Flame graphs
entity_by_property traversal_paths=0 0 $$55.5 \mathrm{ms} \pm 626 \mathrm{μs}\left({\color{lightgreen}-10.864 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=255 1,resolve_depths=inherit:1;values:255;properties:255;links:127;link_dests:126;type:true $$109 \mathrm{ms} \pm 714 \mathrm{μs}\left({\color{gray}-4.715 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:0;links:0;link_dests:0;type:false $$60.7 \mathrm{ms} \pm 470 \mathrm{μs}\left({\color{lightgreen}-13.055 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:0;links:1;link_dests:0;type:true $$70.9 \mathrm{ms} \pm 975 \mathrm{μs}\left({\color{lightgreen}-10.519 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:2;links:1;link_dests:0;type:true $$79.1 \mathrm{ms} \pm 660 \mathrm{μs}\left({\color{lightgreen}-9.143 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:2;properties:2;links:1;link_dests:0;type:true $$86.9 \mathrm{ms} \pm 741 \mathrm{μs}\left({\color{lightgreen}-8.546 \mathrm{\%}}\right) $$
link_by_source_by_property traversal_paths=0 0 $$44.9 \mathrm{ms} \pm 385 \mathrm{μs}\left({\color{lightgreen}-9.725 \mathrm{\%}}\right) $$
link_by_source_by_property traversal_paths=255 1,resolve_depths=inherit:1;values:255;properties:255;links:127;link_dests:126;type:true $$73.0 \mathrm{ms} \pm 538 \mathrm{μs}\left({\color{lightgreen}-5.944 \mathrm{\%}}\right) $$
link_by_source_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:0;links:0;link_dests:0;type:false $$51.2 \mathrm{ms} \pm 527 \mathrm{μs}\left({\color{lightgreen}-11.285 \mathrm{\%}}\right) $$
link_by_source_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:0;links:1;link_dests:0;type:true $$62.0 \mathrm{ms} \pm 600 \mathrm{μs}\left({\color{lightgreen}-5.543 \mathrm{\%}}\right) $$
link_by_source_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:2;links:1;link_dests:0;type:true $$63.9 \mathrm{ms} \pm 659 \mathrm{μs}\left({\color{lightgreen}-6.956 \mathrm{\%}}\right) $$
link_by_source_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:2;properties:2;links:1;link_dests:0;type:true $$63.7 \mathrm{ms} \pm 492 \mathrm{μs}\left({\color{lightgreen}-5.746 \mathrm{\%}}\right) $$

scenarios

Function Value Mean Flame graphs
full_test query-limited $$113 \mathrm{ms} \pm 742 \mathrm{μs}\left({\color{gray}-1.185 \mathrm{\%}}\right) $$ Flame Graph
full_test query-unlimited $$125 \mathrm{ms} \pm 738 \mathrm{μs}\left({\color{gray}-3.290 \mathrm{\%}}\right) $$ Flame Graph
linked_queries query-limited $$24.4 \mathrm{ms} \pm 329 \mathrm{μs}\left({\color{gray}-4.864 \mathrm{\%}}\right) $$ Flame Graph
linked_queries query-unlimited $$504 \mathrm{ms} \pm 1.94 \mathrm{ms}\left({\color{lightgreen}-5.045 \mathrm{\%}}\right) $$ Flame Graph

@TimDiekmann
TimDiekmann marked this pull request as ready for review September 26, 2026 16:31
Copilot AI balanced review requested due to automatic review settings September 26, 2026 16:31
@cursor

cursor Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes default HTTP error bodies and status handling for malformed requests, 405, and panics across the Graph API router; behavior is well-tested but affects all clients once routes adopt the extractors.

Overview
Adds rest::extract with Json, Path, and Query wrappers around axum / axum-extra that answer rejections as application/problem+json (RFC 7807-style) instead of plain text, and wire those failure modes into OpenAPI via problematic + aide. Json also serves as a typed response helper (Json<T, STATUS>) that returns 500 problem details when serialization fails.

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 405 problem document (with Allow), handler panics become 500 problem details via CatchPanicLayer, and the health probe shares the same 405 behavior. caller responses already use extract::Json.

Dependencies: axum-extra (query), problematic promoted to a runtime dep, tower-http catch-panic.

Reviewed by Cursor Bugbot for commit b2c86d6. Bugbot is set up for automated code reviews on this repo. Configure here.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread libs/@local/graph/api/src/rest/openapi/mod.rs
@TimDiekmann
TimDiekmann added this pull request to stack #9848 September 27, 2026 11:36
@TimDiekmann
TimDiekmann requested a review from a team September 28, 2026 07:29

This branch was successfully deployed

5 active (3 outdated) deployments
Preview – petrinaut — b2c86d6c Deployed Sep 26, 2026 by vercel[bot]
pull-request — b2c86d6c Deployed Sep 26, 2026 by TimDiekmann via Sourcemaps (@apps/hash-integration-worker) #37445
Preview – petrinaut-docs — c1376959 Deployed Sep 26, 2026 by vercel[bot]
Preview – hash — c1376959 Deployed Sep 26, 2026 by vercel[bot]
Preview – hashdotdesign-tokens — c1376959 Deployed Sep 26, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/deps Relates to third-party dependencies (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > backend Owned by the @backend team

Development

Successfully merging this pull request may close these issues.

2 participants