-
Notifications
You must be signed in to change notification settings - Fork 404
Speed up MAS token introspection #18357
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
Changes from 5 commits
a20a0bc
7254716
95f3b24
34c65ee
44a301a
27da4ec
61b7ed0
236d7a7
59473e3
a8a34bb
d5845d5
540b460
48d2217
f501987
f2bd5b0
6617341
7283b6b
25c05d4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Increase performance of introspecting access tokens when using delegated auth. |
erikjohnston marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,196 @@ | ||
| /* | ||
| * This file is licensed under the Affero General Public License (AGPL) version 3. | ||
| * | ||
| * Copyright (C) 2025 New Vector, Ltd | ||
| * | ||
| * This program is free software: you can redistribute it and/or modify | ||
| * it under the terms of the GNU Affero General Public License as | ||
| * published by the Free Software Foundation, either version 3 of the | ||
| * License, or (at your option) any later version. | ||
| * | ||
| * See the GNU Affero General Public License for more details: | ||
| * <https://www.gnu.org/licenses/agpl-3.0.html>. | ||
| */ | ||
|
|
||
| use std::{collections::HashMap, future::Future, panic::AssertUnwindSafe}; | ||
|
|
||
| use anyhow::Context; | ||
| use futures::{FutureExt, TryStreamExt}; | ||
| use lazy_static::lazy_static; | ||
| use pyo3::{exceptions::PyException, prelude::*, types::PyString}; | ||
| use reqwest::RequestBuilder; | ||
| use tokio::runtime::Runtime; | ||
|
|
||
| use crate::errors::HttpResponseException; | ||
|
|
||
| lazy_static! { | ||
erikjohnston marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| static ref RUNTIME: Runtime = tokio::runtime::Builder::new_multi_thread() | ||
| .worker_threads(4) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We'll likely want to have that configurable at some point, but this is probably a sane default. |
||
| .enable_all() | ||
| .build() | ||
| .unwrap(); | ||
| static ref DEFERRED_CLASS: PyObject = { | ||
| Python::with_gil(|py| { | ||
| py.import("twisted.internet.defer") | ||
| .expect("module 'twisted.internet.defer' should be importable") | ||
|
||
| .getattr("Deferred") | ||
| .expect("module 'twisted.internet.defer' should have a 'Deferred' class") | ||
| .unbind() | ||
| }) | ||
| }; | ||
| } | ||
|
|
||
| /// Called when registering modules with python. | ||
| pub fn register_module(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { | ||
| let child_module: Bound<'_, PyModule> = PyModule::new(py, "http_client")?; | ||
| child_module.add_class::<HttpClient>()?; | ||
|
|
||
| m.add_submodule(&child_module)?; | ||
|
|
||
| // We need to manually add the module to sys.modules to make `from | ||
| // synapse.synapse_rust import acl` work. | ||
| py.import("sys")? | ||
| .getattr("modules")? | ||
| .set_item("synapse.synapse_rust.http_client", child_module)?; | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| #[pyclass] | ||
| #[derive(Clone)] | ||
| struct HttpClient { | ||
| client: reqwest::Client, | ||
| } | ||
|
|
||
| #[pymethods] | ||
| impl HttpClient { | ||
| #[new] | ||
| pub fn py_new() -> HttpClient { | ||
| HttpClient { | ||
| client: reqwest::Client::new(), | ||
erikjohnston marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
|
|
||
| pub fn get<'a>( | ||
| &self, | ||
| py: Python<'a>, | ||
| url: String, | ||
| response_limit: usize, | ||
| ) -> PyResult<Bound<'a, PyAny>> { | ||
| self.send_request(py, self.client.get(url), response_limit) | ||
| } | ||
|
|
||
| pub fn post<'a>( | ||
| &self, | ||
| py: Python<'a>, | ||
| url: String, | ||
| response_limit: usize, | ||
| headers: HashMap<String, String>, | ||
| request_body: String, | ||
| ) -> PyResult<Bound<'a, PyAny>> { | ||
| let mut builder = self.client.post(url); | ||
| for (name, value) in headers { | ||
| builder = builder.header(name, value); | ||
| } | ||
| builder = builder.body(request_body); | ||
|
|
||
| self.send_request(py, builder, response_limit) | ||
| } | ||
| } | ||
|
|
||
| impl HttpClient { | ||
| fn send_request<'a>( | ||
| &self, | ||
| py: Python<'a>, | ||
| builder: RequestBuilder, | ||
| response_limit: usize, | ||
| ) -> PyResult<Bound<'a, PyAny>> { | ||
| create_deferred(py, async move { | ||
| let response = builder.send().await.context("sending request")?; | ||
|
|
||
| let status = response.status(); | ||
|
|
||
| let mut stream = response.bytes_stream(); | ||
| let mut buffer = Vec::new(); | ||
| while let Some(chunk) = stream.try_next().await.context("reading body")? { | ||
| if buffer.len() + chunk.len() > response_limit { | ||
| Err(anyhow::anyhow!("Response size too large"))?; | ||
| } | ||
|
|
||
| buffer.extend_from_slice(&chunk); | ||
| } | ||
|
Comment on lines
+137
to
+145
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe you can achieve the same with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, originally tried that but it messed up the errors (one of the exceptions stops implementing std Error). Given how straight forwards this is it felt easier than faffing with error types. |
||
|
|
||
| if !status.is_success() { | ||
| return Err(HttpResponseException::new(status, buffer)); | ||
| } | ||
|
|
||
| let r = Python::with_gil(|py| buffer.into_pyobject(py).map(|o| o.unbind()))?; | ||
|
|
||
| Ok(r) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| /// Creates a twisted deferred from the given future, spawning the task on the | ||
| /// tokio runtime. | ||
| /// | ||
| /// Does not handle deferred cancellation or contextvars. | ||
| fn create_deferred<F, O>(py: Python, fut: F) -> PyResult<Bound<'_, PyAny>> | ||
| where | ||
| F: Future<Output = PyResult<O>> + Send + 'static, | ||
| for<'a> O: IntoPyObject<'a>, | ||
| { | ||
| let deferred = DEFERRED_CLASS.bind(py).call0()?; | ||
| let deferred_callback = deferred.getattr("callback")?.unbind(); | ||
| let deferred_errback = deferred.getattr("errback")?.unbind(); | ||
|
|
||
| let reactor = py.import("twisted.internet")?.getattr("reactor")?.unbind(); | ||
|
|
||
| RUNTIME.spawn(async move { | ||
| // TODO: Is it safe to assert unwind safety here? I think so, as we | ||
| // don't use anything that could be tainted by the panic afterwards. | ||
| // Note that `.spawn(..)` asserts unwind safety on the future too. | ||
| let res = AssertUnwindSafe(fut).catch_unwind().await; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. alternatively, spawn the future on the runtime, await on the handle, and it will give you an Err with the panic in case it panics There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could, though then you end up spawning two tasks per function, rather than one. Probably not a huge deal, but feels a bit bleurgh There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Spawning is cheap, let's do that instead please. Also we'll need to spawn a separate task anyway if we want to properly support cancel There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you expand on why you want to use new tasks please? I don't see the benefit of spawning a new task to just wait on it, semantically you end up with a bunch of tasks with different IDs all for the same work. In future, if we wanted to start tracking tasks and e.g. their resource usage then using multiple tasks makes that more complicated. I also don't think we need a separate task for cancellation necessarily. You can change this line to do a select on both There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not really confortable with Anyway, even though AssertUnwindSafe smells like a bad thing waiting to happen, I won't block this PR further because of this if you're not convinced that spawning is fine There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I think we would do this at the task level, we'd have a task-local context that records resource usage, so you could e.g. wrap the top-level future that records the resource consumption of
Bear in mind that this is exactly what spawning a task does in tokio, so its hard to see how it would be fine for that and not here. |
||
|
|
||
| Python::with_gil(move |py| { | ||
| // Flatten the panic into standard python error | ||
| let res = match res { | ||
| Ok(r) => r, | ||
| Err(panic_err) => { | ||
| let panic_message = get_panic_message(&panic_err); | ||
| Err(PyException::new_err( | ||
| PyString::new(py, panic_message).unbind(), | ||
| )) | ||
| } | ||
| }; | ||
|
|
||
| // Send the result to the deferred, via `.callback(..)` or `.errback(..)` | ||
| match res { | ||
| Ok(obj) => { | ||
| reactor | ||
| .call_method(py, "callFromThread", (deferred_callback, obj), None) | ||
| .expect("callFromThread should not fail"); // There's nothing we can really do with errors here | ||
sandhose marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| Err(err) => { | ||
| reactor | ||
| .call_method(py, "callFromThread", (deferred_errback, err), None) | ||
| .expect("callFromThread should not fail"); // There's nothing we can really do with errors here | ||
| } | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| Ok(deferred) | ||
| } | ||
|
|
||
| /// Try and get the panic message out of the | ||
| fn get_panic_message<'a>(panic_err: &'a (dyn std::any::Any + Send + 'static)) -> &'a str { | ||
| // Apparently this is how you extract the panic message from a panic | ||
| if let Some(str_slice) = panic_err.downcast_ref::<&str>() { | ||
| str_slice | ||
| } else if let Some(string) = panic_err.downcast_ref::<String>() { | ||
| string | ||
| } else { | ||
| "unknown error" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| # This file is licensed under the Affero General Public License (AGPL) version 3. | ||
| # | ||
| # Copyright (C) 2025 New Vector, Ltd | ||
| # | ||
| # This program is free software: you can redistribute it and/or modify | ||
| # it under the terms of the GNU Affero General Public License as | ||
| # published by the Free Software Foundation, either version 3 of the | ||
| # License, or (at your option) any later version. | ||
| # | ||
| # See the GNU Affero General Public License for more details: | ||
| # <https://www.gnu.org/licenses/agpl-3.0.html>. | ||
|
|
||
| from typing import Mapping | ||
|
|
||
| class HttpClient: | ||
| def __init__(self) -> None: ... | ||
| async def get(self, url: str, response_limit: int) -> bytes: ... | ||
erikjohnston marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| async def post( | ||
| self, | ||
| url: str, | ||
| response_limit: int, | ||
| headers: Mapping[str, str], | ||
| request_body: str, | ||
| ) -> bytes: ... | ||
Uh oh!
There was an error while loading. Please reload this page.