Clients
The server speaks HTTP on port 8000 and gRPC on port 50051 from a single binary. HTTP is the default and what 95% of users want — the recipes below cover persistent connections and fan-out. gRPC lives in the Advanced section at the bottom of the page.
Every endpoint, query parameter, and response schema lives in the API Reference. This page covers what the reference cannot: keep-alive setup, concurrent fan-out, and protobuf codegen.
HTTP
Use a long-lived client object. A loop of fresh requests pays the TCP handshake every time and can overwhelm the server. All standard HTTP libraries reuse connections by default if you reuse the client.
Python SDK
Install pip install turboocr to get a typed client with retries built in. Full reference at the SDK docs.
from turboocr import Client
with Client( base_url="http://localhost:8000", api_key="tocr_live_...",) as client: response = client.recognize_image("invoice.jpg") print(response.results[0].text)Python
Without the SDK, build one requests.Session at startup and share it. The session pools TCP connections and reuses them across calls, so a tight loop pays the handshake exactly once.
pip install "requests>=2.32"import requests
SESSION = requests.Session()SESSION.headers.update({"Connection": "keep-alive"})BASE_URL = "http://localhost:8000"
def ocr_raw(path: str, layout: bool = False) -> dict: with open(path, "rb") as f: data = f.read() r = SESSION.post( f"{BASE_URL}/ocr/raw", data=data, headers={"Content-Type": "image/png"}, params={"layout": 1} if layout else None, timeout=30, ) r.raise_for_status() return r.json()
def ocr_pdf(path: str, mode: str = "ocr", dpi: int = 100) -> dict: with open(path, "rb") as f: data = f.read() r = SESSION.post( f"{BASE_URL}/ocr/pdf", data=data, params={"mode": mode, "dpi": dpi}, timeout=120, ) r.raise_for_status() return r.json()
print(ocr_raw("invoice.png"))For fan-out, push the same SESSION through a ThreadPoolExecutor. The session is thread-safe for the simple POST pattern below, and the worker count caps how many requests the server sees in flight.
from concurrent.futures import ThreadPoolExecutor
def ocr_many(paths: list[str], workers: int = 8) -> list[dict]: with ThreadPoolExecutor(max_workers=workers) as pool: return list(pool.map(ocr_raw, paths))
results = ocr_many(["a.png", "b.png", "c.png", "d.png"])Java
One HttpClient per process. It holds an internal connection pool, is thread-safe, and negotiates HTTP/1.1 keep-alive transparently. Calling HttpClient.newHttpClient() per request throws away the pool and opens a fresh socket. Targets Java 21+; the fan-out example uses virtual threads.
import java.net.URI;import java.net.http.HttpClient;import java.net.http.HttpRequest;import java.net.http.HttpResponse;import java.nio.file.Files;import java.nio.file.Path;import java.time.Duration;
public final class TurboOcr { public static final HttpClient HTTP = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build();
public static String ocrRaw(Path image) throws Exception { var req = HttpRequest.newBuilder(URI.create("http://localhost:8000/ocr/raw")) .header("Content-Type", "image/png") .timeout(Duration.ofSeconds(30)) .POST(HttpRequest.BodyPublishers.ofByteArray(Files.readAllBytes(image))) .build(); return HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body(); }
public static void main(String[] args) throws Exception { System.out.println(ocrRaw(Path.of("invoice.png"))); }}For fan-out, submit one task per image to a virtual-thread executor. The shared HttpClient keeps concurrent blocking calls cheap; thousands of in-flight requests cost no extra platform threads.
import java.nio.file.Path;import java.util.List;import java.util.concurrent.Executors;import java.util.concurrent.Future;
public class FanOut { public static void main(String[] args) throws Exception { var paths = List.of(Path.of("a.png"), Path.of("b.png"), Path.of("c.png")); try (var pool = Executors.newVirtualThreadPerTaskExecutor()) { List<Future<String>> futures = paths.stream() .map(p -> pool.submit(() -> TurboOcr.ocrRaw(p))) .toList(); for (var f : futures) System.out.println(f.get()); } }}C++
Hold one CURL* easy handle per worker thread for the process lifetime. libcurl reuses the underlying TCP connection as long as the handle is alive. CURLOPT_TCP_KEEPALIVE stops NAT gateways from silently dropping idle sockets. Compile with -std=c++20.
apt install libcurl4-openssl-dev// Compile: g++ -std=c++20 client.cc -lcurl#include <curl/curl.h>#include <fstream>#include <sstream>#include <stdexcept>#include <string>
static size_t write_cb(char* ptr, size_t size, size_t nmemb, void* ud) { static_cast<std::string*>(ud)->append(ptr, size * nmemb); return size * nmemb;}
class Client {public: Client() : curl_(curl_easy_init()) { if (!curl_) throw std::runtime_error("curl_easy_init failed"); curl_easy_setopt(curl_, CURLOPT_TCP_KEEPALIVE, 1L); curl_easy_setopt(curl_, CURLOPT_TCP_KEEPIDLE, 30L); curl_easy_setopt(curl_, CURLOPT_WRITEFUNCTION, write_cb); } ~Client() { if (curl_) curl_easy_cleanup(curl_); } Client(const Client&) = delete; Client& operator=(const Client&) = delete;
std::string ocr_raw(const std::string& path) { std::ifstream f(path, std::ios::binary); std::ostringstream ss; ss << f.rdbuf(); std::string body = ss.str();
std::string response; curl_slist* hdrs = curl_slist_append(nullptr, "Content-Type: image/png");
curl_easy_setopt(curl_, CURLOPT_URL, "http://localhost:8000/ocr/raw"); curl_easy_setopt(curl_, CURLOPT_POST, 1L); curl_easy_setopt(curl_, CURLOPT_HTTPHEADER, hdrs); curl_easy_setopt(curl_, CURLOPT_POSTFIELDS, body.data()); curl_easy_setopt(curl_, CURLOPT_POSTFIELDSIZE, (long)body.size()); curl_easy_setopt(curl_, CURLOPT_WRITEDATA, &response); curl_easy_setopt(curl_, CURLOPT_TIMEOUT, 30L);
CURLcode rc = curl_easy_perform(curl_); curl_slist_free_all(hdrs); if (rc != CURLE_OK) throw std::runtime_error(curl_easy_strerror(rc)); return response; }
private: CURL* curl_ = nullptr;};
int main() { Client c; std::printf("%s\n", c.ocr_raw("invoice.png").c_str());}For fan-out, one handle per thread, driven by std::async. For a single-threaded event loop, curl_multi_* drives many transfers on one thread instead.
#include <future>#include <string>#include <vector>
std::vector<std::string> ocr_many(const std::vector<std::string>& paths) { std::vector<std::future<std::string>> futures; for (const auto& p : paths) { futures.push_back(std::async(std::launch::async, [p] { thread_local Client c; // one handle per worker thread return c.ocr_raw(p); })); } std::vector<std::string> out; out.reserve(futures.size()); for (auto& f : futures) out.push_back(f.get()); return out;}Advanced — gRPC
Use gRPC if you need streaming, smaller wire size at very high QPS, or you already standardize on protobuf across your services. For most workloads, HTTP is simpler and equally fast. The service definition lives in ocr.proto — download it and run the codegen for your language.
Python (gRPC)
pip install "grpcio>=1.68" "grpcio-tools>=1.68" "protobuf>=5.28"python -m grpc_tools.protoc -I proto \ --python_out=. --grpc_python_out=. \ proto/ocr.protoThat emits ocr_pb2.py and ocr_pb2_grpc.py. Reuse a single channel per process; it pools HTTP/2 streams internally.
import grpcfrom concurrent.futures import ThreadPoolExecutorimport ocr_pb2import ocr_pb2_grpc
CHANNEL = grpc.insecure_channel( "localhost:50051", options=[ ("grpc.keepalive_time_ms", 30_000), ("grpc.max_receive_message_length", 50 * 1024 * 1024), ],)STUB = ocr_pb2_grpc.OCRServiceStub(CHANNEL)
def recognize(path: str) -> ocr_pb2.OCRResponse: with open(path, "rb") as f: return STUB.Recognize( ocr_pb2.OCRRequest(image=f.read(), layout=False), timeout=30, )
def recognize_many(paths: list[str], workers: int = 8): with ThreadPoolExecutor(max_workers=workers) as pool: return list(pool.map(recognize, paths))
results = recognize_many(["a.png", "b.png", "c.png"])Java (gRPC)
Save ocr.proto under src/main/proto/ and let the protobuf Gradle plugin handle codegen on every build.
# build.gradleplugins { id 'com.google.protobuf' version '0.9.4' }
dependencies { implementation 'io.grpc:grpc-netty-shaded:1.70.0' implementation 'io.grpc:grpc-protobuf:1.70.0' implementation 'io.grpc:grpc-stub:1.70.0' implementation 'com.google.protobuf:protobuf-java:4.28.3'}
protobuf { protoc { artifact = 'com.google.protobuf:protoc:4.28.3' } plugins { grpc { artifact = 'io.grpc:protoc-gen-grpc-java:1.70.0' } } generateProtoTasks { all().each { task -> task.plugins { grpc {} } } }}One ManagedChannel per process, multiplexed across stubs and threads.
import com.google.protobuf.ByteString;import io.grpc.ManagedChannel;import io.grpc.ManagedChannelBuilder;import ocr.Ocr.OCRRequest;import ocr.Ocr.OCRResponse;import ocr.OCRServiceGrpc;
import java.nio.file.Files;import java.nio.file.Path;import java.util.List;import java.util.concurrent.Executors;import java.util.concurrent.Future;import java.util.concurrent.TimeUnit;
public class GrpcFanOut { public static void main(String[] args) throws Exception { ManagedChannel channel = ManagedChannelBuilder .forAddress("localhost", 50051) .usePlaintext() .keepAliveTime(30, TimeUnit.SECONDS) .maxInboundMessageSize(50 * 1024 * 1024) .build(); var stub = OCRServiceGrpc.newBlockingStub(channel);
var paths = List.of(Path.of("a.png"), Path.of("b.png"), Path.of("c.png")); try (var pool = Executors.newVirtualThreadPerTaskExecutor()) { List<Future<OCRResponse>> futures = paths.stream() .map(p -> pool.submit(() -> { var req = OCRRequest.newBuilder() .setImage(ByteString.copyFrom(Files.readAllBytes(p))) .setLayout(false) .build(); return stub.recognize(req); })) .toList(); for (var f : futures) System.out.println(f.get().getNumDetections()); } channel.shutdown().awaitTermination(5, TimeUnit.SECONDS); }}C++ (gRPC)
apt install protobuf-compiler-grpc libgrpc++-devprotoc -I proto \ --cpp_out=. --grpc_out=. \ --plugin=protoc-gen-grpc=$(which grpc_cpp_plugin) \ proto/ocr.protoBuild with g++ -std=c++20 ... -lgrpc++ -lprotobuf. Reuse a single std::shared_ptr<grpc::Channel> per process.
// Compile: g++ -std=c++20 grpc_client.cc ocr.pb.cc ocr.grpc.pb.cc -lgrpc++ -lprotobuf -lpthread#include <grpcpp/grpcpp.h>#include "ocr.grpc.pb.h"#include <chrono>#include <fstream>#include <future>#include <sstream>#include <vector>
static std::string slurp(const std::string& p) { std::ifstream f(p, std::ios::binary); std::ostringstream ss; ss << f.rdbuf(); return ss.str();}
int main() { grpc::ChannelArguments args; args.SetInt(GRPC_ARG_KEEPALIVE_TIME_MS, 30'000); args.SetInt(GRPC_ARG_MAX_RECEIVE_MESSAGE_LENGTH, 50 * 1024 * 1024); auto channel = grpc::CreateCustomChannel( "localhost:50051", grpc::InsecureChannelCredentials(), args); auto stub = ocr::OCRService::NewStub(channel);
auto recognize = [&](const std::string& path) { ocr::OCRRequest req; req.set_image(slurp(path)); ocr::OCRResponse resp; grpc::ClientContext ctx; ctx.set_deadline(std::chrono::system_clock::now() + std::chrono::seconds(30)); stub->Recognize(&ctx, req, &resp); return resp.num_detections(); };
std::vector<std::string> paths = {"a.png", "b.png", "c.png"}; std::vector<std::future<int>> futures; for (const auto& p : paths) { futures.push_back(std::async(std::launch::async, recognize, p)); } for (auto& f : futures) std::printf("%d\n", f.get());}For per-endpoint request and response details, see the API Reference.