Client API Reference¶
The client module provides a high-level API for fetching Gemini resources with support for TOFU certificate validation, redirects, and timeouts.
Overview¶
The Nauyaca client is built on Python's asyncio.Protocol and asyncio.Transport pattern for efficient, non-blocking I/O. It provides:
- High-level async/await interface via
GeminiClient - TOFU (Trust On First Use) validation for secure connections without CA infrastructure
- Automatic redirect following with loop detection
- Client certificate authentication for restricted resources
- Configurable timeouts and connection settings
GeminiClient¶
GeminiClient
¶
GeminiClient(
timeout: float = 30.0,
max_redirects: int = MAX_REDIRECTS,
ssl_context: SSLContext | None = None,
verify_ssl: bool = False,
trust_on_first_use: bool = True,
tofu_db_path: Path | None = None,
client_cert: Path | str | None = None,
client_key: Path | str | None = None,
)
High-level Gemini client with async/await API.
This class provides a simple, high-level interface for getting Gemini resources. It handles connection management, TLS, redirects, and timeouts.
Examples:
>>> # Basic usage
>>> async with GeminiClient() as client:
... response = await client.get('gemini://example.com/')
... print(response.body)
>>> # With custom timeout and redirect settings
>>> client = GeminiClient(timeout=30, max_redirects=3)
>>> response = await client.get('gemini://example.com/')
>>> # Disable redirect following
>>> response = await client.get(
... 'gemini://example.com/',
... follow_redirects=False
... )
Initialize the Gemini client.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
timeout
|
float
|
Request timeout in seconds. Default is 30 seconds. |
30.0
|
max_redirects
|
int
|
Maximum number of redirects to follow. Default is 5. |
MAX_REDIRECTS
|
ssl_context
|
SSLContext | None
|
Custom SSL context. If None, a default context will be created based on verify_ssl and trust_on_first_use settings. |
None
|
verify_ssl
|
bool
|
Whether to verify SSL certificates using CA validation. Default is False. For Gemini, you should use TOFU instead. |
False
|
trust_on_first_use
|
bool
|
Whether to use TOFU certificate validation. Default is True. This is the recommended mode for Gemini. |
True
|
tofu_db_path
|
Path | None
|
Path to TOFU database. If None, uses default location (~/.nauyaca/tofu.db). |
None
|
client_cert
|
Path | str | None
|
Path to client certificate file (PEM format) for authentication with servers that require client certificates. |
None
|
client_key
|
Path | str | None
|
Path to client private key file (PEM format). Required if client_cert is provided. |
None
|
Source code in src/nauyaca/client/session.py
__aenter__
async
¶
__aexit__
async
¶
delete
async
¶
Delete a resource via zero-byte Titan upload.
In the Titan protocol, a zero-byte upload indicates deletion. The server may or may not support delete operations.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
url
|
str
|
The target URL. Can be either gemini:// or titan:// scheme. |
required |
token
|
str | None
|
Optional authentication token for the server. |
None
|
Returns:
| Type | Description |
|---|---|
GeminiResponse
|
A GeminiResponse from the server indicating success or failure. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the URL is invalid. |
TimeoutError
|
If the request times out. |
ConnectionError
|
If the connection fails. |
Examples:
>>> # Delete a resource
>>> response = await client.delete(
... 'gemini://example.com/uploads/old-file.gmi',
... token='secret-token',
... )
>>> if response.is_success():
... print("Resource deleted")
Source code in src/nauyaca/client/session.py
get
async
¶
Get a Gemini resource.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
url
|
str
|
The Gemini URL to get. |
required |
follow_redirects
|
bool
|
Whether to automatically follow redirects. Default is True. |
True
|
Returns:
| Type | Description |
|---|---|
GeminiResponse
|
A GeminiResponse object with status, meta, and optional body. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the URL is invalid. |
TimeoutError
|
If the request times out. |
ConnectionError
|
If the connection fails. |
Examples:
>>> response = await client.get('gemini://example.com/')
>>> if response.is_success():
... print(response.body)
Source code in src/nauyaca/client/session.py
upload
async
¶
upload(
url: str,
content: bytes | str,
mime_type: str = "text/gemini",
token: str | None = None,
) -> GeminiResponse
Upload content to a Gemini server via the Titan protocol.
Titan is Gemini's upload companion protocol. It uses the same port (1965) and TLS requirements as Gemini, but allows uploading content.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
url
|
str
|
The target URL. Can be either gemini:// or titan:// scheme. If gemini://, it will be converted to titan://. |
required |
content
|
bytes | str
|
The content to upload. Can be bytes or str (will be encoded as UTF-8). |
required |
mime_type
|
str
|
MIME type of the content. Default is "text/gemini". |
'text/gemini'
|
token
|
str | None
|
Optional authentication token for the server. |
None
|
Returns:
| Type | Description |
|---|---|
GeminiResponse
|
A GeminiResponse from the server indicating success or failure. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the URL is invalid. |
TimeoutError
|
If the request times out. |
ConnectionError
|
If the connection fails. |
Examples:
>>> # Upload text content
>>> response = await client.upload(
... 'gemini://example.com/uploads/note.gmi',
... '# My Note\n\nHello, Geminispace!',
... )
>>> # Upload binary content
>>> with open('image.png', 'rb') as f:
... response = await client.upload(
... 'gemini://example.com/uploads/image.png',
... f.read(),
... mime_type='image/png',
... )
>>> # Upload with authentication
>>> response = await client.upload(
... 'gemini://example.com/uploads/file.txt',
... 'Hello!',
... token='secret-token',
... )
Source code in src/nauyaca/client/session.py
299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 | |
Basic Usage¶
The simplest way to fetch a Gemini resource:
import asyncio
from nauyaca.client.session import GeminiClient
async def main():
async with GeminiClient() as client:
response = await client.get('gemini://geminiprotocol.net/')
if response.is_success():
print(response.body)
else:
print(f"Error {response.status}: {response.meta}")
asyncio.run(main())
Configuration Options¶
Create a client with custom settings:
from pathlib import Path
from nauyaca.client.session import GeminiClient
async def main():
# Client with custom timeout and redirect settings
client = GeminiClient(
timeout=60.0, # 60 second timeout
max_redirects=3, # Follow up to 3 redirects
trust_on_first_use=True, # Enable TOFU (recommended)
tofu_db_path=Path("~/.config/nauyaca/tofu.db").expanduser()
)
response = await client.get('gemini://example.com/path')
Client Certificate Authentication¶
For servers that require client certificates (status 6x):
from pathlib import Path
from nauyaca.client.session import GeminiClient
async def main():
client = GeminiClient(
client_cert=Path("/path/to/client-cert.pem"),
client_key=Path("/path/to/client-key.pem")
)
# Now requests will include your client certificate
response = await client.get('gemini://restricted.example.com/')
Error Handling¶
Handle common error conditions:
import asyncio
from nauyaca.client.session import GeminiClient
from nauyaca.security.tofu import CertificateChangedError
async def main():
async with GeminiClient() as client:
try:
response = await client.get('gemini://example.com/')
if response.is_success():
print(f"Content-Type: {response.mime_type}")
print(response.body)
elif response.is_redirect():
print(f"Redirects to: {response.redirect_url}")
else:
print(f"Error {response.status}: {response.meta}")
except CertificateChangedError as e:
# Certificate changed - potential security issue
print(f"WARNING: Certificate changed for {e.hostname}:{e.port}")
print(f"Old fingerprint: {e.old_fingerprint}")
print(f"New fingerprint: {e.new_fingerprint}")
# User should verify this is legitimate before trusting
except asyncio.TimeoutError:
print("Request timed out")
except ConnectionError as e:
print(f"Connection failed: {e}")
except ValueError as e:
print(f"Invalid URL or redirect: {e}")
Uploading Content (Titan)¶
Upload content to a Gemini server using the Titan protocol:
async def main():
async with GeminiClient() as client:
# Upload text content
response = await client.upload(
'gemini://example.com/wiki/page.gmi',
'# My Page\n\nContent here...',
mime_type='text/gemini',
token='auth-token',
)
if response.is_success():
print("Upload successful!")
# Upload binary content
with open('image.png', 'rb') as f:
response = await client.upload(
'gemini://example.com/images/photo.png',
f.read(),
mime_type='image/png',
token='auth-token',
)
Deleting Content (Titan)¶
Delete a resource using a zero-byte Titan upload:
async def main():
async with GeminiClient() as client:
response = await client.delete(
'gemini://example.com/wiki/old-page.gmi',
token='auth-token',
)
if response.is_success():
print("Deleted!")
Server Support Required
The server must have Titan enabled with enable_delete = true for delete operations to succeed.
Disabling Redirects¶
Sometimes you want to handle redirects manually:
async def main():
async with GeminiClient() as client:
# Don't follow redirects automatically
response = await client.get(
'gemini://example.com/',
follow_redirects=False
)
if response.is_redirect():
print(f"Got redirect to: {response.redirect_url}")
# Decide whether to follow it yourself
GeminiResponse¶
GeminiResponse
dataclass
¶
Represents a Gemini protocol response.
Attributes:
| Name | Type | Description |
|---|---|---|
status |
int
|
Two-digit status code (10-69). |
meta |
str
|
Status-dependent metadata string. For success (2x), this is the MIME type. For redirects (3x), this is the redirect URL. For errors, this is an error message. For input (1x), this is the prompt. |
body |
str | bytes | None
|
Response body content (only present for 2x success responses). For text/* MIME types, this is a decoded string. For binary MIME types (images, audio, etc.), this is raw bytes. |
url |
str | None
|
The URL this response came from (useful for tracking redirects). |
Examples:
>>> response = GeminiResponse(
... status=20,
... meta='text/gemini',
... body='# Hello World\nWelcome to Gemini!',
... url='gemini://example.com/'
... )
>>> response.is_success()
True
>>> response.mime_type
'text/gemini'
charset
property
¶
Extract charset from MIME type parameters, defaulting to utf-8.
Returns:
| Type | Description |
|---|---|
str
|
The charset specified in the meta field, or 'utf-8' if not specified. |
mime_type
property
¶
Get the MIME type from a success response.
Returns:
| Type | Description |
|---|---|
str | None
|
The MIME type if this is a success response, None otherwise. |
redirect_url
property
¶
Get the redirect URL from a redirect response.
Returns:
| Type | Description |
|---|---|
str | None
|
The redirect URL if this is a redirect response, None otherwise. |
__str__
¶
Return a human-readable string representation of the response.
Source code in src/nauyaca/protocol/response.py
is_redirect
¶
Checking Response Types¶
The GeminiResponse class provides convenient methods for checking response status:
response = await client.get('gemini://example.com/')
# Check if request succeeded
if response.is_success():
# Status 20-29: response has body content
print(f"MIME type: {response.mime_type}")
print(f"Body: {response.body}")
# Check if it's a redirect
elif response.is_redirect():
# Status 30-39: meta contains redirect URL
print(f"Redirect to: {response.redirect_url}")
# Otherwise it's an error or input request
else:
status_category = interpret_status(response.status)
print(f"{status_category}: {response.meta}")
Accessing Response Attributes¶
All response data is available as attributes:
response = await client.get('gemini://example.com/')
# Core attributes
print(f"Status: {response.status}") # e.g., 20
print(f"Meta: {response.meta}") # e.g., "text/gemini"
print(f"Body: {response.body}") # Only present for 2x status
print(f"URL: {response.url}") # The URL that was requested
# Convenience properties
print(f"MIME type: {response.mime_type}") # Extracted from meta (success only)
print(f"Charset: {response.charset}") # Defaults to utf-8
print(f"Redirect: {response.redirect_url}") # Extracted from meta (redirect only)
Status Code Utilities¶
Utility functions for interpreting status codes:
interpret_status
¶
Interpret a status code and return its category name.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
status
|
int
|
A two-digit Gemini status code (10-69). |
required |
Returns:
| Type | Description |
|---|---|
str
|
A string describing the general category of the status code. |
Examples:
>>> interpret_status(20)
'SUCCESS'
>>> interpret_status(51)
'PERMANENT FAILURE'
>>> interpret_status(30)
'REDIRECT'
Source code in src/nauyaca/protocol/status.py
is_success
¶
Check if a status code indicates success (2x).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
status
|
int
|
A two-digit Gemini status code. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if the status code is in the success range (20-29). |
Source code in src/nauyaca/protocol/status.py
is_redirect
¶
Check if a status code indicates a redirect (3x).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
status
|
int
|
A two-digit Gemini status code. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if the status code is in the redirect range (30-39). |
Source code in src/nauyaca/protocol/status.py
is_input_required
¶
Check if a status code indicates input is required (1x).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
status
|
int
|
A two-digit Gemini status code. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if the status code is in the input range (10-19). |
Source code in src/nauyaca/protocol/status.py
is_error
¶
Check if a status code indicates an error (4x, 5x, or 6x).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
status
|
int
|
A two-digit Gemini status code. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if the status code indicates any type of error. |
Source code in src/nauyaca/protocol/status.py
Status Code Examples¶
from nauyaca.protocol.status import (
interpret_status,
is_success,
is_redirect,
is_input_required,
is_error
)
response = await client.get('gemini://example.com/')
# Get category name
category = interpret_status(response.status) # "SUCCESS", "REDIRECT", etc.
# Check specific categories
if is_success(response.status):
print("Success!")
elif is_redirect(response.status):
print(f"Redirect to: {response.redirect_url}")
elif is_input_required(response.status):
print(f"Server needs input: {response.meta}")
elif is_error(response.status):
print(f"Error: {response.meta}")
Common Patterns¶
Following Redirects Manually¶
async def fetch_with_manual_redirects(client, url, max_redirects=5):
"""Fetch a URL and manually handle redirects."""
redirects_followed = 0
current_url = url
while redirects_followed < max_redirects:
response = await client.get(current_url, follow_redirects=False)
if not response.is_redirect():
return response
# Check if it's a gemini:// redirect
redirect_url = response.redirect_url
if not redirect_url.startswith('gemini://'):
print(f"Warning: non-Gemini redirect to {redirect_url}")
return response
print(f"Following redirect to: {redirect_url}")
current_url = redirect_url
redirects_followed += 1
raise ValueError(f"Too many redirects (>{max_redirects})")
Handling Input Prompts¶
from nauyaca.protocol.status import is_input_required, StatusCode
async def interactive_fetch(client, url):
"""Fetch a URL and handle input prompts interactively."""
response = await client.get(url)
# Check if server is requesting input
if is_input_required(response.status):
# Display the prompt to the user
print(f"Server prompt: {response.meta}")
# Get user input
if response.status == StatusCode.SENSITIVE_INPUT:
# Don't echo for sensitive input (like passwords)
import getpass
user_input = getpass.getpass("Input (hidden): ")
else:
user_input = input("Input: ")
# Build URL with query string
# Gemini uses '?' to separate path from query
query_url = f"{url}?{user_input}"
# Make request with user's input
response = await client.get(query_url)
return response
Client Certificate Authentication¶
async def fetch_with_cert(url, cert_path, key_path):
"""Fetch a resource using client certificate authentication."""
from pathlib import Path
client = GeminiClient(
client_cert=Path(cert_path),
client_key=Path(key_path)
)
try:
response = await client.get(url)
# Check for certificate-related errors
if response.status == 60:
print("Server requires a client certificate")
elif response.status == 61:
print("Certificate not authorized for this resource")
elif response.status == 62:
print("Certificate not valid")
else:
return response
finally:
# Client will be cleaned up automatically
pass
TOFU Certificate Management¶
from nauyaca.security.tofu import TOFUDatabase, CertificateChangedError
from pathlib import Path
async def safe_fetch_with_tofu(url):
"""Fetch with TOFU validation and user confirmation on changes."""
client = GeminiClient(trust_on_first_use=True)
try:
response = await client.get(url)
return response
except CertificateChangedError as e:
# Certificate changed - ask user to verify
print(f"\nWARNING: Certificate changed for {e.hostname}:{e.port}")
print(f"Old fingerprint: {e.old_fingerprint}")
print(f"New fingerprint: {e.new_fingerprint}")
print("\nThis could be a legitimate certificate renewal,")
print("or it could indicate a man-in-the-middle attack.")
answer = input("\nDo you want to trust the new certificate? (yes/no): ")
if answer.lower() == 'yes':
# Trust the new certificate
tofu_db = TOFUDatabase()
# Need to fetch again to get the certificate
# This time we'll trust it
# (In practice, you'd want to extract and trust the cert directly)
print("Please verify the fingerprint through a separate channel!")
else:
print("Certificate not trusted. Aborting.")
raise
Batch Requests¶
async def fetch_multiple(urls):
"""Fetch multiple URLs concurrently."""
async with GeminiClient() as client:
# Create tasks for all URLs
tasks = [client.get(url) for url in urls]
# Run them concurrently
responses = await asyncio.gather(*tasks, return_exceptions=True)
# Process results
for url, response in zip(urls, responses):
if isinstance(response, Exception):
print(f"{url}: Error - {response}")
elif response.is_success():
print(f"{url}: Success - {len(response.body)} bytes")
else:
print(f"{url}: Status {response.status}")
See Also¶
- Protocol Reference - Low-level protocol details
- Security Guide - TOFU and certificate management
- How-to: Client Usage - Practical client recipes