Getting 403 Forbidden on Canister HTTP Calls

While making GET calls from the canister, I am encountering a 403 status error.
I have checked possible causes, including:

  • IPv6 Compatibility
  • Rate Limiting

The above conditions are already met, and the API endpoint is public (the URL provided here is a placeholder). The canister makes requests successfully in a local environment, but after deployment, it throws this error.

Below is my Rust code for reference:

async fn make_http_request(request: CanisterHttpRequestArgument) -> Result<Vec<u8>, String> {
    const MAX_RETRIES: u8 = 5;
    
    let mut retries = 0;
    
    while retries < MAX_RETRIES {
        let cycles: u128 = 50_000_000_000;
        match http_request(request.clone(), cycles).await {
            Ok((response,)) => {
                if response.status.0.to_u64().unwrap_or(0) == 200 {
                    return Ok(response.body);
                } else {
                    return Err(format!("HTTP error: status {}", response.status));
                }
            },
            Err((_, msg)) if msg.contains("No consensus") || msg.contains("SysTransient") => {
                retries += 1;
                ic_cdk::println!("Retry {} for request: {}", retries, request.url);
                // Use ic_cdk::api::call::call_with_payment instead of timer
                continue;
            },
            Err((_, msg)) => return Err(msg),
        }
    }
    
    Err(format!("Failed after {} retries. Last error: No consensus could be reached", MAX_RETRIES))
}

#[ic_cdk::update]
async fn te_ilp2(args: ILPArgs) -> IlpResponse {
    let url = format!(
        "https://backend-91c09684-367d-457-5085be8c9158.bit10.app/verify-transaction?txid={}&chain={}&secret=<secret>", // placeholder url
        args.tick_in_tx_block,
        args.tick_in_network
    );

    let request = CanisterHttpRequestArgument {
        url: url.clone(),
        method: HttpMethod::GET,
        body: None,
        max_response_bytes: None,
        transform: None,
        headers: vec![],
    };

    let response_data = IlpResponseData {
        tick_in_name: args.tick_in_name,
        tick_in_network: args.tick_in_network,
        tick_in_tx_block: args.tick_in_tx_block,
        tick_out_name: args.tick_out_name
    };

    match make_http_request(request).await {
        Ok(response_body) => {
            if let Ok(response_json) = serde_json::from_slice::<serde_json::Value>(&response_body) {
                if let Some(message) = response_json["transaction_data"]["message"].as_str() {
                    match message {
                        "Transaction verified successfully" => IlpResponse::Ok(response_data),
                        "First output address does not match expected address" => {
                            IlpResponse::Err("Address verification failed".to_string())
                        }
                        _ => IlpResponse::Err("Unknown verification status".to_string()),
                    }
                } else {
                    IlpResponse::Err("Invalid response format".to_string())
                }
            } else {
                IlpResponse::Err("Failed to parse response".to_string())
            }
        }
        Err(err) => IlpResponse::Err(format!("HTTP request failed: {}", err)),
    }
}

ic_cdk::export_candid!();

1 Like

I’m not sure about the error—maybe you can share its stack trace? However, reading that it works locally (single node) but not once deployed on mainnet spontaneously sounds as if your responses are not reproducible. If you search for the keyword ‘idempotency key’ on the forum, you might find a few posts related to this topic. Again, I’m not sure if this is your issue—just a gut feeling.

I tried transform() but still getting 403 error, here is my updated code for reference. Am I doing it wrong?

async fn make_http_request(request: CanisterHttpRequestArgument) -> Result<Vec<u8>, String> {
    const MAX_RETRIES: u8 = 5;
    const CYCLES: u128 = 200_000_000_000;
    
    let mut retries = 0;
    
    while retries < MAX_RETRIES {
        match http_request(request.clone(), CYCLES).await {
            Ok((response,)) => {
                if response.status.0.to_u64().unwrap_or(0) == 200 {
                    return Ok(response.body);
                } else {
                    return Err(format!(
                        "HTTP error: status {} - Body: {:?}", 
                        response.status,
                        String::from_utf8_lossy(&response.body)
                    ));
                }
            },
            Err((_, msg)) if msg.contains("No consensus") || msg.contains("SysTransient") => {
                retries += 1;
                ic_cdk::println!("Retry {} for request: {}", retries, request.url);
                continue;
            },
            Err((_, msg)) => return Err(msg),
        }
    }
    
    Err(format!("Failed after {} retries. Last error: No consensus could be reached", MAX_RETRIES))
}

#[ic_cdk::query]
fn transform(raw: TransformArgs) -> HttpResponse {
    HttpResponse {
        status: raw.response.status,
        body: raw.response.body,
        headers: vec![],
    }
}

#[ic_cdk::update]
async fn verify_transaction() -> String {
    let url = "https://backend-91c09684-367d-457-5085be8c9158.bit10.app/verify-transaction?txid={}&secret=<secret>"; // placeholder url

    let tick_out_request = CanisterHttpRequestArgument {
        url: url.to_string(),
        method: HttpMethod::GET,
        body: None,
        max_response_bytes: Some(1024 * 1024),
        transform: Some(TransformContext::from_name(
            "transform".to_string(),
            vec![],
        )),
        headers: vec![
            ic_cdk::api::management_canister::http_request::HttpHeader {
                name: "User-Agent".to_string(),
                value: "ic-http-request/1.0".to_string(),
            },
            ic_cdk::api::management_canister::http_request::HttpHeader {
                name: "Accept".to_string(),
                value: "application/json".to_string(),
            },
            ic_cdk::api::management_canister::http_request::HttpHeader {
                name: "Host".to_string(),
                value: "backend-91c09684-367d-457-5085be8c9158.bit10.app".to_string(),
            },
            ic_cdk::api::management_canister::http_request::HttpHeader {
                name: "Connection".to_string(),
                value: "close".to_string(),
            },
        ],
    };

    let tick_out_body = match make_http_request(tick_out_request.clone()).await {
        Ok(body) => match String::from_utf8(body) {
            Ok(b) => b,
            Err(e) => return format!("Failed to parse response body: {}", e),
        },
        Err(e) => return format!("2nd API tick_out_request failed: {}", e),
    };

    let tick_out_json: Value = match serde_json::from_str(&tick_out_body) {
        Ok(j) => j,
        Err(e) => return format!("Failed to parse JSON: {}", e),
    };

    tick_out_body
}

Your implementation of transform LGTM. Your request does not seem to contains an idempotency key, are responses for similar requests reproducible?

I tried with Idempotency Key but still getting the status 403. The API response is same every time.

Mmmmh. Hard to tell, unfortunately, without debugging. My guess is something along those lines. The common issues I usually face (or sometimes forget about) are:

  • IPv6
  • Implementing the transform pattern to remove unnecessary data
  • Enough cycles for the calls
  • Idempotency key
  • Reproducible responses
  • If none of the above work, using a proxy

Sorry, no other ideas right now, unfortunately. If something comes back to my mind, I’ll let know.

Thank you for the suggestions, I tried to change few things and not getting 403 error but getting ‘HTTP error: status 400’

Here are the changes:

const VERIFICATION_SECRET: &str = "asdf";

async fn make_http_request(request: CanisterHttpRequestArgument) -> Result<Vec<u8>, String> {
    const MAX_RETRIES: u8 = 10;
    let mut retries = 0;
    
    while retries < MAX_RETRIES {
        let cycles: u128 = 50_000_000_000;
        match http_request(request.clone(), cycles).await {
            Ok((response,)) => {
                let status = response.status.0.to_u64().unwrap_or(0);
                ic_cdk::println!("Response status: {}", status);
                
                if status == 200 {
                    return Ok(response.body);
                } else {
                    retries += 1;
                    ic_cdk::println!("Received status {}, attempt {}/{}", status, retries, MAX_RETRIES);
                    if retries >= MAX_RETRIES {
                        return Err(format!("HTTP error: status {}", response.status));
                    }
                    continue;
                }
            },
            Err((code, msg)) => {
                retries += 1;
                ic_cdk::println!("Error: {:?}, {}, attempt {}/{}", code, msg, retries, MAX_RETRIES);
                if retries >= MAX_RETRIES {
                    return Err(msg);
                }
                continue;
            }
        }
    }
    
    Err(format!("Failed after {} retries", MAX_RETRIES))
}

#[ic_cdk::query]
fn transform(args: TransformArgs) -> HttpResponse {
    let res = HttpResponse {
        status: args.response.status,
        headers: vec![],
        body: args.response.body,
    };
    
    res
}

#[ic_cdk::update]
async fn verify_transaction(txid: String) -> Result<String, String> {
    println!("Verifying transaction with txid: {}", txid);
    
    let url = format!(
        "https://backend-91c09684-367d-457-5085be8c9158.bit10.app/verify-transaction?txid={}&chain=bitcoin_testnet",
        txid
    );
    
    let headers = vec![
        HttpHeader {
            name: "Content-Type".to_string(),
            value: "application/json".to_string(),
        },
        HttpHeader {
            name: "Host".to_string(),
            value: "backend-91c09684-367d-457-5085be8c9158.bit10.app".to_string(),
        },
        HttpHeader {
            name: "Accept".to_string(),
            value: "application/json".to_string(),
        },
        HttpHeader {
            name: "Authorization".to_string(),
            value: format!("Bearer {}", VERIFICATION_SECRET),
        },
    ];

    let request = CanisterHttpRequestArgument {
        url,
        method: HttpMethod::GET,
        body: None,
        max_response_bytes: Some(16384),
        transform: Some(TransformContext {
            function: TransformFunc(candid::Func {
                principal: ic_cdk::api::id(),
                method: "transform".to_string(),
            }),
            context: vec![],
        }),
        headers,
    };

    match make_http_request(request).await {
        Ok(response_bytes) => {
            let response_str = String::from_utf8(response_bytes)
                .map_err(|e| format!("Failed to decode response: {}", e))?;
            
            match serde_json::from_str::<Value>(&response_str) {
                Ok(_) => Ok(response_str),
                Err(e) => Err(format!("Invalid JSON response: {}", e))
            }
        },
        Err(e) => Err(format!("HTTP request failed: {}", e))
    }
}

Weird. Unfortunately, I can’t really help further, as I mentioned above. Hopefully, someone else can provide additional hints!