There is a task that looks like a textbook example of polymorphism: "sync stock and prices with marketplaces". There are several platforms, the actions are the same — list the cards, push the stock, push the price, pull the orders. An interface asks to be written:
type Marketplace interface {
ListOffers() ([]Offer, error)
SetStock(offerID string, n int64) error
SetPrice(offerID string, price int64) error
Orders(since time.Time) ([]Order, error)
}
We wrote Ozon, then Wildberries — the two largest marketplaces in Russia and the CIS — and that interface fell apart on every one of its four methods. Below is where exactly, and why, with code. At the end I will say what turned out to be genuinely shared: one thing, and not the one I would have bet on.
For context: we build a self-hosted store for sellers who already trade on those platforms. One Go binary, SQLite, one installation is one shop. The code is open, link at the end.
The differences at a glance
| Ozon | Wildberries | |
|---|---|---|
| API hosts | one | four |
| Auth |
Client-Id + Api-Key
|
a single Authorization
|
| Stock key | the product's offer_id
|
the size's barcode |
| Price key | the same offer_id
|
the card's nmID
|
| Answer to a stock push | a per-item result list |
204 with an empty body |
| Who is to blame on refusal | stated per item | only in a 409, by barcode |
| Answer to a price push | immediate | a task id, the result later |
| Failure inside HTTP 200 | never | happens, it is in the body |
| Order status | inside the order | a separate call |
| Pause on 429 | Retry-After |
usually X-Ratelimit-Retry
|
Now one by one.
1. One key versus two
On Ozon a product is an offer_id — your own article number, the one you uploaded to the cabinet yourself. Both stock and price are set by it. One link row:
CREATE TABLE ozon_links (
product_id INTEGER PRIMARY KEY,
offer_id TEXT NOT NULL,
...
);
On Wildberries a card is an nmID, a card has sizes, and a size has barcodes. Stock is set per barcode, price per card. Those are two identifiers at different levels of nesting, and both have to be stored:
CREATE TABLE wb_links (
product_id INTEGER PRIMARY KEY,
nm_id INTEGER NOT NULL,
barcode TEXT NOT NULL,
...
);
CREATE UNIQUE INDEX idx_wb_links_barcode ON wb_links(barcode);
CREATE INDEX idx_wb_links_nm ON wb_links(nm_id);
Note the indexes: the barcode is unique, the nmID is not. Two products sharing one barcode would push two different levels into the same slot on every pass. Several rows sharing an nmID is normal — those are the sizes of one card.
SetStock(offerID string, ...) is already wrong here: it has no room for the second key, and which of the two to pass depends on what you are doing.
2. A success nobody told you about
Ozon answers a stock push with a per-item result list:
type ItemResult struct {
OfferID string `json:"offer_id"`
Updated bool `json:"updated"`
Errors []itemError `json:"errors"`
}
An item the platform said nothing about counts as not pushed for us, rather than "fine by default": remembering a level the platform never received is worse than sending it twice.
Wildberries answers PUT /api/v3/stocks/{warehouseId} with 204 and an empty body. There is nothing to parse: the whole batch is credited at once. It names the guilty only in a 409, and only the ones that are:
func (c *Client) SetStocks(warehouseID int64, items []StockItem) (map[string]string, error) {
url := fmt.Sprintf("%s/api/v3/stocks/%d", c.marketplace(), warehouseID)
err := c.do("PUT", url, stocksRequest{Stocks: items}, nil)
if err == nil {
return nil, nil // 204: everything accepted
}
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.Status != http.StatusConflict {
return nil, err // transport died, do not credit the batch
}
// 409: the body lists refusals per barcode
...
}
The signatures diverged too: ([]ItemResult, error) on one side, (map[string]string, error) on the other. You can force them into a common type, but half of its fields will always be empty.
3. A failure inside HTTP 200
The classic trap, and Ozon does not have it. Some Wildberries methods answer with a 200 and put the failure in the body:
{"error": true, "errorText": "..."}
Miss that check and the push is recorded as successful and never retried. So the check lives in the transport, not in each method:
var env errorEnvelope
if json.Unmarshal(raw, &env) == nil && env.Error {
return &APIError{Status: resp.StatusCode, Body: raw, ...}
}
The same place strips the token out of the platform's text before it reaches a log or the database. The token travels in a header and cannot leak by itself, but an API that echoes the request back would carry the key into our files. One line is cheaper than finding out whether it does.
4. A price that is applied later
This is where the interface breaks for good.
Ozon answers a price push the way it answers stock: a verdict per item, immediately. So a row's state is one number, the last value pushed.
const kOzonPriceGuard = `offer_id != '' AND price > 0
AND (price + 99) / 100 * 100 != price_pushed`
Rounding up to whole roubles inside the guard is not decoration. The platform accepts whole roubles, so price_pushed holds the rounded value, and comparing raw kopecks against a rounded number would mean "this row changed" on every pass until the end of time.
Wildberries answers POST /api/v2/upload/task with a task id. What happened to the prices is a separate request, on the next pass or later. "Sent" and "accepted" are two different facts, and that needs two columns:
price_pushed INTEGER NOT NULL DEFAULT -1, -- the platform confirmed
price_sent INTEGER NOT NULL DEFAULT -1, -- what the task carries
price_task TEXT NOT NULL DEFAULT '', -- which task carries it
And the guard has to exclude rows with a task in flight, otherwise the next tick uploads everything again:
const kWBPriceGuard = `nm_id != 0 AND price > 0 AND price_task = ''
AND (price + 99) / 100 * 100 != price_pushed`
A separate table wb_price_tasks(upload_id, created_at) exists purely for created_at. Without a creation time, a task the platform forgot about is indistinguishable from one still running, and its products would hang in the "in flight" state forever. After an hour those rows are released with a readable error and return to the normal retry cycle.
5. One price per card, several products
Same place, continued. A Wildberries card has several sizes and one price for all of them. On our side each size can be a separate product with its own price — that is exactly what a catalogue imported from WB itself looks like.
So what do you do when two sizes disagree on the price? Any answer of the sort "take the first" or "take the lowest" is silently repricing somebody's goods. We send nothing:
func groupByCard(rows []database.WBPriceRow) (agreed, conflicted []cardGroup) {
byNm := map[int64]*cardGroup{}
for _, r := range rows {
g, ok := byNm[r.NmID]
if !ok {
byNm[r.NmID] = &cardGroup{nmID: r.NmID, price: r.Price, rows: ...}
continue
}
g.rows = append(g.rows, r)
if r.Price != g.price {
g.price = -1 // the sizes did not agree
}
}
...
}
Every row of such a card gets an error explaining why, and the owner sees it in the UI. This is the case where doing nothing is the only honest behaviour, and no shared interface would ever suggest it: on Ozon the situation does not exist at all.
6. Orders: where the status lives
An Ozon posting is multi-line and carries its status inside. One call to /v3/posting/fbs/list gives you both the contents and the state.
A Wildberries assembly task is one item, and the list carries no status whatsoever. You have to ask for it with a second call, POST /api/v3/orders/status, passing the ids of the tasks that can still change.
Two things follow. First, we have no wb_order_items table. Copying a pair of tables and a join to hold exactly one row per order is structure for the sake of symmetry with somebody else's code.
Second, and nastier: without that second call a cancellation is invisible, and a cancelled order holds our stock hostage. Ozon tells you about cancellations itself. WB does not.
Idempotency in both cases rests on UNIQUE, not on a "have we applied this already" check:
order_id INTEGER NOT NULL UNIQUE -- the assembly task id
Specifically the task id, not the rid: one rid produces several tasks, and keying on it would lose sales. The INSERT either creates the row (the order is new, deduct the stock) or does nothing. A SELECT before the INSERT does not help — two passes slip through the gap between them.
7. The retry header
A detail that costs you a blocked key. On a 429 Ozon answers with Retry-After, Wildberries usually with X-Ratelimit-Retry. Read both and obey the platform instead of your own timer:
func retryAfter(h http.Header) time.Duration {
for _, name := range []string{"X-Ratelimit-Retry", "Retry-After"} {
if sec, err := strconv.Atoi(h.Get(name)); err == nil && sec > 0 {
return time.Duration(sec) * time.Second
}
}
return 0
}
What turned out to be shared
One thing: the arithmetic of the markup ladder. Not the platform interface, not the transport, not the order model — a thirty-line function that picks a multiplier from the shelf price:
func ApplyRule(rules []PriceRule, shelf int64) int64 {
for _, r := range rules {
if r.UpTo == 0 || shelf < r.UpTo {
return int64(math.Round(float64(shelf) * r.Multiplier))
}
}
return 0
}
It moved into a shared file together with its validation and its tests. The rule tables stayed separate — ozon_price_rules and wb_price_rules — because a markup differs per platform. That is data, not code.
Everything else is two independent packages, app/ozon and app/wb, each with its own client, worker, tables prefixed by the platform, and its own rules. The core (products, orders) knows nothing about platforms. Shared code between the two: zero lines, apart from that one function.
But isn't that copy-paste?
The expected objection: "you have two nearly identical workers". They do look alike — a ticker, a wake channel, two counters, a retry ladder. The difference is that one Pass() has three steps and the other has four, and the extra step, settling asynchronous tasks, must run first, otherwise its rows are invisible to the price push.
A shared worker with a "does this platform have async prices" parameter is not reuse, it is an if inside the abstraction that promised to hide it. A third platform will bring a fourth set of rules, and then, with three finished slices in hand, it will be possible to see what they really have in common. Deriving an abstraction from one implementation is a way of learning about the second platform too late.
How this is tested
Both integrations use an httptest server covering the whole client plus an in-memory SQLite, with requests going through the real chi router rather than around it. The fake cabinet can do what needs proving: answer 204 or a 409 naming specific barcodes, return a 429 with a header, hold a price task in the "running" state, and record the order of calls.
That last one turned out more useful than expected. A test on the order of steps inside a pass catches a regression that is otherwise only visible on real money:
want := []string{"orders", "stocks", "prices"}
Orders first, then stock, then prices. Swap the first two and the shop pushes a stock level that does not yet account for the sale that just happened on the platform. That is an oversell of your own making.
The code is open under AGPL-3.0: github.com/fastogt/fastoshop. The integrations live in src/app/ozon and src/app/wb, the shared arithmetic in src/app/database/price_rules.go.
If you have integrated with marketplaces in other regions — Amazon, Allegro, Shopee, Mercado Libre — I would be curious whether the same pattern holds: does the shared interface survive the second platform, or does it break on the same kind of detail?
Top comments (0)