> This is the Markdown version of the page. For the complete Lattice Developers documentation index, fetch https://developer.anduril.com/llms.txt. Append .md to any page URL for its clean Markdown. # Retry connections > Build a reusable retry utility to handle transient failures across every Lattice SDK call. Production integrations send thousands of requests over networks that can drop connections, throttle traffic, or return transient errors. A reusable retry utility lets you apply one consistent backoff policy across every API interaction instead of duplicating retry logic per call. ## Before you begin * Complete the steps in [Set up](/guides/getting-started/set-up) to configure your environment and install the SDK. * Decide which protocol you are using, REST or gRPC, as the error types and retry-eligible codes differ between protocols. > **gRPC authentication** > > These gRPC examples authenticate with a static environment token attached as request metadata. > If you are using OAuth 2.0 client credentials instead, [set up the token refresh module](/guides/getting-started/authenticate#client-credentials) before running the examples on this page. ## What the SDK retries Before building your own utility, understand what the SDK already handles so you do not retry the same error twice. The **Lattice REST SDKs** retry automatically on HTTP `5xx`, `408`, `409`, and `429` responses. The SDK retry mechanism applies exponential backoff starting at 1 second, with a maximum of 60 seconds, with ±20% jitter, and honors `Retry-After`, `retry-after-ms`, and `X-RateLimit-Reset` headers. The default is 2 retries per call. You can override this per call with `request_options={"max_retries": N}`. The **gRPC SDKs** do not implement retries automatically. You must implement retries for your Lattice integrations. > **Avoid double retries** > > If you build a retry utility on top of the Lattice REST SDKs, set `request_options={"max_retries": 0}` on each SDK > call to delegate the entire retry policy to your utility. Without this, both layers retry independently, and > a single transient error can trigger up to `(sdk_retries + 1) x utility_retries` total attempts. ## Build a retry utility A single helper function that wraps any API call gives you one place to tune backoff parameters, adjust error classification, and add observability across your integration: #### Define the retry function The following utility loops up to `max_retries` times, sleeping for an exponentially increasing backoff between attempts. It re-raises immediately on errors that retrying will not fix. ```py title={"Python (REST)"} startLine={33} maxLines={19} from anduril import Lattice, \ Aliases, MilView, Location, Position, Ontology, Provenance from anduril.core import ApiError from datetime import datetime, timezone, timedelta import asyncio import httpx import math import os import sys from uuid import uuid4 lattice_endpoint = os.getenv('LATTICE_ENDPOINT') environment_token = os.getenv('ENVIRONMENT_TOKEN') # Remove sandboxes_token from the following statements if you are not developing on Sandboxes. sandboxes_token = os.getenv('SANDBOXES_TOKEN') if not environment_token or not lattice_endpoint or not sandboxes_token: print("Missing required environment variables.") sys.exit(1) client = Lattice( base_url=f"https://{lattice_endpoint}", token=lambda: str(environment_token), # Remove the following header if you are not developing on Sandboxes. headers={ "anduril-sandbox-authorization": f"Bearer {sandboxes_token}" } ) MAX_RETRIES = 3 INITIAL_BACKOFF_SECONDS = 1.0 TERMINAL_STATUS_CODES = {400, 401, 403, 413} async def retry_with_backoff(operation, *, max_retries=MAX_RETRIES, initial_backoff=INITIAL_BACKOFF_SECONDS): for attempt in range(max_retries): try: return operation() except ApiError as e: # Terminal 4xx errors mean the request itself is wrong; retrying will not help. if e.status_code in TERMINAL_STATUS_CODES: raise if attempt < max_retries - 1: backoff = initial_backoff * (2 ** attempt) await asyncio.sleep(backoff) else: raise except (httpx.ConnectError, httpx.RemoteProtocolError): if attempt < max_retries - 1: backoff = initial_backoff * (2 ** attempt) await asyncio.sleep(backoff) else: raise entity_id = str(uuid4()) radius_degrees = 0.1 creation_time = datetime.now(timezone.utc) count = 0.0 def publish_track(): global count count += 0.1 t = math.radians(count) latest_timestamp = datetime.now(timezone.utc) client.entities.publish_entity( entity_id=entity_id, description="Friendly airplane", is_live=True, aliases=Aliases( name="Airplane 1" ), created_time=creation_time, expiry_time=latest_timestamp + timedelta(minutes=5), ontology=Ontology( template="TEMPLATE_TRACK", platform_type="Airplane" ), mil_view=MilView( disposition="DISPOSITION_FRIENDLY", environment="ENVIRONMENT_AIR" ), location=Location( position=Position( latitude_degrees=50.91402185768586 + (radius_degrees * math.cos(t)), longitude_degrees=0.79203612077257 + (radius_degrees * math.sin(t)) ) ), provenance=Provenance( integration_name="your_integration_name", data_type="your_data_type", source_update_time=latest_timestamp ), # The utility owns the retry policy, so disable the SDK's built-in retry layer to avoid double-retrying. request_options={"max_retries": 0} ) async def app(): try: while True: await retry_with_backoff(publish_track) print(f"Published track with entity ID: {entity_id}") await asyncio.sleep(5) except asyncio.CancelledError: print(">>>Exiting...") except Exception as error: print(f"Exception: {error}") if __name__ == "__main__": asyncio.run(app()) ``` ```rs title={"Rust (REST)"} startLine={120} maxLines={19} use anduril_lattice_sdk::prelude::*; use std::env; use std::time::Duration; use uuid::Uuid; const MAX_RETRIES: u32 = 3; const INITIAL_BACKOFF_SECONDS: f64 = 1.0; /// Returns true for terminal 4xx errors that mean the request itself is wrong, /// so retrying will not help (400 Bad Request, 401 Unauthorized, 403 Forbidden, /// 413 Content Too Large). fn is_terminal(error: &ApiError) -> bool { matches!( error, ApiError::BadRequestError { .. } | ApiError::UnauthorizedError { .. } | ApiError::ContentTooLargeError { .. } | ApiError::Http { status: 403, .. } ) } #[tokio::main] async fn main() -> Result<(), Box> { let lattice_endpoint = env::var("LATTICE_ENDPOINT") .expect("LATTICE_ENDPOINT environment variable not set"); let environment_token = env::var("ENVIRONMENT_TOKEN") .expect("ENVIRONMENT_TOKEN environment variable not set"); // Remove sandboxes_token if you are not developing on Sandboxes. let sandboxes_token = env::var("SANDBOXES_TOKEN") .expect("SANDBOXES_TOKEN environment variable not set"); if environment_token.is_empty() || lattice_endpoint.is_empty() || sandboxes_token.is_empty() { eprintln!("Missing required environment variables:"); eprintln!(" LATTICE_ENDPOINT"); eprintln!(" ENVIRONMENT_TOKEN"); eprintln!(" SANDBOXES_TOKEN"); std::process::exit(1); } let config = ClientConfig { base_url: format!("https://{}", lattice_endpoint), token: Some(environment_token), // Remove custom_headers if you are not developing on Sandboxes. custom_headers: HashMap::from([( "anduril-sandbox-authorization".to_string(), format!("Bearer {}", sandboxes_token), )]), ..Default::default() }; let client = Lattice::new(config)?; let entity_id = Uuid::new_v4().to_string(); let radius_degrees: f64 = 0.1; let creation_time = Utc::now().fixed_offset(); let mut count: f64 = 0.0; loop { count += 0.1; let t = count.to_radians(); let latest_timestamp = Utc::now().fixed_offset(); let entity = Entity { entity_id: Some(entity_id.clone()), description: Some("Friendly airplane".to_string()), is_live: Some(true), aliases: Some(Aliases { name: Some("Airplane 1".to_string()), ..Default::default() }), created_time: Some(creation_time), expiry_time: Some((Utc::now() + chrono::Duration::minutes(5)).fixed_offset()), ontology: Some(Ontology { template: Some(OntologyTemplate::TemplateTrack), platform_type: Some("Airplane".to_string()), ..Default::default() }), mil_view: Some(MilView { disposition: Some(MilViewDisposition::DispositionFriendly), environment: Some(MilViewEnvironment::EnvironmentAir), ..Default::default() }), location: Some(Location { position: Some(Position { latitude_degrees: Some(50.91402185768586 + (radius_degrees * t.cos())), longitude_degrees: Some(0.79203612077257 + (radius_degrees * t.sin())), ..Default::default() }), ..Default::default() }), provenance: Some(Provenance { integration_name: Some("your_integration_name".to_string()), data_type: Some("your_data_type".to_string()), source_update_time: Some(latest_timestamp), ..Default::default() }), ..Default::default() }; // The utility owns the retry policy, so disable the SDK's built-in retry // layer (max_retries = 0) to avoid double-retrying. let client_ref = &client; let publish = || { let entity = entity.clone(); async move { let options = RequestOptions::new().max_retries(0); client_ref.entities.publish_entity(&entity, Some(options)).await } }; match retry_with_backoff(publish).await { Ok(_) => println!("Published track with entity ID: {}", entity_id), Err(error) => eprintln!("Exception: {}", error), } tokio::time::sleep(Duration::from_secs(5)).await; } } /// Run an async operation with exponential backoff, giving up immediately on /// terminal 4xx errors. async fn retry_with_backoff(operation: F) -> Result where F: Fn() -> Fut, Fut: std::future::Future>, { let mut last_error: Option = None; for attempt in 0..MAX_RETRIES { match operation().await { Ok(value) => return Ok(value), Err(error) => { // Terminal 4xx errors mean the request itself is wrong; // retrying will not help. if is_terminal(&error) { return Err(error); } last_error = Some(error); if attempt < MAX_RETRIES - 1 { let backoff = INITIAL_BACKOFF_SECONDS * 2f64.powi(attempt as i32); tokio::time::sleep(Duration::from_secs_f64(backoff)).await; } } } } Err(last_error.expect("retry loop ran at least once")) } ``` ```go title={"Go (gRPC)"} startLine={45} maxLines={26} // This Go example is compatible with artifacts generated using // the following grpc/go plugin: https://buf.build/anduril/lattice-sdk/sdks/main:grpc/go package main import ( "context" "fmt" "log" "math" "os" "time" "schema-registry.developer.anduril.com/gen/go/anduril/lattice-sdk/grpc/go/anduril/entitymanager/v1/entitymanagerv1grpc" entitymanagerv1 "schema-registry.developer.anduril.com/gen/go/anduril/lattice-sdk/protocolbuffers/go/anduril/entitymanager/v1" ontologyv1 "schema-registry.developer.anduril.com/gen/go/anduril/lattice-sdk/protocolbuffers/go/anduril/ontology/v1" "github.com/google/uuid" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" ) // RetryOpts controls the retry behavior of retryWithBackoff. type RetryOpts struct { MaxAttempts int InitialBackoff time.Duration } // retryableCodes lists transient server-side and connectivity errors that are // safe to retry. NotFound is included because a resource may not yet be visible // due to eventual consistency. Terminal codes (InvalidArgument, PermissionDenied, // Unauthenticated, FailedPrecondition, AlreadyExists) are intentionally absent — // retrying them will not change the outcome. var retryableCodes = map[codes.Code]bool{ codes.Unavailable: true, codes.DeadlineExceeded: true, codes.ResourceExhausted: true, codes.Internal: true, codes.Aborted: true, codes.Unknown: true, codes.NotFound: true, } func retryWithBackoff(ctx context.Context, op func(context.Context) error, opts RetryOpts) error { for attempt := 0; attempt < opts.MaxAttempts; attempt++ { err := op(ctx) if err == nil { return nil } code := status.Code(err) if !retryableCodes[code] { // Terminal error — the request itself is wrong; retrying will not help. return err } if attempt == opts.MaxAttempts-1 { return err } backoff := opts.InitialBackoff * (1 << attempt) select { case <-time.After(backoff): case <-ctx.Done(): return ctx.Err() } } return fmt.Errorf("retry: exceeded %d attempts", opts.MaxAttempts) } func main() { environmentToken := os.Getenv("ENVIRONMENT_TOKEN") latticeEndpoint := os.Getenv("LATTICE_ENDPOINT") sandboxesToken := os.Getenv("SANDBOXES_TOKEN") if latticeEndpoint == "" || environmentToken == "" || sandboxesToken == "" { fmt.Println("Missing required environment variables") os.Exit(1) } auth := &BearerTokenAuth{ Token: environmentToken, SandboxesToken: sandboxesToken, } opts := []grpc.DialOption{ grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(nil, "")), grpc.WithPerRPCCredentials(auth), } conn, err := grpc.NewClient(latticeEndpoint, opts...) if err != nil { log.Fatalf("Did not connect: %v", err) } defer conn.Close() client := entitymanagerv1grpc.NewEntityManagerAPIClient(conn) entityId := uuid.New().String() radiusDegrees := 0.1 count := 0.0 creationTime := time.Now().UTC() creationTimestamp := timestamppb.New(creationTime) for { latestTimestamp := time.Now().UTC() ctx := context.Background() count += 0.1 t := math.Pi * count / 180.0 entity := &entitymanagerv1.Entity{ EntityId: entityId, Description: "Friendly airplane", Aliases: &entitymanagerv1.Aliases{ Name: "Airplane 1", }, IsLive: true, CreatedTime: creationTimestamp, ExpiryTime: timestamppb.New(latestTimestamp.Add(5 * time.Minute)), Ontology: &entitymanagerv1.Ontology{ Template: entitymanagerv1.Template_TEMPLATE_TRACK, PlatformType: "Airplane", }, MilView: &entitymanagerv1.MilView{ Disposition: ontologyv1.Disposition_DISPOSITION_FRIENDLY, Environment: ontologyv1.Environment_ENVIRONMENT_AIR, }, Location: &entitymanagerv1.Location{ Position: &entitymanagerv1.Position{ LatitudeDegrees: 50.91402185768586 + (radiusDegrees * math.Cos(t)), LongitudeDegrees: 0.79203612077257 + (radiusDegrees * math.Sin(t)), }, }, Provenance: &entitymanagerv1.Provenance{ IntegrationName: "your_integration_name", DataType: "your_data_type", SourceUpdateTime: timestamppb.New(latestTimestamp), }, } request := &entitymanagerv1.PublishEntityRequest{Entity: entity} err := retryWithBackoff(ctx, func(ctx context.Context) error { _, err := client.PublishEntity(ctx, request) return err }, RetryOpts{MaxAttempts: 3, InitialBackoff: time.Second}) if err != nil { fmt.Printf("Publish failed after retries: %v\n", err) } else { fmt.Printf("Publishing track with entity ID: %s\n", entityId) } time.Sleep(5 * time.Second) } } ``` ```py title={"Python (gRPC)"} startLine={39} maxLines={18} # This Python example is compatible with artifacts generated using the following # Buf plugins: https://schema-registry.developer.anduril.com/anduril/lattice-sdk/sdks/main:bufbuild/py # and https://schema-registry.developer.anduril.com/anduril/lattice-sdk/sdks/main:connectrpc/py import math import os import sys import time import uuid from datetime import datetime, timedelta, timezone from bearer_auth import create_client from connectrpc.code import Code from connectrpc.errors import ConnectError from protobuf.wkt import Timestamp from anduril.entitymanager.v1.entity_pub_pb import Aliases, Entity, Provenance from anduril.entitymanager.v1.entity_manager_api_pub_pb import PublishEntityRequest from anduril.entitymanager.v1.entity_manager_api_pub_connect import EntityManagerAPIClientSync from anduril.entitymanager.v1.location_pub_pb import Location, Position from anduril.entitymanager.v1.ontology_pub_pb import MilView, Ontology from anduril.entitymanager.v1.types_pub_pb import Template from anduril.ontology.v1.type_pub_pb import Disposition, Environment # Transient server-side and connectivity errors that are safe to retry. # NOT_FOUND is included because a resource may not yet be visible due to eventual # consistency. Terminal codes (INVALID_ARGUMENT, PERMISSION_DENIED, UNAUTHENTICATED, # FAILED_PRECONDITION, ALREADY_EXISTS) are intentionally absent — retrying them # will not change the outcome. RETRYABLE_CODES = { Code.UNAVAILABLE, Code.DEADLINE_EXCEEDED, Code.RESOURCE_EXHAUSTED, Code.INTERNAL, Code.ABORTED, Code.UNKNOWN, Code.NOT_FOUND, } def retry_with_backoff(op, max_attempts=3, initial_backoff_sec=1.0): """Retry a callable with exponential backoff on retryable Connect errors.""" last_error: Exception | None = None for attempt in range(max_attempts): try: return op() except ConnectError as error: last_error = error code = error.code if code not in RETRYABLE_CODES: # Terminal error — the request itself is wrong; retrying will not help. raise if attempt == max_attempts - 1: raise backoff = initial_backoff_sec * (1 << attempt) time.sleep(backoff) raise RuntimeError(f"retry: exceeded {max_attempts} attempts") from last_error def to_timestamp(dt: datetime) -> Timestamp: return Timestamp.from_datetime(dt) def main(): environment_token = os.getenv("ENVIRONMENT_TOKEN") lattice_endpoint = os.getenv("LATTICE_ENDPOINT") sandboxes_token = os.getenv("SANDBOXES_TOKEN") if not environment_token or not lattice_endpoint or not sandboxes_token: print("Missing required environment variables", file=sys.stderr) sys.exit(1) # Create the API client over Connect gRPC with authentication client = create_client(EntityManagerAPIClientSync, lattice_endpoint, environment_token, sandboxes_token) entity_id = str(uuid.uuid4()) radius_degrees = 0.1 count = 0.0 creation_timestamp = to_timestamp(datetime.now(timezone.utc)) while True: latest = datetime.now(timezone.utc) latest_ts = to_timestamp(latest) expiry_ts = to_timestamp(latest + timedelta(minutes=5)) count += 0.1 t = math.pi * count / 180.0 entity = Entity( entity_id=entity_id, description="Friendly airplane", aliases=Aliases(name="Airplane 1"), is_live=True, created_time=creation_timestamp, expiry_time=expiry_ts, ontology=Ontology(template=Template.TRACK, platform_type="Airplane"), mil_view=MilView( disposition=Disposition.FRIENDLY, environment=Environment.AIR, ), location=Location( position=Position( latitude_degrees=50.91402185768586 + (radius_degrees * math.cos(t)), longitude_degrees=0.79203612077257 + (radius_degrees * math.sin(t)), ), ), provenance=Provenance( integration_name="your_integration_name", data_type="your_data_type", source_update_time=latest_ts, ), ) request = PublishEntityRequest(entity=entity) try: retry_with_backoff(lambda: client.publish_entity(request)) print(f"Publishing track with entity ID: {entity_id}") except Exception as error: print(f"Publish failed after retries: {error}", file=sys.stderr) time.sleep(5) if __name__ == "__main__": main() ``` ```rs title={"Rust (gRPC)"} startLine={41} maxLines={26} // This Rust example is compatible with artifacts generated using // the following grpc/rust plugin: https://buf.build/anduril/lattice-sdk/sdks/main:community/neoeinstein-tonic mod bearer_auth; use anduril_lattice_sdk_community_neoeinstein_prost::anduril::entitymanager::v1::{ Aliases, Entity, Location, MilView, Ontology, Position, Provenance, PublishEntityRequest, Template, }; use anduril_lattice_sdk_community_neoeinstein_prost::anduril::ontology::v1::{ Disposition, Environment, }; use anduril_lattice_sdk_community_neoeinstein_tonic::anduril::entitymanager::v1::tonic::entity_manager_api_client::EntityManagerApiClient; use bearer_auth::EnvironmentTokenAuth; use prost_types::Timestamp; use tonic::metadata::MetadataValue; use tonic::service::interceptor::InterceptedService; use tonic::transport::{Channel, ClientTlsConfig}; use tonic::{Code, Request}; use uuid::Uuid; use std::env; use std::f64::consts::PI; use std::future::Future; use std::time::{Duration, SystemTime, UNIX_EPOCH}; fn now_timestamp() -> Timestamp { let d = SystemTime::now().duration_since(UNIX_EPOCH).unwrap(); Timestamp { seconds: d.as_secs() as i64, nanos: d.subsec_nanos() as i32, } } fn add_seconds(ts: &Timestamp, seconds: i64) -> Timestamp { Timestamp { seconds: ts.seconds + seconds, nanos: ts.nanos, } } // RetryOpts controls the retry behavior of retry_with_backoff. #[derive(Clone, Copy)] struct RetryOpts { max_attempts: u32, initial_backoff: Duration, } // retryable_code lists transient server-side and connectivity errors that are // safe to retry. NotFound is included because a resource may not yet be visible // due to eventual consistency. Terminal codes (InvalidArgument, PermissionDenied, // Unauthenticated, FailedPrecondition, AlreadyExists) are intentionally absent — // retrying them will not change the outcome. fn is_retryable(code: Code) -> bool { matches!( code, Code::Unavailable | Code::DeadlineExceeded | Code::ResourceExhausted | Code::Internal | Code::Aborted | Code::Unknown | Code::NotFound ) } async fn retry_with_backoff( mut op: F, opts: RetryOpts, ) -> Result where F: FnMut() -> Fut, Fut: Future>, { let mut attempt = 0; loop { match op().await { Ok(value) => return Ok(value), Err(status) => { if !is_retryable(status.code()) { // Terminal error — the request itself is wrong; retrying will not help. return Err(status); } attempt += 1; if attempt >= opts.max_attempts { return Err(status); } let backoff = opts.initial_backoff * (1 << (attempt - 1)); tokio::time::sleep(backoff).await; } } } } #[tokio::main] async fn main() -> Result<(), Box> { let environment_token = env::var("ENVIRONMENT_TOKEN") .expect("ENVIRONMENT_TOKEN environment variable not set"); let lattice_endpoint = env::var("LATTICE_ENDPOINT") .expect("LATTICE_ENDPOINT environment variable not set"); let sandboxes_token = env::var("SANDBOXES_TOKEN") .expect("SANDBOXES_TOKEN environment variable not set"); let auth = EnvironmentTokenAuth::new( environment_token, sandboxes_token, ); let tls_config = ClientTlsConfig::new().with_native_roots(); let channel = Channel::from_shared(format!("https://{}", lattice_endpoint))? .tls_config(tls_config)? .connect() .await?; let access_token = auth.get_token().await?; let sandboxes_token = auth.sandboxes_token(); let auth_header: MetadataValue<_> = format!("Bearer {}", access_token).parse()?; let sandbox_header: MetadataValue<_> = format!("Bearer {}", sandboxes_token).parse()?; let client: EntityManagerApiClient> = EntityManagerApiClient::with_interceptor( channel, move |mut req: Request<()>| { req.metadata_mut() .insert("authorization", auth_header.clone()); req.metadata_mut() .insert("anduril-sandbox-authorization", sandbox_header.clone()); Ok(req) }, ); let entity_id = Uuid::new_v4().to_string(); let radius_degrees = 0.1; let mut count = 0.0_f64; let creation_timestamp = now_timestamp(); loop { let latest_timestamp = now_timestamp(); count += 0.1; let t = PI * count / 180.0; let entity = Entity { entity_id: entity_id.clone(), description: "Friendly airplane".to_string(), aliases: Some(Aliases { name: "Airplane 1".to_string(), ..Default::default() }), is_live: true, created_time: Some(creation_timestamp.clone()), expiry_time: Some(add_seconds(&latest_timestamp, 300)), ontology: Some(Ontology { template: Template::Track as i32, platform_type: "Airplane".to_string(), ..Default::default() }), mil_view: Some(MilView { disposition: Disposition::Friendly as i32, environment: Environment::Air as i32, ..Default::default() }), location: Some(Location { position: Some(Position { latitude_degrees: 50.91402185768586 + radius_degrees * t.cos(), longitude_degrees: 0.79203612077257 + radius_degrees * t.sin(), ..Default::default() }), ..Default::default() }), provenance: Some(Provenance { integration_name: "your_integration_name".to_string(), data_type: "your_data_type".to_string(), source_update_time: Some(latest_timestamp.clone()), ..Default::default() }), ..Default::default() }; let publish_request = PublishEntityRequest { entity: Some(entity), }; let result = retry_with_backoff( || { let mut client = client.clone(); let request = publish_request.clone(); async move { client.publish_entity(Request::new(request)).await.map(|_| ()) } }, RetryOpts { max_attempts: 3, initial_backoff: Duration::from_secs(1), }, ) .await; match result { Ok(_) => println!("Publishing track with entity ID: {}", entity_id), Err(e) => eprintln!("Publish failed after retries: {}", e), } tokio::time::sleep(Duration::from_secs(5)).await; } } ``` #### Classify which errors to retry Not all errors benefit from a retry. Retrying a malformed request or an unauthorized call wastes time and can obscure bugs. Classify errors into two buckets, **retryable** and **terminal**, before deciding whether to back off or re-raise immediately. **The REST SDKs** This utility catches two broad error categories: * `anduril.ApiError` with a `status_code`: Inspect the code to decide. * `httpx.ConnectError` and `httpx.RemoteProtocolError`: Low-level transport failures that are always retryable. Retryable status codes that should be retried: * `404` Not Found: The resource might not yet be visible due to eventual consistency. * `408` Request Timeout: The server did not receive the complete request in time, often due to transient network latency. * `409` Conflict: A concurrent write contended with this request, so retrying after a short backoff often succeeds. * `429` Too Many Requests: The client has been rate-limited, so back off before retrying. * `5xx` Server Error: The server encountered a transient failure that might clear on a subsequent attempt. Terminal status codes that should **not** be retried: * `400` Bad Request: The payload is malformed. * `401` Unauthorized: Refresh credentials before retrying. * `403` Forbidden: Retrying will not change permissions. * `413` Payload Too Large: Reduce the payload size. **The gRPC SDKs** Extract the gRPC status code with `status.Code(err)` and check it against a `map[codes.Code]bool`. Retryable codes that should be retried: * `UNAVAILABLE`: The server is temporarily unreachable. * `DEADLINE_EXCEEDED`: The call timed out before a response was received. * `RESOURCE_EXHAUSTED`: The client has been rate-limited or has exceeded a quota. * `INTERNAL`: The server encountered an unspecified failure that may be transient. * `ABORTED`: A concurrency conflict occurred, so retrying after a short backoff often succeeds. * `UNKNOWN`: An unrecognized error was returned, so treat it as transient. * `NOT_FOUND`: The resource might not yet be visible due to eventual consistency. Terminal codes that should **not** be retried: * `INVALID_ARGUMENT`: Fix the request before retrying. * `PERMISSION_DENIED`: Retrying will not grant access. * `UNAUTHENTICATED`: Refresh the token before retrying. * `FAILED_PRECONDITION`: A required precondition is not met. * `ALREADY_EXISTS`: The resource already exists. #### Apply the utility to any API call Wrap the API call in a zero-argument, callable function, and pass it to the utility. The utility handles timing and error classification. ```py title={"Python (REST)"} startLine={99} maxLines={10} from anduril import Lattice, \ Aliases, MilView, Location, Position, Ontology, Provenance from anduril.core import ApiError from datetime import datetime, timezone, timedelta import asyncio import httpx import math import os import sys from uuid import uuid4 lattice_endpoint = os.getenv('LATTICE_ENDPOINT') environment_token = os.getenv('ENVIRONMENT_TOKEN') # Remove sandboxes_token from the following statements if you are not developing on Sandboxes. sandboxes_token = os.getenv('SANDBOXES_TOKEN') if not environment_token or not lattice_endpoint or not sandboxes_token: print("Missing required environment variables.") sys.exit(1) client = Lattice( base_url=f"https://{lattice_endpoint}", token=lambda: str(environment_token), # Remove the following header if you are not developing on Sandboxes. headers={ "anduril-sandbox-authorization": f"Bearer {sandboxes_token}" } ) MAX_RETRIES = 3 INITIAL_BACKOFF_SECONDS = 1.0 TERMINAL_STATUS_CODES = {400, 401, 403, 413} async def retry_with_backoff(operation, *, max_retries=MAX_RETRIES, initial_backoff=INITIAL_BACKOFF_SECONDS): for attempt in range(max_retries): try: return operation() except ApiError as e: # Terminal 4xx errors mean the request itself is wrong; retrying will not help. if e.status_code in TERMINAL_STATUS_CODES: raise if attempt < max_retries - 1: backoff = initial_backoff * (2 ** attempt) await asyncio.sleep(backoff) else: raise except (httpx.ConnectError, httpx.RemoteProtocolError): if attempt < max_retries - 1: backoff = initial_backoff * (2 ** attempt) await asyncio.sleep(backoff) else: raise entity_id = str(uuid4()) radius_degrees = 0.1 creation_time = datetime.now(timezone.utc) count = 0.0 def publish_track(): global count count += 0.1 t = math.radians(count) latest_timestamp = datetime.now(timezone.utc) client.entities.publish_entity( entity_id=entity_id, description="Friendly airplane", is_live=True, aliases=Aliases( name="Airplane 1" ), created_time=creation_time, expiry_time=latest_timestamp + timedelta(minutes=5), ontology=Ontology( template="TEMPLATE_TRACK", platform_type="Airplane" ), mil_view=MilView( disposition="DISPOSITION_FRIENDLY", environment="ENVIRONMENT_AIR" ), location=Location( position=Position( latitude_degrees=50.91402185768586 + (radius_degrees * math.cos(t)), longitude_degrees=0.79203612077257 + (radius_degrees * math.sin(t)) ) ), provenance=Provenance( integration_name="your_integration_name", data_type="your_data_type", source_update_time=latest_timestamp ), # The utility owns the retry policy, so disable the SDK's built-in retry layer to avoid double-retrying. request_options={"max_retries": 0} ) async def app(): try: while True: await retry_with_backoff(publish_track) print(f"Published track with entity ID: {entity_id}") await asyncio.sleep(5) except asyncio.CancelledError: print(">>>Exiting...") except Exception as error: print(f"Exception: {error}") if __name__ == "__main__": asyncio.run(app()) ``` ```rs title={"Rust (REST)"} startLine={102} maxLines={10} use anduril_lattice_sdk::prelude::*; use std::env; use std::time::Duration; use uuid::Uuid; const MAX_RETRIES: u32 = 3; const INITIAL_BACKOFF_SECONDS: f64 = 1.0; /// Returns true for terminal 4xx errors that mean the request itself is wrong, /// so retrying will not help (400 Bad Request, 401 Unauthorized, 403 Forbidden, /// 413 Content Too Large). fn is_terminal(error: &ApiError) -> bool { matches!( error, ApiError::BadRequestError { .. } | ApiError::UnauthorizedError { .. } | ApiError::ContentTooLargeError { .. } | ApiError::Http { status: 403, .. } ) } #[tokio::main] async fn main() -> Result<(), Box> { let lattice_endpoint = env::var("LATTICE_ENDPOINT") .expect("LATTICE_ENDPOINT environment variable not set"); let environment_token = env::var("ENVIRONMENT_TOKEN") .expect("ENVIRONMENT_TOKEN environment variable not set"); // Remove sandboxes_token if you are not developing on Sandboxes. let sandboxes_token = env::var("SANDBOXES_TOKEN") .expect("SANDBOXES_TOKEN environment variable not set"); if environment_token.is_empty() || lattice_endpoint.is_empty() || sandboxes_token.is_empty() { eprintln!("Missing required environment variables:"); eprintln!(" LATTICE_ENDPOINT"); eprintln!(" ENVIRONMENT_TOKEN"); eprintln!(" SANDBOXES_TOKEN"); std::process::exit(1); } let config = ClientConfig { base_url: format!("https://{}", lattice_endpoint), token: Some(environment_token), // Remove custom_headers if you are not developing on Sandboxes. custom_headers: HashMap::from([( "anduril-sandbox-authorization".to_string(), format!("Bearer {}", sandboxes_token), )]), ..Default::default() }; let client = Lattice::new(config)?; let entity_id = Uuid::new_v4().to_string(); let radius_degrees: f64 = 0.1; let creation_time = Utc::now().fixed_offset(); let mut count: f64 = 0.0; loop { count += 0.1; let t = count.to_radians(); let latest_timestamp = Utc::now().fixed_offset(); let entity = Entity { entity_id: Some(entity_id.clone()), description: Some("Friendly airplane".to_string()), is_live: Some(true), aliases: Some(Aliases { name: Some("Airplane 1".to_string()), ..Default::default() }), created_time: Some(creation_time), expiry_time: Some((Utc::now() + chrono::Duration::minutes(5)).fixed_offset()), ontology: Some(Ontology { template: Some(OntologyTemplate::TemplateTrack), platform_type: Some("Airplane".to_string()), ..Default::default() }), mil_view: Some(MilView { disposition: Some(MilViewDisposition::DispositionFriendly), environment: Some(MilViewEnvironment::EnvironmentAir), ..Default::default() }), location: Some(Location { position: Some(Position { latitude_degrees: Some(50.91402185768586 + (radius_degrees * t.cos())), longitude_degrees: Some(0.79203612077257 + (radius_degrees * t.sin())), ..Default::default() }), ..Default::default() }), provenance: Some(Provenance { integration_name: Some("your_integration_name".to_string()), data_type: Some("your_data_type".to_string()), source_update_time: Some(latest_timestamp), ..Default::default() }), ..Default::default() }; // The utility owns the retry policy, so disable the SDK's built-in retry // layer (max_retries = 0) to avoid double-retrying. let client_ref = &client; let publish = || { let entity = entity.clone(); async move { let options = RequestOptions::new().max_retries(0); client_ref.entities.publish_entity(&entity, Some(options)).await } }; match retry_with_backoff(publish).await { Ok(_) => println!("Published track with entity ID: {}", entity_id), Err(error) => eprintln!("Exception: {}", error), } tokio::time::sleep(Duration::from_secs(5)).await; } } /// Run an async operation with exponential backoff, giving up immediately on /// terminal 4xx errors. async fn retry_with_backoff(operation: F) -> Result where F: Fn() -> Fut, Fut: std::future::Future>, { let mut last_error: Option = None; for attempt in 0..MAX_RETRIES { match operation().await { Ok(value) => return Ok(value), Err(error) => { // Terminal 4xx errors mean the request itself is wrong; // retrying will not help. if is_terminal(&error) { return Err(error); } last_error = Some(error); if attempt < MAX_RETRIES - 1 { let backoff = INITIAL_BACKOFF_SECONDS * 2f64.powi(attempt as i32); tokio::time::sleep(Duration::from_secs_f64(backoff)).await; } } } } Err(last_error.expect("retry loop ran at least once")) } ``` ```go title={"Go (gRPC)"} startLine={145} maxLines={10} // This Go example is compatible with artifacts generated using // the following grpc/go plugin: https://buf.build/anduril/lattice-sdk/sdks/main:grpc/go package main import ( "context" "fmt" "log" "math" "os" "time" "schema-registry.developer.anduril.com/gen/go/anduril/lattice-sdk/grpc/go/anduril/entitymanager/v1/entitymanagerv1grpc" entitymanagerv1 "schema-registry.developer.anduril.com/gen/go/anduril/lattice-sdk/protocolbuffers/go/anduril/entitymanager/v1" ontologyv1 "schema-registry.developer.anduril.com/gen/go/anduril/lattice-sdk/protocolbuffers/go/anduril/ontology/v1" "github.com/google/uuid" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" ) // RetryOpts controls the retry behavior of retryWithBackoff. type RetryOpts struct { MaxAttempts int InitialBackoff time.Duration } // retryableCodes lists transient server-side and connectivity errors that are // safe to retry. NotFound is included because a resource may not yet be visible // due to eventual consistency. Terminal codes (InvalidArgument, PermissionDenied, // Unauthenticated, FailedPrecondition, AlreadyExists) are intentionally absent — // retrying them will not change the outcome. var retryableCodes = map[codes.Code]bool{ codes.Unavailable: true, codes.DeadlineExceeded: true, codes.ResourceExhausted: true, codes.Internal: true, codes.Aborted: true, codes.Unknown: true, codes.NotFound: true, } func retryWithBackoff(ctx context.Context, op func(context.Context) error, opts RetryOpts) error { for attempt := 0; attempt < opts.MaxAttempts; attempt++ { err := op(ctx) if err == nil { return nil } code := status.Code(err) if !retryableCodes[code] { // Terminal error — the request itself is wrong; retrying will not help. return err } if attempt == opts.MaxAttempts-1 { return err } backoff := opts.InitialBackoff * (1 << attempt) select { case <-time.After(backoff): case <-ctx.Done(): return ctx.Err() } } return fmt.Errorf("retry: exceeded %d attempts", opts.MaxAttempts) } func main() { environmentToken := os.Getenv("ENVIRONMENT_TOKEN") latticeEndpoint := os.Getenv("LATTICE_ENDPOINT") sandboxesToken := os.Getenv("SANDBOXES_TOKEN") if latticeEndpoint == "" || environmentToken == "" || sandboxesToken == "" { fmt.Println("Missing required environment variables") os.Exit(1) } auth := &BearerTokenAuth{ Token: environmentToken, SandboxesToken: sandboxesToken, } opts := []grpc.DialOption{ grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(nil, "")), grpc.WithPerRPCCredentials(auth), } conn, err := grpc.NewClient(latticeEndpoint, opts...) if err != nil { log.Fatalf("Did not connect: %v", err) } defer conn.Close() client := entitymanagerv1grpc.NewEntityManagerAPIClient(conn) entityId := uuid.New().String() radiusDegrees := 0.1 count := 0.0 creationTime := time.Now().UTC() creationTimestamp := timestamppb.New(creationTime) for { latestTimestamp := time.Now().UTC() ctx := context.Background() count += 0.1 t := math.Pi * count / 180.0 entity := &entitymanagerv1.Entity{ EntityId: entityId, Description: "Friendly airplane", Aliases: &entitymanagerv1.Aliases{ Name: "Airplane 1", }, IsLive: true, CreatedTime: creationTimestamp, ExpiryTime: timestamppb.New(latestTimestamp.Add(5 * time.Minute)), Ontology: &entitymanagerv1.Ontology{ Template: entitymanagerv1.Template_TEMPLATE_TRACK, PlatformType: "Airplane", }, MilView: &entitymanagerv1.MilView{ Disposition: ontologyv1.Disposition_DISPOSITION_FRIENDLY, Environment: ontologyv1.Environment_ENVIRONMENT_AIR, }, Location: &entitymanagerv1.Location{ Position: &entitymanagerv1.Position{ LatitudeDegrees: 50.91402185768586 + (radiusDegrees * math.Cos(t)), LongitudeDegrees: 0.79203612077257 + (radiusDegrees * math.Sin(t)), }, }, Provenance: &entitymanagerv1.Provenance{ IntegrationName: "your_integration_name", DataType: "your_data_type", SourceUpdateTime: timestamppb.New(latestTimestamp), }, } request := &entitymanagerv1.PublishEntityRequest{Entity: entity} err := retryWithBackoff(ctx, func(ctx context.Context) error { _, err := client.PublishEntity(ctx, request) return err }, RetryOpts{MaxAttempts: 3, InitialBackoff: time.Second}) if err != nil { fmt.Printf("Publish failed after retries: %v\n", err) } else { fmt.Printf("Publishing track with entity ID: %s\n", entityId) } time.Sleep(5 * time.Second) } } ``` ```py title={"Python (gRPC)"} startLine={122} maxLines={7} # This Python example is compatible with artifacts generated using the following # Buf plugins: https://schema-registry.developer.anduril.com/anduril/lattice-sdk/sdks/main:bufbuild/py # and https://schema-registry.developer.anduril.com/anduril/lattice-sdk/sdks/main:connectrpc/py import math import os import sys import time import uuid from datetime import datetime, timedelta, timezone from bearer_auth import create_client from connectrpc.code import Code from connectrpc.errors import ConnectError from protobuf.wkt import Timestamp from anduril.entitymanager.v1.entity_pub_pb import Aliases, Entity, Provenance from anduril.entitymanager.v1.entity_manager_api_pub_pb import PublishEntityRequest from anduril.entitymanager.v1.entity_manager_api_pub_connect import EntityManagerAPIClientSync from anduril.entitymanager.v1.location_pub_pb import Location, Position from anduril.entitymanager.v1.ontology_pub_pb import MilView, Ontology from anduril.entitymanager.v1.types_pub_pb import Template from anduril.ontology.v1.type_pub_pb import Disposition, Environment # Transient server-side and connectivity errors that are safe to retry. # NOT_FOUND is included because a resource may not yet be visible due to eventual # consistency. Terminal codes (INVALID_ARGUMENT, PERMISSION_DENIED, UNAUTHENTICATED, # FAILED_PRECONDITION, ALREADY_EXISTS) are intentionally absent — retrying them # will not change the outcome. RETRYABLE_CODES = { Code.UNAVAILABLE, Code.DEADLINE_EXCEEDED, Code.RESOURCE_EXHAUSTED, Code.INTERNAL, Code.ABORTED, Code.UNKNOWN, Code.NOT_FOUND, } def retry_with_backoff(op, max_attempts=3, initial_backoff_sec=1.0): """Retry a callable with exponential backoff on retryable Connect errors.""" last_error: Exception | None = None for attempt in range(max_attempts): try: return op() except ConnectError as error: last_error = error code = error.code if code not in RETRYABLE_CODES: # Terminal error — the request itself is wrong; retrying will not help. raise if attempt == max_attempts - 1: raise backoff = initial_backoff_sec * (1 << attempt) time.sleep(backoff) raise RuntimeError(f"retry: exceeded {max_attempts} attempts") from last_error def to_timestamp(dt: datetime) -> Timestamp: return Timestamp.from_datetime(dt) def main(): environment_token = os.getenv("ENVIRONMENT_TOKEN") lattice_endpoint = os.getenv("LATTICE_ENDPOINT") sandboxes_token = os.getenv("SANDBOXES_TOKEN") if not environment_token or not lattice_endpoint or not sandboxes_token: print("Missing required environment variables", file=sys.stderr) sys.exit(1) # Create the API client over Connect gRPC with authentication client = create_client(EntityManagerAPIClientSync, lattice_endpoint, environment_token, sandboxes_token) entity_id = str(uuid.uuid4()) radius_degrees = 0.1 count = 0.0 creation_timestamp = to_timestamp(datetime.now(timezone.utc)) while True: latest = datetime.now(timezone.utc) latest_ts = to_timestamp(latest) expiry_ts = to_timestamp(latest + timedelta(minutes=5)) count += 0.1 t = math.pi * count / 180.0 entity = Entity( entity_id=entity_id, description="Friendly airplane", aliases=Aliases(name="Airplane 1"), is_live=True, created_time=creation_timestamp, expiry_time=expiry_ts, ontology=Ontology(template=Template.TRACK, platform_type="Airplane"), mil_view=MilView( disposition=Disposition.FRIENDLY, environment=Environment.AIR, ), location=Location( position=Position( latitude_degrees=50.91402185768586 + (radius_degrees * math.cos(t)), longitude_degrees=0.79203612077257 + (radius_degrees * math.sin(t)), ), ), provenance=Provenance( integration_name="your_integration_name", data_type="your_data_type", source_update_time=latest_ts, ), ) request = PublishEntityRequest(entity=entity) try: retry_with_backoff(lambda: client.publish_entity(request)) print(f"Publishing track with entity ID: {entity_id}") except Exception as error: print(f"Publish failed after retries: {error}", file=sys.stderr) time.sleep(5) if __name__ == "__main__": main() ``` ```rs title={"Rust (gRPC)"} startLine={182} maxLines={10} // This Rust example is compatible with artifacts generated using // the following grpc/rust plugin: https://buf.build/anduril/lattice-sdk/sdks/main:community/neoeinstein-tonic mod bearer_auth; use anduril_lattice_sdk_community_neoeinstein_prost::anduril::entitymanager::v1::{ Aliases, Entity, Location, MilView, Ontology, Position, Provenance, PublishEntityRequest, Template, }; use anduril_lattice_sdk_community_neoeinstein_prost::anduril::ontology::v1::{ Disposition, Environment, }; use anduril_lattice_sdk_community_neoeinstein_tonic::anduril::entitymanager::v1::tonic::entity_manager_api_client::EntityManagerApiClient; use bearer_auth::EnvironmentTokenAuth; use prost_types::Timestamp; use tonic::metadata::MetadataValue; use tonic::service::interceptor::InterceptedService; use tonic::transport::{Channel, ClientTlsConfig}; use tonic::{Code, Request}; use uuid::Uuid; use std::env; use std::f64::consts::PI; use std::future::Future; use std::time::{Duration, SystemTime, UNIX_EPOCH}; fn now_timestamp() -> Timestamp { let d = SystemTime::now().duration_since(UNIX_EPOCH).unwrap(); Timestamp { seconds: d.as_secs() as i64, nanos: d.subsec_nanos() as i32, } } fn add_seconds(ts: &Timestamp, seconds: i64) -> Timestamp { Timestamp { seconds: ts.seconds + seconds, nanos: ts.nanos, } } // RetryOpts controls the retry behavior of retry_with_backoff. #[derive(Clone, Copy)] struct RetryOpts { max_attempts: u32, initial_backoff: Duration, } // retryable_code lists transient server-side and connectivity errors that are // safe to retry. NotFound is included because a resource may not yet be visible // due to eventual consistency. Terminal codes (InvalidArgument, PermissionDenied, // Unauthenticated, FailedPrecondition, AlreadyExists) are intentionally absent — // retrying them will not change the outcome. fn is_retryable(code: Code) -> bool { matches!( code, Code::Unavailable | Code::DeadlineExceeded | Code::ResourceExhausted | Code::Internal | Code::Aborted | Code::Unknown | Code::NotFound ) } async fn retry_with_backoff( mut op: F, opts: RetryOpts, ) -> Result where F: FnMut() -> Fut, Fut: Future>, { let mut attempt = 0; loop { match op().await { Ok(value) => return Ok(value), Err(status) => { if !is_retryable(status.code()) { // Terminal error — the request itself is wrong; retrying will not help. return Err(status); } attempt += 1; if attempt >= opts.max_attempts { return Err(status); } let backoff = opts.initial_backoff * (1 << (attempt - 1)); tokio::time::sleep(backoff).await; } } } } #[tokio::main] async fn main() -> Result<(), Box> { let environment_token = env::var("ENVIRONMENT_TOKEN") .expect("ENVIRONMENT_TOKEN environment variable not set"); let lattice_endpoint = env::var("LATTICE_ENDPOINT") .expect("LATTICE_ENDPOINT environment variable not set"); let sandboxes_token = env::var("SANDBOXES_TOKEN") .expect("SANDBOXES_TOKEN environment variable not set"); let auth = EnvironmentTokenAuth::new( environment_token, sandboxes_token, ); let tls_config = ClientTlsConfig::new().with_native_roots(); let channel = Channel::from_shared(format!("https://{}", lattice_endpoint))? .tls_config(tls_config)? .connect() .await?; let access_token = auth.get_token().await?; let sandboxes_token = auth.sandboxes_token(); let auth_header: MetadataValue<_> = format!("Bearer {}", access_token).parse()?; let sandbox_header: MetadataValue<_> = format!("Bearer {}", sandboxes_token).parse()?; let client: EntityManagerApiClient> = EntityManagerApiClient::with_interceptor( channel, move |mut req: Request<()>| { req.metadata_mut() .insert("authorization", auth_header.clone()); req.metadata_mut() .insert("anduril-sandbox-authorization", sandbox_header.clone()); Ok(req) }, ); let entity_id = Uuid::new_v4().to_string(); let radius_degrees = 0.1; let mut count = 0.0_f64; let creation_timestamp = now_timestamp(); loop { let latest_timestamp = now_timestamp(); count += 0.1; let t = PI * count / 180.0; let entity = Entity { entity_id: entity_id.clone(), description: "Friendly airplane".to_string(), aliases: Some(Aliases { name: "Airplane 1".to_string(), ..Default::default() }), is_live: true, created_time: Some(creation_timestamp.clone()), expiry_time: Some(add_seconds(&latest_timestamp, 300)), ontology: Some(Ontology { template: Template::Track as i32, platform_type: "Airplane".to_string(), ..Default::default() }), mil_view: Some(MilView { disposition: Disposition::Friendly as i32, environment: Environment::Air as i32, ..Default::default() }), location: Some(Location { position: Some(Position { latitude_degrees: 50.91402185768586 + radius_degrees * t.cos(), longitude_degrees: 0.79203612077257 + radius_degrees * t.sin(), ..Default::default() }), ..Default::default() }), provenance: Some(Provenance { integration_name: "your_integration_name".to_string(), data_type: "your_data_type".to_string(), source_update_time: Some(latest_timestamp.clone()), ..Default::default() }), ..Default::default() }; let publish_request = PublishEntityRequest { entity: Some(entity), }; let result = retry_with_backoff( || { let mut client = client.clone(); let request = publish_request.clone(); async move { client.publish_entity(Request::new(request)).await.map(|_| ()) } }, RetryOpts { max_attempts: 3, initial_backoff: Duration::from_secs(1), }, ) .await; match result { Ok(_) => println!("Publishing track with entity ID: {}", entity_id), Err(e) => eprintln!("Publish failed after retries: {}", e), } tokio::time::sleep(Duration::from_secs(5)).await; } } ``` A single utility keeps retry behavior consistent across your integration, so tuning the backoff policy or adding logging requires only a change in one place. ## What's next * Learn how to [publish entities](/guides/entities/publish) to Lattice. * Review [Authentication](/guides/getting-started/authenticate) to understand how credentials are refreshed. * Check [Choose a protocol](/guides/best-practices/choose-a-protocol) to decide between REST and gRPC for your integration. > Build a reusable retry utility to handle transient failures across every Lattice SDK call.