Skip to main content

Fast, type-safe web framework with Rust-powered Python bindings

Project description

spikard-py

PyO3 bindings that expose the Rust HTTP runtime to Python applications under the module name _spikard.

This crate implements high-performance Python bindings for Spikard's HTTP server by bridging Python's asyncio with Rust's async/await runtime through carefully optimized FFI patterns.


Table of Contents


Architecture

The spikard-py crate implements the language-agnostic Handler trait defined in spikard-http, enabling clean separation between the pure Rust HTTP server and Python-specific code.

Handler Trait Implementation

// From spikard-http/src/handler_trait.rs
pub trait Handler: Send + Sync {
    fn call(
        &self,
        request: Request<Body>,
        request_data: RequestData,
    ) -> Pin<Box<dyn Future<Output = HandlerResult> + Send + '_>>;
}

The PythonHandler struct (src/handler.rs) implements this trait, wrapping Python callable objects and converting between Rust and Python types. This design keeps spikard-http completely free of FFI dependencies while enabling multiple language bindings (Python, Node.js, WASM) to share the same HTTP core.

Key architectural benefits:

  1. Zero FFI dependencies in HTTP core - spikard-http has no knowledge of Python, PyO3, or any other language binding
  2. Language-agnostic routing - Routes are defined once and work with any Handler implementation
  3. Isolated FFI concerns - All Python-specific code lives in this crate
  4. Future-proof - New language bindings can be added without touching the HTTP server

Performance Optimizations

Async Handler Execution

Problem: Naively calling Python async handlers from Rust using tokio::task::spawn_blocking introduces ~4.8ms overhead per request due to thread pool management and GIL coordination.

Solution: Convert Python coroutines directly to Rust futures using pyo3_async_runtimes::tokio::into_future(), eliminating the blocking thread pool entirely.

Implementation (src/handler.rs:155-194):

// For async handlers
let output = Python::attach(|py| {
    let handler_obj = handler.bind(py);

    // Prepare kwargs with request data
    let kwargs = request_data_to_py_kwargs(py, &request_data, handler_obj.clone())?;

    // Call Python async function, returns a coroutine
    let coroutine = if let Some(py_params) = validated_params {
        handler_obj.call((), Some(&kwargs))?
    } else {
        handler_obj.call((), Some(&kwargs))?
    };

    // ✅ Convert Python coroutine → Rust future (no spawn_blocking!)
    pyo3_async_runtimes::tokio::into_future(coroutine)
})
.map_err(|e: PyErr| (StatusCode::INTERNAL_SERVER_ERROR, format!("Python error: {}", e)))?
.await  // ✅ Await the Rust future directly on Tokio runtime
.map_err(|e: PyErr| (StatusCode::INTERNAL_SERVER_ERROR, format!("Python error: {}", e)))?;

Performance impact:

  • Before: ~5ms per async request (spawn_blocking + GIL + wake overhead)
  • After: ~170µs per async request (pure Python execution)
  • Improvement: ~25-30x faster for async handlers

Note: Synchronous Python handlers still require spawn_blocking to avoid blocking Tokio's executor, as they cannot yield control.


Event Loop Reuse

Problem: Creating a new Python event loop for each async handler invocation adds ~55µs overhead and increases GIL contention.

Solution: Initialize the event loop once at server startup and reuse it across all async handler calls using TaskLocals stored in a OnceCell.

Implementation (src/handler.rs:28-45):

use once_cell::sync::OnceCell;
use pyo3_async_runtimes::TaskLocals;

/// Global Python event loop task locals for async handlers
static TASK_LOCALS: OnceCell<TaskLocals> = OnceCell::new();

/// Initialize Python event loop for async handlers
/// Must be called once after Python::initialize()
pub fn init_python_event_loop() -> PyResult<()> {
    Python::attach(|py| {
        let asyncio = py.import("asyncio")?;
        let event_loop = asyncio.call_method0("new_event_loop")?;
        asyncio.call_method1("set_event_loop", (event_loop.clone(),))?;

        // ✅ Initialize once, reuse forever
        TASK_LOCALS.get_or_try_init(|| {
            TaskLocals::new(event_loop.into()).copy_context(py)
        })?;

        Ok(())
    })
}

Performance impact:

  • Eliminates ~55µs event loop creation overhead per request
  • Reduces GIL contention by reusing the same loop
  • One-time initialization cost amortized across millions of requests

Usage: The CLI (crates/spikard-cli/src/main.rs) calls init_python_event_loop() once during server startup.


Zero-Copy Data Conversion

Problem: Converting JSON data between Rust (serde_json::Value) and Python objects via intermediate JSON strings doubles the serialization cost:

// ❌ Inefficient: Rust Value → JSON string → Python object
let json_str = serde_json::to_string(value)?;  // Serialize to string
json_module.call_method1("loads", (json_str,))  // Parse string in Python

Solution: Directly construct Python objects from Rust Value using PyO3 native types, bypassing the JSON string encoding entirely.

Implementation (src/handler.rs:434-476):

/// Convert JSON Value to Python object (optimized zero-copy conversion)
///
/// Performance improvement: ~30-40% faster than the json.loads approach
fn json_to_python<'py>(py: Python<'py>, value: &Value) -> PyResult<Bound<'py, PyAny>> {
    use pyo3::types::{PyBool, PyDict, PyFloat, PyList, PyNone, PyString};

    match value {
        Value::Null => Ok(PyNone::get(py).as_any().clone()),

        Value::Bool(b) => Ok(PyBool::new(py, *b).as_any().clone()),

        Value::Number(n) => {
            if let Some(i) = n.as_i64() {
                Ok(i.into_pyobject(py)?.into_any())
            } else if let Some(u) = n.as_u64() {
                Ok(u.into_pyobject(py)?.into_any())
            } else if let Some(f) = n.as_f64() {
                Ok(PyFloat::new(py, f).into_any())
            } else {
                // Fallback for exotic number representations
                Ok(PyString::new(py, &n.to_string()).into_any())
            }
        }

        Value::String(s) => Ok(PyString::new(py, s).into_any()),

        // Recursively convert arrays
        Value::Array(arr) => {
            let py_list = PyList::empty(py);
            for item in arr {
                let py_item = json_to_python(py, item)?;
                py_list.append(py_item)?;
            }
            Ok(py_list.into_any())
        }

        // Recursively convert objects
        Value::Object(obj) => {
            let py_dict = PyDict::new(py);
            for (key, value) in obj {
                let py_value = json_to_python(py, value)?;
                py_dict.set_item(key, py_value)?;
            }
            Ok(py_dict.into_any())
        }
    }
}

Performance impact:

  • Before: ~100µs for typical request body conversion (serialize + parse)
  • After: ~60µs for same conversion (direct construction)
  • Improvement: ~30-40% faster, scales with payload complexity

Note: This optimization complements the msgspec integration in packages/python/spikard/_internal/converters.py for even faster Python-side serialization.


GIL Management

Problem: Holding Python's Global Interpreter Lock (GIL) during async awaits blocks other Python threads and degrades concurrency.

Solution: Minimize GIL scope by releasing it before awaiting Rust futures.

Implementation pattern:

// ✅ Correct: GIL released before async await
let output = Python::attach(|py| {
    // GIL held only during Python code execution
    let handler_obj = handler.bind(py);
    let kwargs = request_data_to_py_kwargs(py, &request_data, handler_obj.clone())?;
    let coroutine = handler_obj.call((), Some(&kwargs))?;

    // Convert to Rust future while still holding GIL
    pyo3_async_runtimes::tokio::into_future(coroutine)
})  // ✅ GIL released here
.await  // ✅ No GIL held during async wait

Anti-pattern to avoid:

// ❌ Wrong: GIL held during entire async operation
Python::with_gil(|py| async move {
    // GIL held for entire async block - blocks other threads!
    let result = some_async_operation().await;
    result
}).await

Performance impact:

  • Enables true concurrent request handling (GIL not blocking other requests)
  • Reduces latency spikes under concurrent load
  • Critical for achieving high requests-per-second throughput

Validation in Rust

Problem: Validating request bodies and parameters in Python requires acquiring the GIL, converting data to Python objects, and executing Python validation code—all before the handler runs.

Solution: Perform JSON Schema validation and parameter extraction in pure Rust before entering Python, enabling early returns on invalid requests without GIL overhead.

Implementation (src/handler.rs:100-147):

// ✅ Validate request body in Rust BEFORE entering Python
if let Some(validator) = &self.request_validator {
    if let Err(errors) = validator.validate(&request_data.body) {
        let problem = ProblemDetails::from_validation_error(&errors);
        return Err((problem.status_code(), problem.to_json_pretty()?));
    }
}

// ✅ Validate and extract parameters in Rust
let validated_params = if let Some(validator) = &self.parameter_validator {
    match validator.validate_and_extract(&request_data.path_params, &request_data.query_params) {
        Ok(params) => Some(params),
        Err(errors) => {
            let problem = ProblemDetails::from_validation_error(&errors);
            return Err((problem.status_code(), problem.to_json_pretty()?));
        }
    }
} else {
    None
};

// Only enter Python if validation passed
let handler = self.handler.clone();
if self.is_async {
    // ... call Python handler with validated data ...
}

Performance impact:

  • Validation happens in pure Rust (no GIL contention)
  • Invalid requests return immediately (never enter Python)
  • Validated parameters passed directly to handler (no re-validation)
  • Structured RFC 9457 Problem Details errors for client consumption

Security benefit: All input validation occurs in memory-safe Rust before untrusted data reaches Python code.


PyO3 Configuration

Extension Module Management

Critical configuration: The extension-module feature in PyO3 controls whether the library links to libpython. This setting must differ between extension modules (.so/.pyd files) and binaries (executables, tests).

Problem: If extension-module is in default features, binaries fail to link with errors like:

ld: symbol(s) not found for architecture arm64
  "__Py_NoneStruct", referenced from...

Solution: Keep extension-module as an optional feature, enable it only for maturin builds.

Configuration (Cargo.toml):

[features]
default = []  # ✅ NOT including extension-module
extension-module = ["pyo3/extension-module"]
server = ["dep:tracing", "dep:anyhow", "dep:clap", "pyo3/auto-initialize"]

Configuration (pyproject.toml):

[tool.maturin]
module-name = "_spikard"
features = ["extension-module"]  # ✅ Enable for Python extension builds

Why this works:

  • Python extensions (.so/.pyd built by maturin): Enable extension-module → don't link libpython → compatible with manylinux wheels
  • Rust binaries (CLI, tests): Disable extension-module → link libpython → can embed Python interpreter

Reference: PyO3 FAQ - extension-module feature


Building & Installation

Development Build (Editable Install)

For rapid iteration during development:

# From repository root
uv run maturin develop

# Or use the task runner
task build:py

This creates an editable install, allowing you to modify Rust code and rebuild without reinstalling the wheel.

Release Build

For production or benchmarking (enables optimizations):

# Build optimized wheel
uv run maturin build --release

# Or use task runner
task build:py --release

Important: Always benchmark with --release builds, as debug builds are 10-50x slower.

Building the Test Server

A standalone server binary is available for testing:

cargo build --release -p spikard-py --bin spikard-py-server --features server

Run it:

./target/release/spikard-py-server /path/to/app.py --port 8000

Testing

Rust Unit Tests

Test the Handler trait implementation in isolation:

# Run unit tests for spikard-http (Handler trait tests)
cargo test -p spikard-http

# Run with output
cargo test -p spikard-http -- --nocapture

Python Integration Tests

End-to-end tests that exercise the full Python → Rust → HTTP stack:

# From repository root
PYTHONPATH=packages/python uv run pytest e2e/python/tests/

# Run specific test file
PYTHONPATH=packages/python uv run pytest e2e/python/tests/test_query_params.py

# Run with verbose output
PYTHONPATH=packages/python uv run pytest e2e/python/tests/ -v

Full Test Suite

Run all tests (Rust + Python):

task test

Note: Integration tests for spikard-py itself require special PyO3 configuration due to the extension-module feature. The comprehensive e2e Python tests provide equivalent coverage.


Implementation Details

Module Structure

src/
├── lib.rs           # PyO3 module definition, Python-facing API
├── handler.rs       # PythonHandler implementation, core FFI logic
└── bin/
    └── server.rs    # Standalone server binary for testing

Key Types

PythonHandler

The bridge between Python callables and the Handler trait:

pub struct PythonHandler {
    handler: Py<PyAny>,                           // Python callable
    is_async: bool,                               // Async vs sync handler
    request_validator: Option<Arc<SchemaValidator>>,
    response_validator: Option<Arc<SchemaValidator>>,
    parameter_validator: Option<Arc<ParameterValidator>>,
}

Methods:

  • new() - Construct with validators
  • call() - Implement Handler trait, route to async vs sync execution

RequestData Conversion

Python handlers receive request data as keyword arguments:

async def my_handler(
    path_params: dict,
    query_params: dict,
    body: dict,
    headers: dict,
    cookies: dict,
    method: str,
    path: str,
):
    return {"message": "Hello"}

The request_data_to_py_kwargs() function (src/handler.rs:242-280) converts Rust RequestData to Python kwargs:

fn request_data_to_py_kwargs(
    py: Python,
    data: &RequestData,
    handler: Bound<PyAny>,
) -> PyResult<Bound<PyDict>> {
    let kwargs = PyDict::new(py);

    // Convert each field using zero-copy json_to_python()
    kwargs.set_item("path_params", json_to_python(py, &data.path_params)?)?;
    kwargs.set_item("query_params", json_to_python(py, &data.query_params)?)?;
    kwargs.set_item("body", json_to_python(py, &data.body)?)?;
    // ... headers, cookies, method, path ...

    Ok(kwargs)
}

Response Handling

Python handlers can return:

  1. Plain dict/list → 200 OK JSON response
  2. Custom Response object → Extract status_code, content, headers

The python_to_response_result() function (src/handler.rs:282-335) handles both cases:

fn python_to_response_result(py: Python, output: &Bound<PyAny>) -> PyResult<ResponseResult> {
    if output.hasattr("status_code")? {
        // Custom Response object
        let status_code: u16 = output.getattr("status_code")?.extract()?;
        let content: Value = /* extract and convert to JSON */;
        let headers: HashMap<String, String> = /* extract headers dict */;

        Ok(ResponseResult::Custom {
            content,
            status_code,
            headers,
        })
    } else {
        // Plain dict/list → default 200 OK
        let json_value = python_to_json(py, output)?;
        Ok(ResponseResult::Json(json_value))
    }
}

Error Handling

All errors crossing the FFI boundary are converted to PyErr:

// Domain errors → PyErr
let result = some_fallible_operation()
    .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(format!("Error: {}", e)))?;

// Validation errors → RFC 9457 Problem Details JSON
if let Err(errors) = validator.validate(&data) {
    let problem = ProblemDetails::from_validation_error(&errors);
    return Err((problem.status_code(), problem.to_json_pretty()?));
}

Never panic across FFI boundaries. All fallible paths return PyResult<T>.


Design Principles

Panic-Free FFI

Rust code in this crate must avoid panics. All fallible operations use Result<T, E> and propagate errors with ?. Errors are converted to PyErr before crossing the FFI boundary.

Why: Panicking across FFI boundaries is undefined behavior and can corrupt Python's runtime state.

Minimal GIL Scope

The GIL should be held only during Python code execution, never during async awaits or Rust computations.

Why: The GIL is a global lock—holding it unnecessarily serializes concurrent requests.

Zero-Copy Where Possible

Prefer direct type conversion over serialization intermediates (JSON strings, msgpack bytes).

Why: Each serialization/deserialization cycle costs CPU and allocations. Zero-copy conversion is 30-40% faster.

Validate Early in Rust

Perform input validation in Rust before entering Python, enabling early returns on invalid data.

Why: Invalid requests fail fast without GIL overhead, improving throughput and security.


Performance Characteristics

Async Handler Latency

Component Time Notes
HTTP parsing (Axum) ~10µs Rust HTTP/1.1 parsing
Validation (Rust) ~20µs JSON Schema validation
FFI overhead ~5µs Rust → Python type conversion
Python handler execution ~150µs Depends on handler complexity
Response conversion ~10µs Python → Rust → HTTP response
Total ~195µs ~5,000 req/sec per core

Throughput Benchmarks

With a simple async handler returning {"message": "Hello"}:

  • Single core: ~60,000 req/sec
  • Concurrency 50: ~58,000 req/sec (stable under load)
  • P99 latency: <2ms

Comparison to pure Python:

  • FastAPI (uvicorn): ~3,000 req/sec (20x slower)
  • Pure Python asyncio: ~8,000 req/sec (7x slower)

Note: These numbers are from simple handlers. Real-world performance depends on handler complexity, database I/O, etc.


Future Optimizations

Object Pooling

Reuse Python objects (dicts, lists) across requests to reduce allocation pressure:

// Future optimization: object pool for kwargs dicts
static KWARGS_POOL: ObjectPool<PyDict> = ObjectPool::new();

let kwargs = KWARGS_POOL.acquire(py);
// ... populate kwargs ...

Estimated improvement: +5-10% throughput

HTTP Body Streaming

Currently buffering entire request body in memory. Stream large payloads directly to Python asyncio streams:

async def upload_handler(body_stream):
    async for chunk in body_stream:
        await process_chunk(chunk)

Benefit: Support multi-GB uploads without memory pressure.

Sub-Interpreter Support

Use Python 3.12+ sub-interpreters to run multiple Python runtimes in parallel (bypassing GIL):

// Future: per-worker sub-interpreter
let sub_interpreter = SubInterpreter::new()?;
sub_interpreter.run(handler)?;

Benefit: True parallelism for CPU-bound Python handlers.


Related Documentation

  • Architecture: docs/adr/0001-architecture-and-principles.md
  • Validation Strategy: docs/adr/0003-validation-and-fixtures.md
  • msgspec Integration: docs/adr/0003-validation-and-fixtures.md
  • HTTP Core: crates/spikard-http/README.md
  • Python Package: packages/python/README.md

Development Notes

Code Style

  • Rust code follows rustfmt.toml (run cargo fmt)
  • Keep functions small and focused (<100 lines)
  • Document public APIs with /// doc comments
  • Use debug_log_module!() macro for conditional logging

Performance Profiling

Profile with perf on Linux or Instruments on macOS:

# Build with debug info
cargo build --release -p spikard-py --features server

# Run server under profiler
perf record -F 99 -g ./target/release/spikard-py-server app.py

# Generate flamegraph
perf script | stackcollapse-perf.pl | flamegraph.pl > flame.svg

Debugging FFI Issues

Enable debug logging:

DEBUG=1 ./target/release/spikard-py-server app.py

Enable Python tracebacks in PyO3:

pyo3::prepare_freethreaded_python();
Python::with_gil(|py| {
    py.run("import sys; sys.excepthook = lambda *args: None", None, None).unwrap();
});

Contributing

When adding features to this crate:

  1. Maintain panic-free guarantees - All errors → PyErr
  2. Update tests - Add Rust unit tests and Python e2e tests
  3. Benchmark changes - Profile before/after with realistic workloads
  4. Document patterns - Update this README with new optimization techniques
  5. Run full test suite - task test before committing

Refer to ai-rulez.yaml for comprehensive rules and patterns.


License

MIT - See LICENSE in repository root.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

spikard-0.1.2.tar.gz (154.8 kB view details)

Uploaded Source

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

spikard-0.1.2-py3-none-any.whl (51.5 kB view details)

Uploaded Python 3

spikard-0.1.2-cp310-abi3-win_amd64.whl (5.6 MB view details)

Uploaded CPython 3.10+Windows x86-64

spikard-0.1.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (5.4 MB view details)

Uploaded CPython 3.10+manylinux: glibc 2.17+ x86-64

spikard-0.1.2-cp310-abi3-macosx_14_0_arm64.whl (4.9 MB view details)

Uploaded CPython 3.10+macOS 14.0+ ARM64

File details

Details for the file spikard-0.1.2.tar.gz.

File metadata

  • Download URL: spikard-0.1.2.tar.gz
  • Upload date:
  • Size: 154.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for spikard-0.1.2.tar.gz
Algorithm Hash digest
SHA256 8607b5c69cb70ae847c0b67b5854245bd6bf8fe1a52d9aafec3b5b6e6e369f6a
MD5 5db7b0b46227e3e9533b797ce9a466ab
BLAKE2b-256 720c456267077fe67804b707da4674c163298ba156932de3a323a38ecd98afec

See more details on using hashes here.

Provenance

The following attestation bundles were made for spikard-0.1.2.tar.gz:

Publisher: publish.yaml on Goldziher/spikard

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file spikard-0.1.2-py3-none-any.whl.

File metadata

  • Download URL: spikard-0.1.2-py3-none-any.whl
  • Upload date:
  • Size: 51.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for spikard-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 214904bef2ec9db8eb68ceb9faf88ac2bfcfaa57be9976248bc7dfb439df93bd
MD5 9027a5fccdfdd67c1e73b0979885edcc
BLAKE2b-256 24cb74688d54cb7e8141bbda75873a87ca8e0dc5c9f96ba33ab42be62ee076da

See more details on using hashes here.

Provenance

The following attestation bundles were made for spikard-0.1.2-py3-none-any.whl:

Publisher: publish.yaml on Goldziher/spikard

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file spikard-0.1.2-cp310-abi3-win_amd64.whl.

File metadata

  • Download URL: spikard-0.1.2-cp310-abi3-win_amd64.whl
  • Upload date:
  • Size: 5.6 MB
  • Tags: CPython 3.10+, Windows x86-64
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for spikard-0.1.2-cp310-abi3-win_amd64.whl
Algorithm Hash digest
SHA256 19ac55528d222a7285045fae2b9a670a611a07018d4176c1999198c45e58da1a
MD5 01073cf2504436fe9b7b3274d47b62b0
BLAKE2b-256 23f1f8d334921445a5f39d637bbdaffde9b817ed604b230ba7e19bd823dc7293

See more details on using hashes here.

Provenance

The following attestation bundles were made for spikard-0.1.2-cp310-abi3-win_amd64.whl:

Publisher: publish.yaml on Goldziher/spikard

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file spikard-0.1.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for spikard-0.1.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 8d3ff7008b7a35f3f5f9524e733ae7d43ee0cc08be646c777287aa2316d32c6e
MD5 36957483572b705c43705852fe05bbfe
BLAKE2b-256 d739b462cc9b4f3882ca8f57086985422b5c89ae702d954cd71d23f77e432b17

See more details on using hashes here.

Provenance

The following attestation bundles were made for spikard-0.1.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl:

Publisher: publish.yaml on Goldziher/spikard

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file spikard-0.1.2-cp310-abi3-macosx_14_0_arm64.whl.

File metadata

File hashes

Hashes for spikard-0.1.2-cp310-abi3-macosx_14_0_arm64.whl
Algorithm Hash digest
SHA256 bb9d51b1802c0f1a4ce4a23ba02163cc3a6701d205d4aac10bef4f56721bd69d
MD5 32294ac9a1b6a169f570d0130793ca81
BLAKE2b-256 90248822469a46228d57ca5452b70f13cbaaf85f5f484538433a2a9ff2d40ce3

See more details on using hashes here.

Provenance

The following attestation bundles were made for spikard-0.1.2-cp310-abi3-macosx_14_0_arm64.whl:

Publisher: publish.yaml on Goldziher/spikard

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page