Last reviewed: November 2, 2026 (Asia/Shanghai); sources fetched November 1 UTC.

Direct answer

Build a CometAPI Rust client around one reusable asynchronous reqwest::Client. Serialize a small request with Serde, send it to the documented /v1/chat/completions endpoint , inspect the status and media type, then collect response chunks within a byte ceiling before deserializing them. Return text only after checking the chat envelope, assistant role, finish reason, refusal, and tool-call fields.

The CometAPI chat reference supplies the HTTP contract. The reqwest crate documentation supplies the async transport and feature configuration, while Serde JSON supplies typed serialization and deserialization. The example below uses those boundaries to keep an HTTP success separate from an accepted application result.

Who this is for

This tutorial is for developers adding a text-only, non-streaming model call to a Rust backend, worker, or command-line application. It uses Tokio and targets a native server or desktop process. Browser WebAssembly has different transport controls and must not receive a server API key.

You need a current Rust toolchain with Cargo and the native build tools required by the TLS dependencies. Keep the key in your secret mechanism and choose the model in trusted configuration. Check the current CometAPI model catalog documentation before setting COMETAPI_MODEL; the public catalog documents IDs, capability tags, and endpoint hints, but your account’s usable access still needs verification.

Key takeaways

  • Construct the client once and share it across calls to retain connection pooling.
  • Enable the Cargo features used by the example explicitly.
  • Set redirect, proxy, retry, and timeout policies before sending credentials.
  • Count response bytes during reading; typed JSON parsing alone imposes no application byte ceiling.
  • Use Option for nullable response fields, then apply a separate acceptance policy.
  • Keep upstream bodies, prompts, and credentials out of ordinary error messages.

For a standard-library transport comparison, see Build a CometAPI Go Client with net/http . The Node.js response-byte tutorial explains the same read-before-parse ordering in another runtime.

Set up the Rust project

Create a binary project:

cargo new comet-rust-demo
cd comet-rust-demo

Use this Cargo.toml. The reqwest and Serde JSON versions identify the references checked for this tutorial; review updates and retain Cargo.lock for an application build.

[package]
name = "comet-rust-demo"
version = "0.1.0"
edition = "2024"

[dependencies]
reqwest = { version = "=0.13.5", default-features = false, features = ["rustls", "json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "=1.0.154"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

The reqwest reference documents that its async client requires Tokio, json enables JSON helpers, and rustls enables that TLS backend. Response::chunk() is available without the optional stream feature; bytes_stream() requires that feature. This example needs only the former.

Implement the client and typed response

Save this as src/lib.rs:

use reqwest::{header, redirect, retry, Client, StatusCode};
use serde::{Deserialize, Serialize};
use std::time::Duration;

const ENDPOINT: &str = "https://api.cometapi.com/v1/chat/completions";
const MAX_PROMPT_BYTES: usize = 8_000;
const MAX_RESPONSE_BYTES: usize = 1024 * 1024;

#[derive(Debug, PartialEq, Eq)]
pub enum ChatError {
    Configuration,
    Prompt,
    Transport,
    Http(u16),
    MediaType,
    TooLarge,
    Json,
    Outcome,
}

pub struct CometClient {
    http: Client,
    key: header::HeaderValue,
    model: String,
}

#[derive(Serialize)]
struct ChatRequest<'a> {
    model: &'a str,
    messages: [Message<'a>; 1],
    stream: bool,
}

#[derive(Serialize)]
struct Message<'a> {
    role: &'static str,
    content: &'a str,
}

#[derive(Deserialize)]
struct Completion {
    object: String,
    choices: Vec<Choice>,
}

#[derive(Deserialize)]
struct Choice {
    finish_reason: Option<String>,
    message: Reply,
}

#[derive(Deserialize)]
struct Reply {
    role: String,
    content: Option<String>,
    refusal: Option<String>,
    tool_calls: Option<Vec<serde_json::Value>>,
}

impl CometClient {
    pub fn new(key: &str, model: &str) -> Result<Self, ChatError> {
        if key.trim().is_empty() || model.trim().is_empty() {
            return Err(ChatError::Configuration);
        }
        let mut key = header::HeaderValue::from_str(&format!("Bearer {key}"))
            .map_err(|_| ChatError::Configuration)?;
        key.set_sensitive(true);
        let http = Client::builder()
            .https_only(true)
            .redirect(redirect::Policy::none())
            .retry(retry::never())
            .no_proxy()
            .connect_timeout(Duration::from_secs(5))
            .timeout(Duration::from_secs(30))
            .build()
            .map_err(|_| ChatError::Configuration)?;
        Ok(Self { http, key, model: model.to_owned() })
    }

    pub async fn complete(&self, prompt: &str) -> Result<String, ChatError> {
        if prompt.trim().is_empty() || prompt.len() > MAX_PROMPT_BYTES {
            return Err(ChatError::Prompt);
        }
        let payload = ChatRequest {
            model: &self.model,
            messages: [Message { role: "user", content: prompt }],
            stream: false,
        };
        let mut response = self.http.post(ENDPOINT)
            .header(header::AUTHORIZATION, self.key.clone())
            .header(header::ACCEPT, "application/json")
            .json(&payload)
            .send().await
            .map_err(|_| ChatError::Transport)?;
        if response.status() != StatusCode::OK {
            return Err(ChatError::Http(response.status().as_u16()));
        }
        let json_media = response.headers().get(header::CONTENT_TYPE)
            .and_then(|v| v.to_str().ok())
            .and_then(|v| v.split(';').next())
            .is_some_and(|v| v.trim().eq_ignore_ascii_case("application/json"));
        if !json_media {
            return Err(ChatError::MediaType);
        }
        let mut body = Vec::new();
        while let Some(chunk) = response.chunk().await
            .map_err(|_| ChatError::Transport)?
        {
            append_chunk(&mut body, &chunk, MAX_RESPONSE_BYTES)?;
        }
        parse_reply(&body)
    }
}

fn append_chunk(body: &mut Vec<u8>, chunk: &[u8], limit: usize)
    -> Result<(), ChatError>
{
    if body.len() > limit || chunk.len() > limit - body.len() {
        return Err(ChatError::TooLarge);
    }
    body.extend_from_slice(chunk);
    Ok(())
}

fn parse_reply(body: &[u8]) -> Result<String, ChatError> {
    let data: Completion = serde_json::from_slice(body)
        .map_err(|_| ChatError::Json)?;
    if data.object != "chat.completion" || data.choices.len() != 1 {
        return Err(ChatError::Outcome);
    }
    let choice = data.choices.into_iter().next().ok_or(ChatError::Outcome)?;
    let message = choice.message;
    if choice.finish_reason.as_deref() != Some("stop")
        || message.role != "assistant"
        || message.refusal.is_some()
        || message.tool_calls.as_ref().is_some_and(|calls| !calls.is_empty())
    {
        return Err(ChatError::Outcome);
    }
    message.content.filter(|text| !text.trim().is_empty())
        .ok_or(ChatError::Outcome)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn fixture(finish: &str, refusal: serde_json::Value) -> Vec<u8> {
        serde_json::to_vec(&serde_json::json!({
            "object": "chat.completion",
            "choices": [{
                "finish_reason": finish,
                "message": {
                    "role": "assistant", "content": "Hello",
                    "refusal": refusal, "tool_calls": []
                }
            }]
        })).unwrap()
    }

    #[test]
    fn accepts_only_completed_text() {
        assert_eq!(parse_reply(&fixture("stop", serde_json::Value::Null)),
                   Ok("Hello".to_owned()));
        assert_eq!(parse_reply(&fixture("length", serde_json::Value::Null)),
                   Err(ChatError::Outcome));
        assert_eq!(parse_reply(&fixture("stop", serde_json::json!("Declined"))),
                   Err(ChatError::Outcome));
        assert_eq!(parse_reply(b"{broken"), Err(ChatError::Json));
    }

    #[test]
    fn cap_rejects_before_copying() {
        let mut body = Vec::new();
        assert_eq!(append_chunk(&mut body, b"1234", 4), Ok(()));
        assert_eq!(append_chunk(&mut body, b"5", 4), Err(ChatError::TooLarge));
        assert_eq!(body, b"1234");
    }
}

The request borrows the configured model and prompt while Serde constructs the outbound JSON. The RequestBuilder reference documents that .json(&payload) serializes the value and sets Content-Type: application/json. The public method accepts only a prompt, so a browser cannot replace the endpoint, key, model, or transport policy through an arbitrary request object.

The response structs are a projection of the documented wire format. content, refusal, and tool_calls are inside message; finish_reason belongs to the choice. Optional fields let absent or null values reach the acceptance policy. Required fields with missing or incompatible types fail deserialization. Additional response metadata can be ignored while the application still checks the fields it consumes.

parse_reply accepts exactly one ordinary, completed text choice. It rejects refusals, nonempty tool calls, missing text, an unexpected role, and any finish reason other than stop. CometAPI documents length, tool_calls, and content_filter as other outcomes. Supporting those outcomes requires a product decision and a richer result type. Returning String here deliberately promises only the accepted text case.

The error enum carries fixed categories and, for HTTP failures, a numeric status. It never embeds a reqwest error, Serde error, raw response, prompt, or credential. CometClient has no derived Debug implementation. Marking the header sensitive is an additional precaution; it does not erase the original environment value or make arbitrary request logging safe.

Make the transport policy explicit

The ClientBuilder reference documents several defaults that matter for an authenticated generation request:

ControlExample policyReason
RedirectsPolicy::none()Return the original redirect status for review.
Retriesretry::never()Keep one client invocation from initiating automatic retry attempts.
Proxiesno_proxy()Make this demonstration use direct egress.
Connection timeoutFive secondsBound connection establishment.
Total HTTP timeoutThirty secondsKeep the request and response-body read inside one transport allowance.
Destination schemeHTTPS onlyReject accidental HTTP use by this client.

Reqwest 0.13.5 follows redirects and retries protocol NACKs by default. Its never-retry policy explicitly disables that retry behavior. An application can add a separate, bounded retry policy later, after deciding how to handle uncertain remote outcomes. This example makes no exactly-once execution claim.

no_proxy() is a deliberate deployment assumption. If your environment requires an approved proxy, configure it explicitly instead and test that route. Keep certificate verification enabled; a proxy or certificate failure should not be repaired by accepting invalid certificates.

The total timeout covers connection setup through completion of the HTTP body, according to the builder reference. It does not bound time spent waiting in your own work queue before calling this method, nor does it preempt synchronous Serde parsing afterward. The five-second and thirty-second values are local examples, not provider latency guarantees. A service should carry its caller’s remaining time budget into the adapter rather than give every downstream attempt a fresh allowance.

Bound the body before parsing

The Response reference distinguishes reading one chunk from collecting the full body with bytes() or parsing it through json(). The loop above counts the bytes actually returned by chunk(). It rejects a chunk before copying it when that copy would exceed the one-mebibyte application ceiling.

This bounds the accepted byte buffer’s length. It does not cap every allocation: the incoming chunk already exists, the vector can reserve extra capacity, deserialization allocates strings and collections, and concurrent calls multiply resource use. Treat response size and concurrency as separate settings. The byte check also does not depend on a trustworthy Content-Length header.

Non-200 and wrong-media-type responses return before body collection. The locally owned response then goes out of scope; the code does not read an arbitrary error page merely to log it. An early return can sacrifice connection reuse, which is preferable to an unbounded diagnostic read. It does not establish that remote generation stopped or that no usage was recorded.

The 8,000-byte prompt ceiling is another local setting. Rust string length measures UTF-8 bytes, not model tokens. Verify model-specific token controls separately before adding them to the request type.

Call the client from an application

Replace src/main.rs with:

use comet_rust_demo::{ChatError, CometClient};

async fn run() -> Result<(), ChatError> {
    let key = std::env::var("COMETAPI_KEY").map_err(|_| ChatError::Configuration)?;
    let model = std::env::var("COMETAPI_MODEL").map_err(|_| ChatError::Configuration)?;
    let client = CometClient::new(&key, &model)?;
    let reply = client.complete("Explain idempotency in one sentence.").await?;
    println!("{reply}");
    Ok(())
}

#[tokio::main]
async fn main() {
    if let Err(error) = run().await {
        eprintln!("cometapi_request_failed:{error:?}");
        std::process::exit(1);
    }
}

Set COMETAPI_KEY through your secret mechanism and COMETAPI_MODEL to a verified chat model ID, then use cargo run --locked when you intend to make one live request. The binary prints the accepted answer as its intended output. In a web service, pass that answer to the authorized response consumer and keep it out of routine operational logs.

Construct CometClient during application startup and share a reference or application-owned handle with request handlers. Avoid spawning a detached generation task merely to return early: the owning request or job should retain responsibility for its result and cleanup. A surrounding service still needs caller cancellation, admission controls, input authorization, and its own error mapping.

Contract details to verify

The two included unit tests exercise the acceptance policy and the copy boundary without a key. Extend them with an HTTP test server before using the adapter in a user-facing route. A test-only constructor can substitute the endpoint and permit loopback HTTP; keep that facility outside the public production configuration.

FixtureRequired result
Short valid promptOne POST with configured model, one user message, bearer header, and stream: false.
Blank or over-limit promptPrompt before an outbound request.
HTTP 302Http(302) and no request to the redirect destination.
HTTP 429 with an arbitrary bodyHttp(429) with no provider body in the error.
HTTP 200 with HTMLMediaType before JSON parsing.
Body exactly at the ceilingAllow collection, then apply JSON and outcome checks.
Body one byte over the ceilingTooLarge before copying the excess bytes.
Headers arrive but the body stallsThe total HTTP timeout produces Transport.
Refusal, tools, truncation, or wrong roleNo successful text result.

The project compiled and both included unit tests passed during preparation with Rust 1.99.1, reqwest 0.13.5, and Serde JSON 1.0.154. Those checks covered accepted text, truncation, refusal, malformed JSON, and rejecting a byte beyond the copy limit. No authenticated CometAPI request or deployment was performed. The HTTP fixtures in the table remain verification work for the reader.

Run cargo test --locked after adding fixtures. Inspect method, path, JSON fields, and header presence without printing the bearer value. Use a fake credential and synthetic model name for local server tests. A parser test cannot establish that redirects, timeouts, or request construction work end to end.

Failure modes

The code compiles but cannot send JSON. Confirm that reqwest’s json feature is enabled and that the application uses the lockfile reviewed with its dependency versions.

HTTP 200 becomes a successful answer automatically. Check the typed response and acceptance policy. A refusal or incomplete answer can arrive inside a valid chat-completion envelope.

A transport error triggers another generation immediately. Preserve the uncertainty. Decide whether another attempt is allowed under the caller’s deadline and attempt allowance before introducing retries.

A model upgrade changes response behavior. Compare the selected model’s current endpoint and parameter documentation. Keep the model in controlled configuration and update fixtures before changing the parser.

The key or prompt appears in diagnostic output. Inspect tracing middleware and HTTP debug settings as well as the adapter. Fixed error variants cannot prevent another layer from logging request bodies or headers.

Sources checked

These eight primary technical references were fetched successfully on November 1, 2026 UTC. They support an evergreen implementation task. The reqwest references are fixed to 0.13.5 and Serde JSON to 1.0.154; the CometAPI pages were checked live for the current wire contract.

FAQ

Does Serde validate the generated answer’s meaning?

It validates the data representation requested by the Rust types. The explicit policy then selects an acceptable completion state. Business rules, authorization, HTML safety, and semantic accuracy require application checks.

Can this client consume streaming completions?

It sends stream: false and expects one JSON document. Streaming requires a separate SSE parser with event framing, a completion policy, and its own limits.

Should all errors be retried?

No. Request validation, authentication, media-type, parsing, and outcome failures need diagnosis. Even a transport timeout can leave the remote result uncertain. Choose retries in an application policy with explicit limits.

Reader next step

Build the project, run the synthetic tests, and add the HTTP fixtures for request construction, redirects, and a stalled body. Then make one authorized staging call with a harmless prompt. Retain the dependency lockfile, model ID, HTTP status class, response-contract verdict, and test results. Those records establish the integration boundary you checked; a separate operational review should set production concurrency and retry budgets.