derive_builder is fine and I use it sometimes. For the “public client library” case where I want small, readable, reviewable generated code, I hand-roll the builder. The trick is to use Default for the builder struct and thread the config through a build() method. It is three times as much code as derive_builder and zero macro magic to read later.

#[derive(Debug, Clone)]
pub struct Client {
    pub endpoint: String,
    pub timeout:  std::time::Duration,
    pub retries:  u32,
}

#[derive(Debug, Default)]
pub struct ClientBuilder {
    endpoint: Option<String>,
    timeout:  Option<std::time::Duration>,
    retries:  Option<u32>,
}

impl ClientBuilder {
    pub fn new() -> Self { Self::default() }

    pub fn endpoint(mut self, v: impl Into<String>) -> Self {
        self.endpoint = Some(v.into());
        self
    }
    pub fn timeout(mut self, v: std::time::Duration) -> Self {
        self.timeout = Some(v);
        self
    }
    pub fn retries(mut self, v: u32) -> Self {
        self.retries = Some(v);
        self
    }

    pub fn build(self) -> Result<Client, &'static str> {
        Ok(Client {
            endpoint: self.endpoint.ok_or("endpoint is required")?,
            timeout:  self.timeout.unwrap_or(std::time::Duration::from_secs(30)),
            retries:  self.retries.unwrap_or(3),
        })
    }
}

// Usage:
let c = ClientBuilder::new()
    .endpoint("https://example.com")
    .timeout(std::time::Duration::from_secs(5))
    .build()?;

impl Into<String> lets callers pass &str or String. Required vs optional is encoded in build(). See also /snippets/rust-safe-env-macro/.