Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions crates/fmp-py/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,30 @@ The rules:
`FMP_BASE_URL` only replaces the base URL; the `token` and `FMP_API_KEY`
rules above are unchanged.

To read the proxy's cache status and remaining daily budget on a success,
pass `on_response`. It is called as `on_response(endpoint_id, status,
headers)` after every successful call, from any namespace:

```python
from fmp import FmpClient


def on_response(endpoint_id: str, status: int, headers: dict[str, str]) -> None:
print(endpoint_id, headers.get("x-proxy-cache"), headers.get("x-proxy-daily-remaining"))


client = FmpClient(on_response=on_response)
rows = client.quote.short("AAPL") # prints: quote-short HIT 41
```

- `headers` has only the allowlisted headers (`retry-after`, `x-proxy-*`,
`x-ratelimit-*`), lowercase and redacted; a repeated name keeps its first
value, like an error's `headers`, and it is `{}` when none were sent.
- It runs on the calling thread with the GIL, before the method returns, so
it works from several threads at once and each call sees its own headers.
- It is never called for errors: an `FmpError` already carries `headers`.
- If it raises, the method raises that exception and its data is discarded.

## Custom router or proxy

```python
Expand Down
18 changes: 18 additions & 0 deletions crates/fmp-py/python/fmp/_native/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,18 @@ class FmpClient:
non-loopback HTTP requires `danger_allow_insecure_authentication=True`.
Redirects are either disabled or restricted to the same origin.

`on_response`, when given, is called as `on_response(endpoint_id, status,
headers)` after every successful response, for every namespace method, for
example `("quote-short", 200, {"x-proxy-cache": "HIT",
"x-proxy-daily-remaining": "42"})`. `headers` holds only the allowlisted
response headers (`retry-after`, `x-proxy-*`, `x-ratelimit-*`), lowercase
and redacted, first value winning for a repeated name, the same as an
error's `headers`, but `{}` rather than `None` when none were sent. It runs
on the calling thread, with the GIL, after the body decoded and before the
method returns, so it is safe to use from several threads. It is never
called for errors, which carry their own `headers`. If it raises, the
method raises that exception and its decoded data is discarded.

`close()` releases the client's pooled connections; afterwards every call,
including through a namespace fetched before the close, raises
`FmpConfigError`. Closing twice is a no-op, and `with FmpClient(...) as
Expand Down Expand Up @@ -405,6 +417,12 @@ class FmpClient:
max_response_body_bytes: typing.Optional[builtins.int] = None,
danger_allow_insecure_authentication: builtins.bool = False,
follow_redirects: typing.Optional[builtins.bool] = None,
on_response: typing.Optional[
typing.Callable[
[builtins.str, builtins.int, builtins.dict[builtins.str, builtins.str]],
None,
]
] = None,
) -> FmpClient: ...
def close(self) -> None:
r"""
Expand Down
29 changes: 27 additions & 2 deletions crates/fmp-py/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};

use crate::{
errors::to_py_error,
response_hook,
runtime::{self, ClientHandle},
};

Expand Down Expand Up @@ -194,6 +195,18 @@ fn positive_duration(value: f64, field: &'static str) -> PyResult<Duration> {
/// non-loopback HTTP requires `danger_allow_insecure_authentication=True`.
/// Redirects are either disabled or restricted to the same origin.
///
/// `on_response`, when given, is called as `on_response(endpoint_id, status,
/// headers)` after every successful response, for every namespace method, for
/// example `("quote-short", 200, {"x-proxy-cache": "HIT",
/// "x-proxy-daily-remaining": "42"})`. `headers` holds only the allowlisted
/// response headers (`retry-after`, `x-proxy-*`, `x-ratelimit-*`), lowercase
/// and redacted, first value winning for a repeated name, the same as an
/// error's `headers`, but `{}` rather than `None` when none were sent. It runs
/// on the calling thread, with the GIL, after the body decoded and before the
/// method returns, so it is safe to use from several threads. It is never
/// called for errors, which carry their own `headers`. If it raises, the
/// method raises that exception and its decoded data is discarded.
///
/// `close()` releases the client's pooled connections; afterwards every call,
/// including through a namespace fetched before the close, raises
/// `FmpConfigError`. Closing twice is a no-op, and `with FmpClient(...) as
Expand All @@ -208,7 +221,7 @@ pub(crate) struct FmpClient {
#[pymethods]
impl FmpClient {
#[new]
#[pyo3(signature = (*, token=None, base_url=None, path_prefix=None, auth_mode=None, auth_name=None, auth_prefix=None, headers=None, timeout=None, connect_timeout=None, max_response_body_bytes=None, danger_allow_insecure_authentication=false, follow_redirects=None))]
#[pyo3(signature = (*, token=None, base_url=None, path_prefix=None, auth_mode=None, auth_name=None, auth_prefix=None, headers=None, timeout=None, connect_timeout=None, max_response_body_bytes=None, danger_allow_insecure_authentication=false, follow_redirects=None, on_response=None))]
#[allow(clippy::too_many_arguments)]
fn new(
token: Option<String>,
Expand All @@ -226,6 +239,8 @@ impl FmpClient {
max_response_body_bytes: Option<Bound<'_, PyInt>>,
danger_allow_insecure_authentication: bool,
follow_redirects: Option<bool>,
#[gen_stub(override_type(type_repr = "typing.Optional[typing.Callable[[builtins.str, builtins.int, builtins.dict[builtins.str, builtins.str]], None]]", imports = ("builtins", "typing")))]
on_response: Option<Bound<'_, PyAny>>,
) -> PyResult<Self> {
let auth = authentication(auth_mode, token, auth_name, auth_prefix)?;
let mut builder = ClientBuilder::default().authentication(auth);
Expand Down Expand Up @@ -262,12 +277,22 @@ impl FmpClient {
});
}

let on_response = on_response.filter(|callback| !callback.is_none());
if let Some(callback) = &on_response {
if !callback.is_callable() {
return Err(invalid_configuration(
"on_response must be callable or None",
));
}
builder = builder.on_response(response_hook::record);
}

builder
.clone()
.build()
.map_err(|error| build_error(auth_mode, error))?;
Ok(Self {
builder: ClientHandle::new(builder),
builder: ClientHandle::new(builder, on_response.map(Bound::unbind)),
})
}

Expand Down
1 change: 1 addition & 0 deletions crates/fmp-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ mod facade_domains;
mod models;
mod namespaces;
mod registration;
mod response_hook;
mod runtime;
mod secrets;

Expand Down
77 changes: 77 additions & 0 deletions crates/fmp-py/src/response_hook.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//! The `on_response` callback of `FmpClient`.
//!
//! The libfmp observer runs inside the request, on whichever thread polls it,
//! without the GIL. It only records what it saw into a per-call task-local
//! slot that [`capture`] scopes around one call; [`deliver`] then runs the
//! Python callback on the calling thread, with the GIL, once the call is done.
//! Concurrent calls each own their slot, so no thread sees another's headers.

use std::{
collections::BTreeMap,
future::Future,
mem,
sync::{Arc, Mutex, PoisonError},
};

use libfmp::ResponseInfo;
use pyo3::{prelude::*, types::PyAny};

/// One successful response as the callback receives it.
pub(crate) struct Seen {
endpoint: &'static str,
status: u16,
headers: BTreeMap<String, String>,
}

type Slot = Arc<Mutex<Vec<Seen>>>;

tokio::task_local! {
static SEEN: Slot;
}

/// The libfmp observer: records one success into the current call's slot.
///
/// Header names are lowercase and the first value of a repeated name wins,
/// the same rule as the error `headers` attribute. Outside [`capture`] (a
/// client without `on_response`) it does nothing.
pub(crate) fn record(info: &ResponseInfo<'_>) {
let _ = SEEN.try_with(|slot| {
let mut headers = BTreeMap::new();
for (name, value) in info.headers.iter() {
headers
.entry(name.to_owned())
.or_insert_with(|| value.to_owned());
}
slot.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(Seen {
endpoint: info.endpoint,
status: info.status,
headers,
});
});
}

/// Runs `future` with a fresh slot and returns its output with every success
/// recorded during it.
pub(crate) async fn capture<Fut: Future>(future: Fut) -> (Fut::Output, Vec<Seen>) {
let slot = Slot::default();
let output = SEEN.scope(Arc::clone(&slot), future).await;
let seen = mem::take(&mut *slot.lock().unwrap_or_else(PoisonError::into_inner));
(output, seen)
}

/// Calls `callback(endpoint_id, status, headers)` for each recorded success,
/// re-acquiring the GIL on the calling thread. The first exception it raises
/// is returned, so the endpoint method raises it instead of returning data.
pub(crate) fn deliver(callback: &Py<PyAny>, seen: Vec<Seen>) -> PyResult<()> {
if seen.is_empty() {
return Ok(());
}
Python::attach(|py| {
for success in seen {
callback.call1(py, (success.endpoint, success.status, success.headers))?;
}
Ok(())
})
}
23 changes: 18 additions & 5 deletions crates/fmp-py/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ use std::{
use libfmp::{Client, ClientBuilder};
use tokio::runtime::{Builder as RuntimeBuilder, Handle, Runtime};

use pyo3::PyResult;
use pyo3::{Py, PyResult, types::PyAny};

use crate::errors::{FmpError, to_py_error};
use crate::{
errors::{FmpError, to_py_error},
response_hook,
};

fn configuration_error(message: &'static str) -> pyo3::PyErr {
to_py_error(libfmp::Error::configuration(message))
Expand All @@ -48,13 +51,17 @@ const CLOSED_MESSAGE: &str = "the client is closed";
pub(crate) struct ClientHandle {
builder: ClientBuilder,
closed: AtomicBool,
on_response: Option<Py<PyAny>>,
}

impl ClientHandle {
pub(crate) fn new(builder: ClientBuilder) -> Arc<Self> {
/// `on_response`, when given, is called after every successful response;
/// the builder must already carry [`response_hook::record`] as observer.
pub(crate) fn new(builder: ClientBuilder, on_response: Option<Py<PyAny>>) -> Arc<Self> {
Arc::new(Self {
builder,
closed: AtomicBool::new(false),
on_response,
})
}

Expand Down Expand Up @@ -205,7 +212,8 @@ fn current() -> PyResult<Arc<Inner>> {
/// Returns a configuration error when `handle` is closed, or when called from
/// inside a runtime worker thread, where `Runtime::block_on` would panic.
/// Callers release the GIL (via `Python::detach`) around this so other Python
/// threads make progress.
/// threads make progress. With an `on_response` callback, it runs here after
/// the call, with the GIL re-acquired, and its exception is returned.
pub(crate) fn block_on<F, Fut, T>(handle: Arc<ClientHandle>, operation: F) -> PyResult<T>
where
F: FnOnce(Client) -> Fut,
Expand All @@ -221,7 +229,12 @@ where
}
let inner = current()?;
let client = inner.client(&handle)?;
Ok(inner.block_on(operation(client)))
let Some(callback) = &handle.on_response else {
return Ok(inner.block_on(operation(client)));
};
let (output, seen) = inner.block_on(response_hook::capture(operation(client)));
response_hook::deliver(callback, seen)?;
Ok(output)
}

/// Mark `handle` closed and drop its pooled client, releasing its idle
Expand Down
Loading
Loading