Back to flin
flin

HTTP Client Built Into the Language

How FLIN ships a complete HTTP client as a built-in language feature -- GET, POST, PUT, DELETE with JSON handling, timeouts, retries, and headers, all without importing a library.

Thales & Claude | March 25, 2026 9 min flin
flinhttpclientapifetch

Every web application makes HTTP requests. A SaaS dashboard fetches analytics from an API. An e-commerce site charges cards through Stripe. A social app pulls user profiles from a backend. HTTP is the connective tissue of the modern web, and yet most programming languages treat it as an afterthought -- something you handle with a third-party library.

In JavaScript, the built-in fetch API arrived years after Node.js launched, and it still lacks features like automatic retries and request timeouts (the AbortController workaround is nobody's idea of elegant). In Python, the standard library's urllib is so painful that requests became the most downloaded package on PyPI. In Go, the built-in net/http client is decent but verbose -- a simple POST with JSON body takes 15 lines.

FLIN includes a complete HTTP client as a built-in. Sessions 063 and 064 designed and implemented it. Five functions. Zero imports. Automatic JSON handling. Configurable timeouts and retries. Everything a web developer needs to integrate with any API.

The Five Functions

flinhttp_get(url)
http_get(url, options)

http_post(url, options)

http_put(url, options)

http_patch(url, options)

http_delete(url)
http_delete(url, options)

Five functions for five HTTP methods. That is the entire API surface. Each function takes a URL and an optional options map, makes the request, and returns a response object. No constructing request objects. No configuring middleware chains. No importing adapters.

GET Requests: The Simple Case

The most common HTTP operation is fetching data. In FLIN, it is one line:

flinresponse = http_get("https://api.example.com/users")

The response object contains everything you need:

flinresponse.status            // 200
response.ok                // true (status 200-299)
response.headers           // Map of response headers
response.body              // Raw response body (text)
response.json              // Parsed JSON (if Content-Type is application/json)

response.ok is a boolean that is true when the status code is in the 200-299 range. This eliminates the most common bug in HTTP client code: forgetting to check the status code. In JavaScript, fetch resolves successfully even for 404 and 500 responses -- a design choice that has caused millions of bugs. In FLIN, response.ok makes the check explicit and obvious.

response.json lazily parses the response body as JSON. If the body is not valid JSON, it returns none instead of throwing an error. This means you can safely access it without a try-catch block:

flinresponse = http_get("https://api.example.com/users")

{if response.ok}
    users = response.json
    {if users != none}
        {for user in users}
            <Text>{user["name"]}</Text>
        {/for}
    {else}
        <Alert type="warning">Invalid response format</Alert>
    {/if}
{else}
    <Alert type="danger">Request failed: {response.status}</Alert>
{/if}

No try-catch. No error callbacks. No promise chains. Just conditional checks on values that are always present.

POST Requests: Sending Data

POST requests are where FLIN's automatic JSON handling shines:

flinresponse = http_post("https://api.example.com/users", {
    body: {
        name: "Juste Gnimavo",
        email: "[email protected]",
        role: "admin"
    }
})

When the body is a map (or an entity), FLIN automatically serializes it to JSON and sets the Content-Type: application/json header. You do not need to call JSON.stringify. You do not need to set headers manually. The common case -- sending JSON to a REST API -- is the default.

If you need a different content type, you can override it:

flin// Form data
response = http_post("https://api.example.com/login", {
    body: "username=juste&password=secret",
    headers: {
        "Content-Type": "application/x-www-form-urlencoded"
    }
})

// Raw text
response = http_post("https://api.example.com/webhook", {
    body: "raw payload text",
    headers: {
        "Content-Type": "text/plain"
    }
})

The rule is simple: if you provide a map as the body, FLIN sends JSON. If you provide a string, FLIN sends it as-is with whatever Content-Type you specify.

Authentication Headers

Most API calls require authentication. FLIN's HTTP functions accept a headers map:

flintoken = env("API_TOKEN")

response = http_get("https://api.example.com/me", {
    headers: {
        "Authorization": "Bearer {token}"
    }
})

user = response.json
print("Logged in as: {user['name']}")

String interpolation works inside header values, so you can embed variables directly. The env() function reads environment variables -- another built-in that eliminates a dependency.

API Key Patterns

flin// Header-based API key
response = http_get("https://api.stripe.com/v1/charges", {
    headers: { "Authorization": "Bearer {env('STRIPE_KEY')}" }
})

// Query parameter API key
key = env("WEATHER_API_KEY")
response = http_get("https://api.weather.com/forecast?key={key}&city=Abidjan")

Timeouts and Retries

Production applications need timeouts (so a slow API does not hang your entire application) and retries (so transient network errors do not fail permanently). Both are built into the options:

flinresponse = http_post("https://api.payment.com/charge", {
    body: { amount: 2500, currency: "XOF" },
    timeout: 30.seconds,
    retry: 3
})

timeout accepts a duration value (using FLIN's natural duration syntax). If the request takes longer than the specified duration, it fails with a timeout error and response.ok is false.

retry specifies how many times to retry a failed request. Retries use exponential backoff: first retry after 1 second, second after 2 seconds, third after 4 seconds. Only network errors and 5xx responses trigger retries -- 4xx responses (client errors) are not retried because they would fail again with the same payload.

flin// Robust API call with full options
response = http_post("https://api.example.com/data", {
    body: payload,
    headers: {
        "Authorization": "Bearer {token}",
        "X-Request-Id": uuid()
    },
    timeout: 10.seconds,
    retry: 2
})

{if response.ok}
    data = response.json
    print("Success: {data['id']}")
{else if response.status == 401}
    print("Authentication failed -- refresh token")
{else if response.status == 429}
    print("Rate limited -- try again later")
{else}
    print("Failed after retries: {response.status}")
{/if}

PUT, PATCH, and DELETE

The remaining HTTP methods follow the same pattern:

flin// PUT - full replacement
response = http_put("https://api.example.com/users/42", {
    body: {
        name: "Juste Gnimavo",
        email: "[email protected]",
        role: "admin"
    }
})

// PATCH - partial update
response = http_patch("https://api.example.com/users/42", {
    body: { role: "super_admin" }
})

// DELETE
response = http_delete("https://api.example.com/users/42")

// DELETE with body (some APIs require this)
response = http_delete("https://api.example.com/batch", {
    body: { ids: [1, 2, 3] }
})

All five methods return the same response object structure. All five support the same options (body, headers, timeout, retry). There is no special behavior for any method -- the API is completely uniform.

Error Handling

HTTP requests can fail for many reasons: network errors, DNS failures, TLS certificate problems, timeouts. FLIN handles all of these by returning a response object with ok = false and a descriptive status:

flinresponse = http_get("https://unreachable.example.com")

{if not response.ok}
    {if response.status == 0}
        // Network error (DNS failure, connection refused, etc.)
        print("Network error: {response.body}")
    {else if response.status >= 500}
        // Server error
        print("Server error: {response.status}")
    {else}
        // Client error (4xx)
        print("Client error: {response.status} - {response.body}")
    {/if}
{/if}

Status code 0 is a FLIN convention for network-level failures (no HTTP response was received). This is different from JavaScript's fetch, which throws an exception for network errors, requiring a try-catch around every request. FLIN's approach is consistent: every http_* call returns a response object. You always check response.ok. The control flow is always visible in the code.

Real-World Example: Third-Party API Integration

Here is a complete example integrating with a payment API:

flinfn charge_customer(customer_id: text, amount: int, currency: text) {
    response = http_post("https://api.payment.com/v1/charges", {
        body: {
            customer: customer_id,
            amount: amount,
            currency: currency,
            description: "Order #{uuid().slice(0, 8)}"
        },
        headers: {
            "Authorization": "Bearer {env('PAYMENT_SECRET_KEY')}",
            "Idempotency-Key": uuid()
        },
        timeout: 30.seconds,
        retry: 2
    })

    {if response.ok}
        charge = response.json
        return { success: true, charge_id: charge["id"] }
    {else}
        error = response.json
        log_error("Payment failed: {response.status} - {error['message']}")
        return { success: false, error: error["message"] }
    {/if}
}

// Usage in a view
result = charge_customer("cus_abc123", 5000, "XOF")

{if result.success}
    <Alert type="success">Payment processed: {result.charge_id}</Alert>
{else}
    <Alert type="danger">Payment failed: {result.error}</Alert>
{/if}

This is a production-ready payment integration in 30 lines. It handles authentication, idempotency keys, timeouts, retries, error responses, and user feedback. No HTTP library. No JSON parsing library. No environment variable library. All built-in.

Implementation: reqwest Under the Hood

FLIN's HTTP client is built on Rust's reqwest crate, the most popular HTTP client in the Rust ecosystem. reqwest uses hyper for HTTP/1.1 and HTTP/2 support, rustls for TLS, and tokio for async I/O.

The built-in functions are thin wrappers that translate FLIN values to reqwest calls:

rustfn builtin_http_post(vm: &mut Vm, args: &[Value]) -> Result<Value, VmError> {
    let url = vm.get_string(args[0])?;
    let options = if args.len() > 1 { vm.get_map(args[1])? } else { Map::new() };

    let client = reqwest::blocking::Client::builder()
        .timeout(extract_timeout(&options))
        .build()
        .map_err(|e| VmError::HttpError(e.to_string()))?;

    let mut request = client.post(url);

    // Set headers
    if let Some(headers) = options.get("headers") {
        for (key, value) in vm.get_map(headers)?.iter() {
            request = request.header(key, value);
        }
    }

    // Set body (auto-serialize maps to JSON)
    if let Some(body) = options.get("body") {
        match body {
            Value::Map(_) => request = request.json(&serialize_to_json(vm, body)?),
            Value::String(s) => request = request.body(s.clone()),
            _ => request = request.body(vm.value_to_string(body)?),
        }
    }

    // Execute with retry logic
    let response = execute_with_retry(request, extract_retry_count(&options))?;

    // Build response map
    Ok(build_response_value(vm, response))
}

The blocking client is used because FLIN's VM is currently single-threaded. Async HTTP support is planned for a future release, where http_get will become an async operation that the VM can yield on while waiting for the response.

What We Intentionally Excluded

The HTTP client is deliberately simple. We excluded features that add complexity without benefiting 90% of use cases:

Cookie jars. Automatic cookie management across requests adds state that is hard to debug. If you need cookies, pass them as headers explicitly.

File upload. Multipart form data is complex and rarely needed in API-to-API communication. File uploads in FLIN are handled by the view system and entity system, not the HTTP client.

WebSocket support. WebSockets are a different protocol with different semantics. They will be a separate built-in (ws_connect) when implemented.

Request interceptors. Middleware chains for logging, authentication, and transformation are an antipattern in a language that values explicitness. If you need to add an auth header to every request, write a wrapper function. It is five lines, it is obvious, and it does not require understanding a middleware pipeline.

Streaming responses. Server-sent events and streaming responses require async support. They are planned for the async runtime release.

The HTTP client does one thing well: make synchronous HTTP requests with JSON bodies and return structured responses. For 90% of web application API calls, that is exactly what you need.

Five Functions, Zero Dependencies

FLIN's HTTP client replaces:

  • JavaScript: fetch API + AbortController + JSON.stringify + error handling boilerplate
  • Python: requests library (31 million downloads per week)
  • Go: net/http + json.Marshal + ioutil.ReadAll
  • Ruby: net/http or faraday or httparty

Five functions. One options format. One response format. Every HTTP method a web application uses, available from the first line of code with zero configuration.


This is Part 75 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO built an HTTP client into a programming language.

Series Navigation: - [74] Time and Timezone Functions - [75] HTTP Client Built Into the Language (you are here) - [76] Security Functions: Crypto, JWT, Argon2 - [77] Introspection and Reflection at Runtime

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles