Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions crates/goose/src/providers/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ impl AnthropicProvider {
return Err(map_http_error_to_provider_error(
response.status,
response.payload,
"v1/models",
));
}

Expand Down
12 changes: 9 additions & 3 deletions crates/goose/src/providers/databricks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use super::formats::databricks::create_request;
use super::formats::openai_responses::create_responses_request;
use super::oauth;
use super::openai_compatible::{
handle_response_openai_compat, handle_status, map_http_error_to_provider_error,
handle_response_openai_compat, handle_status, map_http_error_to_provider_error, sanitize_url,
stream_openai_compat, stream_responses_compat,
};
use super::retry::ProviderRetry;
Expand Down Expand Up @@ -442,10 +442,11 @@ impl Provider for DatabricksProvider {
.await?;
if !resp.status().is_success() {
let status = resp.status();
let url = sanitize_url(resp.url().as_str());
let error_text = resp.text().await.unwrap_or_default();

let json_payload = serde_json::from_str::<Value>(&error_text).ok();
return Err(map_http_error_to_provider_error(status, json_payload));
return Err(map_http_error_to_provider_error(status, json_payload, &url));
}
Ok(resp)
})
Expand All @@ -461,9 +462,14 @@ impl Provider for DatabricksProvider {
.await?;
if !resp.status().is_success() {
let status = resp.status();
let url = sanitize_url(resp.url().as_str());
let error_text = resp.text().await.unwrap_or_default();
let json_payload = serde_json::from_str::<Value>(&error_text).ok();
return Err(map_http_error_to_provider_error(status, json_payload));
return Err(map_http_error_to_provider_error(
status,
json_payload,
&url,
));
}
Ok(resp)
})
Expand Down
5 changes: 3 additions & 2 deletions crates/goose/src/providers/gcpvertexai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use crate::providers::formats::gcpvertexai::{
DEFAULT_MODEL, KNOWN_MODELS,
};
use crate::providers::gcpauth::GcpAuth;
use crate::providers::openai_compatible::map_http_error_to_provider_error;
use crate::providers::openai_compatible::{map_http_error_to_provider_error, sanitize_url};
use crate::providers::retry::RetryConfig;
use crate::providers::utils::RequestLog;
use crate::session_context::SESSION_ID_HEADER;
Expand Down Expand Up @@ -359,9 +359,10 @@ impl GcpVertexAIProvider {
"Authentication failed with status: {status}"
)));
} else {
let url = sanitize_url(response.url().as_str());
let response_text = response.text().await.unwrap_or_default();
let payload = serde_json::from_str::<Value>(&response_text).ok();
return Err(map_http_error_to_provider_error(status, payload));
return Err(map_http_error_to_provider_error(status, payload, &url));
}
}
}
Expand Down
5 changes: 3 additions & 2 deletions crates/goose/src/providers/google.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use super::api_client::{ApiClient, AuthMethod};
use super::base::MessageStream;
use super::errors::ProviderError;
use super::openai_compatible::{handle_status, map_http_error_to_provider_error};
use super::openai_compatible::{handle_status, map_http_error_to_provider_error, sanitize_url};
use super::retry::ProviderRetry;
use super::utils::RequestLog;
use crate::conversation::message::Message;
Expand Down Expand Up @@ -177,9 +177,10 @@ impl Provider for GoogleProvider {
.await?;
let status = response.status();
if !status.is_success() {
let url = sanitize_url(response.url().as_str());
let body = response.text().await.unwrap_or_default();
let payload = serde_json::from_str::<serde_json::Value>(&body).ok();
return Err(map_http_error_to_provider_error(status, payload));
return Err(map_http_error_to_provider_error(status, payload, &url));
}

let json: serde_json::Value = response.json().await?;
Expand Down
40 changes: 31 additions & 9 deletions crates/goose/src/providers/http_status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,23 @@ use serde_json::Value;

use super::errors::ProviderError;

/// Strip credentials and sensitive query parameters from a URL for safe
/// inclusion in error messages and logs. Drops userinfo (`user:pass@`) and
/// all query parameters (which may contain API keys like `?key=...`).
/// Returns the original string unchanged if it doesn't parse as a URL
/// (e.g. a bare path like "v1/models").
pub fn sanitize_url(raw: &str) -> String {
let Ok(mut url) = url::Url::parse(raw) else {
return raw.to_string();
};
if !url.username().is_empty() || url.password().is_some() {
let _ = url.set_username("");
let _ = url.set_password(None);
}
url.set_query(None);
url.to_string()
}

/// Hard cap on retry delays we'll honor from remote responses. A malformed
/// 429 with `retry_after_seconds: 1e30` (or a far-future HTTP-date) should
/// degrade to "no retry hint" rather than freeze the agent or panic when
Expand Down Expand Up @@ -115,6 +132,7 @@ fn check_context_length_exceeded(text: &str) -> bool {
pub fn map_http_error_to_provider_error(
status: StatusCode,
payload: Option<Value>,
url: &str,
) -> ProviderError {
let extract_message = || -> String {
payload
Expand All @@ -132,13 +150,14 @@ pub fn map_http_error_to_provider_error(
let error = match status {
StatusCode::OK => unreachable!("Should not call this function with OK status"),
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => ProviderError::Authentication(format!(
"Authentication failed. Status: {}. Response: {}",
"Authentication failed for {url}. Status: {}. Response: {}",
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Redact sensitive URL parts in provider error messages

Avoid interpolating raw url into user-facing ProviderError strings here, because several providers accept base URLs that preserve credentials and query parameters (see OpenAiProvider::parse_base_url tests around key=... and user:pass@... in crates/goose/src/providers/openai.rs). With this change, any non-2xx response now echoes the full request URL into errors/logs, so failed requests can leak API keys or passwords to telemetry and terminal output. Sanitize the URL (drop userinfo and sensitive query values) before including it in error text.

Useful? React with 👍 / 👎.

status,
extract_message()
)),
StatusCode::NOT_FOUND => {
ProviderError::RequestFailed(format!("Resource not found (404): {}", extract_message()))
}
StatusCode::NOT_FOUND => ProviderError::RequestFailed(format!(
"Resource not found (404) at {url}: {}",
extract_message()
)),
StatusCode::PAYMENT_REQUIRED => ProviderError::CreditsExhausted {
details: extract_message(),
top_up_url: None,
Expand All @@ -156,11 +175,13 @@ pub fn map_http_error_to_provider_error(
details: extract_message(),
retry_delay: None,
},
_ if status.is_server_error() => {
ProviderError::ServerError(format!("Server error ({}): {}", status, extract_message()))
}
_ if status.is_server_error() => ProviderError::ServerError(format!(
"Server error ({}) at {url}: {}",
status,
extract_message()
)),
_ => ProviderError::RequestFailed(format!(
"Request failed with status {}: {}",
"Request failed with status {} at {url}: {}",
status,
extract_message()
)),
Expand All @@ -181,10 +202,11 @@ pub fn map_http_error_to_provider_error(
pub async fn handle_status(response: Response) -> Result<Response, ProviderError> {
let status = response.status();
if !status.is_success() {
let url = sanitize_url(response.url().as_str());
let headers = response.headers().clone();
let body = response.text().await.unwrap_or_default();
let payload = serde_json::from_str::<Value>(&body).ok();
let mut err = map_http_error_to_provider_error(status, payload.clone());
let mut err = map_http_error_to_provider_error(status, payload.clone(), &url);
if let ProviderError::RateLimitExceeded { details, .. } = &err {
err = ProviderError::RateLimitExceeded {
details: details.clone(),
Expand Down
6 changes: 4 additions & 2 deletions crates/goose/src/providers/openai_compatible.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,9 @@ impl Provider for OpenAiCompatibleProvider {

// Re-exported from the dedicated `http_status` module — these helpers are
// format-agnostic and used across all provider families.
pub use super::http_status::{handle_response, handle_status, map_http_error_to_provider_error};
pub use super::http_status::{
handle_response, handle_status, map_http_error_to_provider_error, sanitize_url,
};

// Legacy alias kept for callers that haven't migrated their import path yet.
pub use super::http_status::handle_response as handle_response_openai_compat;
Expand Down Expand Up @@ -244,7 +246,7 @@ mod tests {
payload: Option<Value>,
expected_variant: &str,
) {
let err = map_http_error_to_provider_error(status, payload);
let err = map_http_error_to_provider_error(status, payload, "http://test/endpoint");
let actual = err.telemetry_type();
let expected_telemetry = match expected_variant {
"CreditsExhausted" => "credits_exhausted",
Expand Down
5 changes: 3 additions & 2 deletions crates/goose/src/providers/snowflake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use super::base::{
};
use super::errors::ProviderError;
use super::formats::snowflake::{create_request, get_usage, response_to_message};
use super::openai_compatible::map_http_error_to_provider_error;
use super::openai_compatible::{map_http_error_to_provider_error, sanitize_url};
use super::retry::ProviderRetry;
use super::utils::{get_model, ImageFormat, RequestLog};
use crate::config::ConfigError;
Expand Down Expand Up @@ -123,6 +123,7 @@ impl SnowflakeProvider {
.await?;

let status = response.status();
let url = sanitize_url(response.url().as_str());
let payload_text: String = response.text().await.ok().unwrap_or_default();

if status.is_success() {
Expand Down Expand Up @@ -292,7 +293,7 @@ impl SnowflakeProvider {
Ok(answer_payload)
} else {
let error_json = serde_json::from_str::<Value>(&payload_text).ok();
Err(map_http_error_to_provider_error(status, error_json))
Err(map_http_error_to_provider_error(status, error_json, &url))
}
}
}
Expand Down
15 changes: 9 additions & 6 deletions crates/goose/src/providers/tetrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,15 @@ impl TetrateProvider {
}
}

fn error_from_tetrate_error_payload(payload: Value) -> ProviderError {
fn error_from_tetrate_error_payload(payload: Value, url: &str) -> ProviderError {
let code = payload
.get("error")
.and_then(|e| e.get("code"))
.and_then(|c| c.as_u64())
.unwrap_or(500) as u16;
let status = reqwest::StatusCode::from_u16(code)
.unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
Self::enrich_credits_error(map_http_error_to_provider_error(status, Some(payload)))
Self::enrich_credits_error(map_http_error_to_provider_error(status, Some(payload), url))
Copy link
Copy Markdown
Collaborator

@jh-block jh-block May 15, 2026

Choose a reason for hiding this comment

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

Did it forget to sanitize here? Or is it that tetrate URLs don't have secrets in them.

I wonder whether having the URL be part of the error type (outside of the string) and having it be sanitised in the Display/Debug impl might be safer than needing to remember to sanitise it before constructing the error?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

yeah, that's probably better. going to merge it though to get ahead

}
}

Expand Down Expand Up @@ -173,7 +173,10 @@ impl Provider for TetrateProvider {
.await
.map_err(Self::enrich_credits_error)?;
if body.get("error").is_some() {
return Err(Self::error_from_tetrate_error_payload(body));
return Err(Self::error_from_tetrate_error_payload(
body,
"v1/chat/completions",
));
}

return Err(ProviderError::ExecutionError(
Expand Down Expand Up @@ -203,7 +206,7 @@ impl Provider for TetrateProvider {

// Tetrate can return errors in 200 OK responses, so check explicitly
if json.get("error").is_some() {
return Err(Self::error_from_tetrate_error_payload(json));
return Err(Self::error_from_tetrate_error_payload(json, "v1/models"));
}

let arr = json.get("data").and_then(|v| v.as_array()).ok_or_else(|| {
Expand Down Expand Up @@ -257,7 +260,7 @@ mod tests {
"message": "Insufficient credits"
}
});
match TetrateProvider::error_from_tetrate_error_payload(payload) {
match TetrateProvider::error_from_tetrate_error_payload(payload, "test") {
ProviderError::CreditsExhausted {
details,
top_up_url,
Expand All @@ -277,7 +280,7 @@ mod tests {
"message": "Invalid API key"
}
});
match TetrateProvider::error_from_tetrate_error_payload(payload) {
match TetrateProvider::error_from_tetrate_error_payload(payload, "test") {
ProviderError::Authentication(msg) => {
assert!(msg.contains("Invalid API key"));
}
Expand Down
9 changes: 5 additions & 4 deletions crates/goose/src/providers/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,14 @@ fn parse_google_retry_delay(payload: &Value) -> Option<Duration> {
/// - `Err(ProviderError)`: Describes the failure reason.
pub async fn handle_response_google_compat(response: Response) -> Result<Value, ProviderError> {
let status = response.status();
let url = super::http_status::sanitize_url(response.url().as_str());
let payload: Option<Value> = response.json().await.ok();
let final_status = get_google_final_status(status, payload.as_ref());

match final_status {
StatusCode::OK => payload.ok_or_else( || ProviderError::RequestFailed("Response body is not valid JSON".to_string()) ),
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
Err(ProviderError::Authentication(format!("Authentication failed. Please ensure your API keys are valid and have the required permissions. \
Err(ProviderError::Authentication(format!("Authentication failed for {url}. Please ensure your API keys are valid and have the required permissions. \
Status: {}. Response: {:?}", final_status, payload )))
}
StatusCode::BAD_REQUEST | StatusCode::NOT_FOUND => {
Expand All @@ -172,7 +173,7 @@ pub async fn handle_response_google_compat(response: Response) -> Result<Value,
tracing::debug!(
"{}", format!("Provider request failed with status: {}. Payload: {:?}", final_status, payload)
);
Err(ProviderError::RequestFailed(format!("Request failed with status: {}. Message: {}", final_status, error_msg)))
Err(ProviderError::RequestFailed(format!("Request failed with status {} at {url}. Message: {}", final_status, error_msg)))
}
StatusCode::TOO_MANY_REQUESTS => {
let retry_delay = payload.as_ref().and_then(parse_google_retry_delay);
Expand All @@ -182,13 +183,13 @@ pub async fn handle_response_google_compat(response: Response) -> Result<Value,
})
}
_ if final_status.is_server_error() => Err(ProviderError::ServerError(
format_server_error_message(final_status, payload.as_ref()),
format!("Server error ({}) at {url}: {}", final_status, format_server_error_message(final_status, payload.as_ref())),
)),
_ => {
tracing::debug!(
"{}", format!("Provider request failed with status: {}. Payload: {:?}", final_status, payload)
);
Err(ProviderError::RequestFailed(format!("Request failed with status: {}", final_status)))
Err(ProviderError::RequestFailed(format!("Request failed with status {} at {url}", final_status)))
}
}
}
Expand Down
5 changes: 3 additions & 2 deletions crates/goose/src/providers/venice.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use super::base::{
ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata, ProviderUsage, Usage,
};
use super::errors::ProviderError;
use super::openai_compatible::map_http_error_to_provider_error;
use super::openai_compatible::{map_http_error_to_provider_error, sanitize_url};
use super::retry::ProviderRetry;
use crate::conversation::message::{Message, MessageContent};

Expand Down Expand Up @@ -126,6 +126,7 @@ impl VeniceProvider {
.await?;

let status = response.status();
let url = sanitize_url(response.url().as_str());
tracing::debug!("Venice response status: {}", status);

if !status.is_success() {
Expand Down Expand Up @@ -180,7 +181,7 @@ impl VeniceProvider {

// Use the common error mapping function
let error_json = serde_json::from_str::<Value>(&error_body).ok();
return Err(map_http_error_to_provider_error(status, error_json));
return Err(map_http_error_to_provider_error(status, error_json, &url));
}

let response_text = response.text().await?;
Expand Down
Loading