← All posts

Engineering · 2026.05.20 · 6 min · Updated 2026.10.26

From a FastAPI Script to a Spring Boot SaaS: Rebuilding My Bitfinex Auto-Lending Platform

A Bitfinex auto-lending side project rewritten from a good-enough FastAPI script into a multi-module Spring Boot and Next.js platform with idempotency, reconciliation, circuit breaking, and full monitoring.

Reading
Theme
Size
Width

If an auto-lending script loses its connection right after sending an offer, did the offer reach Bitfinex or not? Retrying blindly means lending the same money twice.

I rewrote my FastAPI lending script as a Spring Boot + Next.js platform. Its core is idempotent order placement: write the DB first, then place the order, and when the connection fails, leave the outcome to reconciliation instead of guessing.

A circuit breaker, rate limiting, and monitoring sit on top of that, so the system can get back up on its own when something fails.

This side project started out of pure laziness. Funding (lending) rates on Bitfinex move every day, and placing offers by hand is tedious, so I wrote a script to do it automatically. Good enough to run was fine.

Then it started managing real money and other people started using it, and "good enough" wasn't.

The old version worked but was fragile

The first version was textbook. A FastAPI backend ran a few async tasks to poll the market and place offers, a Vue 3 + Vite frontend drew some earnings charts, and Docker Compose glued it together.

The stack was fine. What the system lacked was any concept of "what happens when this fails."

If the network dropped after an offer was sent, I had no idea whether it went through. If the exchange returned a 5xx now and then, the whole poll loop would stall. Adding a field meant hand-editing the schema.

It ran fine with the wind at its back, but lending is about managing money, and headwind is the normal case.

Rewriting in Spring Boot and Next.js

Rather than patch it, I rewrote it. The backend went to Spring Boot 3 / Java 21, for a very practical reason: Java and Spring are my day job, so familiarity translates directly into reliability.

What I wanted was transactions, scheduling (@Scheduled), circuit breakers, and metrics: the boring-but-mature stuff, all available in one ecosystem. Strong typing plus JPA and Flyway means schema changes are versioned and migrated, with no more hand-editing the database.

The frontend became Next.js 16 / React 19, using static export (output: export) straight to Cloudflare Pages. The frontend needs no server, just a CDN.

It's a monorepo, roughly layered like this:

Next.js (static export) → Cloudflare Pages
        │  JWT (HttpOnly cookie) + XSRF
        ▼
Cloudflare Tunnel  →  Spring Boot (Java 21)
                          ├─ Lending engine (scheduled)
                          ├─ Reconciliation (scheduled)
                          ├─ Bitfinex client (circuit breaker + rate limit)
                          ├─ Notifications (Email / Telegram)
                          └─ Audit (hash chain)
                          │
                 PostgreSQL 16   Redis 7
                 (Flyway)        (cache/lock/rate-limit)

The backend faces the world through a Cloudflare Tunnel, so the server needs no public IP and no self-managed reverse-proxy certificates. For a personal project, that saves a lot of hassle.

Write the DB first, then place the order

The core of the whole system is idempotent order placement: sending the same order any number of times has the same result as sending it once.

The lending engine runs on a fixed schedule. Each cycle is roughly: read the market, compute the rate and period to offer, compute the available balance, place the order.

Simple-sounding, but "place the order" talks to an external exchange. It can time out at any moment, or succeed without the response ever reaching me. Naively retrying there means lending the same money twice.

My fix is to invert the order and write the DB first, then place the order:

  1. Write a PENDING order to the DB first, with a self-generated idempotency UUID.
  2. Send the offer to Bitfinex.
  3. On success, update it to ACTIVE and record the exchange's offer ID.
  4. On an API logic error, mark it ERROR.
  5. On a transport-level error (timeout and the like), leave it PENDING and don't guess.

Step 5 is the key. On a connection error I assume neither success nor failure, and hand the decision to reconciliation.

A second scheduled job, reconciliation, runs on a longer interval. It scans for orders stuck in PENDING and asks the exchange whether each one actually landed.

If the exchange knows about it, the order moves to the matching state. If not, I infer it never went through, and the next engine cycle reprocesses it.

This "optimistic placement + after-the-fact reconciliation" combo means the system never double-places on a flaky network, and never strands money in an unknown state.

It's the biggest lesson from the old version: in a distributed setting, state has to be able to heal itself, not pray that every call succeeds.

Idempotency comes from the ordering of the whole placement flow; slapping on a UUID isn't enough. Think through what happens if each step fails before you write it.

Circuit breaker, rate limits, and keys

The external API is the least controllable piece, so I wrap it in two layers.

The first is a circuit breaker (Resilience4j). Once failures reach a threshold ratio it trips open and stops hitting the exchange for a while, so I don't hammer it, and drag myself down, while it's down.

There's a subtlety here: only transport-level errors count toward the breaker. API logic errors (bad params, say) are my own bugs and shouldn't trip it. Otherwise the breaker trips on my own bugs too, and problems get harder to find.

The second layer is rate limiting. The exchange has rate limits, so I use Redis as a shared counter for a token bucket with some headroom, to avoid getting blocked for being too aggressive.

The pricing strategy itself anchors to the funding order book's depth and adds a dynamic premium, with a rate sanity check before submission to reject outliers. I'll keep the exact parameters private, but that's the skeleton.

The platform touches users' Bitfinex API keys, and a leak there is a disaster, so:

  • Login is email + password (BCrypt) + TOTP two-factor, issuing a JWT.
  • Users' exchange keys are AES-256 encrypted before they hit the DB, and decrypted only when needed.
  • The audit log is a hash chain (each record includes the previous record's hash), so tampering is detectable after the fact.

Monitoring, and what the rewrite cost

In the old version, when something broke I could only grep logs.

Now the logs are structured JSON. Prometheus scrapes metrics (lending-cycle latency, orders placed, circuit-breaker state, rate-limit saturation), with Grafana dashboards and Alertmanager pinging Telegram on incidents. Health checks are split into liveness and readiness, and Docker probes accordingly.

Deployment runs CI on a self-hosted GitHub Actions runner, secrets are injected from 1Password, and multi-stage Docker builds produce a slim image. For a one-person project, this is what lets me not wake up at 3am to check on it.

The cost of a rewrite is real. Going from FastAPI to Spring meant rethinking every edge case; translating the code wasn't enough. What I got back was the confidence to let it run on its own.

What this side project taught me is the same thing my day job does: writing a program that runs is easy, and writing a system that gets back up when it fails is hard.

Idempotency, reconciliation, circuit breakers, and observability don't show in a demo. When it's quietly managing your money at 3am, they're what it runs on.

#System Design#Bitfinex#Spring Boot

Letters

Loading comments…

Next

Engineering

muse.ai Email Approval Cards Getting Skipped: Moving a Chrome Extension From Button Text to React Data

A store review said my extension skipped email approval cards. One approval can appear in three forms and labels vary by action and language, so 1.3.0 reads Muse's attributes and React data instead.