Why you should use sk

TLDR


The problem with retry, timeout, and async libraries

You probably already use something for retry logic. Maybe tenacity. Maybe a hand-rolled decorator.

from tenacity import retry, stop_after_attempt, wait_exponential

@retry(stop=stop_after_attempt(3), wait=wait_exponential())
def fetch_data(url):
    return requests.get(url).json()

This works. But now your function always retries. Every call. Every time.

What if you want to retry in production but not in tests? What if one call site needs a timeout but another doesn't? What if you want to run it in the background just this once?

You end up with multiple wrapped versions of the same function, or you start passing flags and config around.

sk — modify at the call site, not the definition

sk takes a different approach. You define your function once, cleanly. Then you decide how to call it each time.

from suitkaise import sk

@sk
def fetch_data(url):
    return requests.get(url).json()

The function works exactly like before:

data = fetch_data("https://api.example.com")

But now you have modifiers available at every call site:

# retry 3 times with exponential backoff
data = fetch_data.retry(times=3, delay=1.0, backoff_factor=2.0)("https://api.example.com")

# timeout after 5 seconds
data = fetch_data.timeout(5.0)("https://api.example.com")

# run in background, get a Future
future = fetch_data.background()("https://api.example.com")
result = future.result()

# rate limit to 2 calls per second
data = fetch_data.rate_limit(2.0)("https://api.example.com")

# make it async
data = await fetch_data.asynced()("https://api.example.com")

The function definition stays clean. The call site says exactly what's happening. No wrapper functions, no config objects, no multiple versions.

Chain modifiers

Modifiers can be chained in any order:

# retry 3 times, with a 5-second timeout per attempt
data = fetch_data.retry(3).timeout(5.0)("https://api.example.com")

# same thing, different order — identical behavior
data = fetch_data.timeout(5.0).retry(3)("https://api.example.com")

The execution order is always consistent regardless of how you chain them:

  1. Rate limit (outermost) — throttle before each attempt
  2. Retry — retry loop
  3. Timeout — per-attempt timeout
  4. Function call (innermost)

This means you don't have to think about ordering. Just add what you need.

# all five modifiers, chained
result = await (
    fetch_data.asynced()
    .retry(times=3, delay=0.5)
    .timeout(10.0)
    .rate_limit(5.0)
)("https://api.example.com")

The double parentheses look a little confusing

They do! But it's actually really simple.

This is intentional. The actual function arguments are always at the end of the chain:

fetch_data.retry(3).timeout(5.0)("https://api.example.com")
#         ^^^^^^^^  ^^^^^^^^^^^^  ^^^^^^^^^^^^^^^^^^^^^^^^^
#         modifier   modifier      actual function args

You might notice the pattern: fn.modifier()("args"). The first call sets up the modifier. The second call runs the function.

Once you see it, it's easy to read: everything before the last parentheses is configuration, the last parentheses are the call.

But now when reviewing code, you can quickly see how it is being modified without sifting through 5 extra args in the main function call.

Works on classes too

@sk isn't just for functions. Put it on a class and every method gets modifiers:

@sk
class DataProcessor:
    def __init__(self, config):
        self.config = config
        self.results = []

    def process(self, data):
        return transform(data)

    def save(self, path):
        with open(path, 'w') as f:
            f.write(json.dumps(self.results))

processor = DataProcessor(config)

# normal call
processor.process(data)

# with timeout
processor.save.timeout(10.0)("output.json")

# with retry
processor.process.retry(3)(data)

# in background
future = processor.save.background()("output.json")

You can even get an async version of the entire class:

AsyncProcessor = DataProcessor.asynced()
processor = AsyncProcessor(config)

# all blocking methods are now async
await processor.process(data)
await processor.save("output.json")

Auto-detects blocking code

sk uses AST analysis to inspect your function's source code and detect blocking patterns — time.sleep(), requests.get(), file I/O, database calls, subprocess calls, and many more.

@sk
def slow_fetch(url):
    return requests.get(url).text

slow_fetch.has_blocking_calls  # True
slow_fetch.blocking_calls      # ['requests.get']

This detection controls which modifiers are available. .asynced() and .background() are only allowed on functions that actually block — preventing you from wrapping pure CPU code in asyncio.to_thread() where it wouldn't help.

If the AST can't detect your blocking code (C extensions, custom blocking functions, tight CPU loops), use @blocking to explicitly mark it:

@sk
@blocking
def heavy_computation():
    return sum(x**2 for x in range(10_000_000))

# now .asynced() and .background() are available
result = await heavy_computation.asynced()()

The hidden killer feature: _shared_meta

This is what makes sk essential to the suitkaise ecosystem.

When you put @sk on a class, it analyzes every method's AST to figure out which instance attributes each method reads and writes. It stores this as _shared_meta:

@sk
class Counter:
    def __init__(self):
        self.value = 0

    def increment(self):
        self.value += 1

print(Counter._shared_meta)
# {
#     'methods': {
#         'increment': {'reads': ['value'], 'writes': ['value']}
#     },
#     'properties': {}
# }

Why does this matter? Because Share uses _shared_meta to know exactly which attributes to sync after each method call.

Without _shared_meta, Share would have to sync everything after every operation — slow and wasteful.

With _shared_meta, Share only syncs the attributes that actually changed. This is what makes Share practical at scale: the overhead is proportional to what you actually touch, not to the total size of the shared object.

from suitkaise.processing import Share

@sk
class Counter:
    def __init__(self):
        self.value = 0

    def increment(self):
        self.value += 1

share = Share()
share.counter = Counter()

# works across processes — Share knows to sync only 'value' after increment()
share.counter.increment()

If you're using Share with custom classes, @sk is what makes it efficient. Without it, Share still works, but you lose time every time Share needs to calculate _shared_meta for each object of that class.

Compared to alternatives

vs tenacity

Tenacity is a great retry library with more retry strategies and conditions than sk.

But tenacity bakes retry config into the function definition:

from tenacity import retry, stop_after_attempt, wait_exponential

@retry(stop=stop_after_attempt(3), wait=wait_exponential())
def fetch_data(url):
    return requests.get(url).json()

# every call retries. always. even in tests.
# want a timeout too? add another library or wrap it yourself.

With sk, you decide per call site:

from suitkaise import sk

@sk
def fetch_data(url):
    return requests.get(url).json()

# production: retry with timeout
data = fetch_data.retry(3).timeout(5.0)("https://api.example.com")

# tests: no retry, no timeout — just a normal call
data = fetch_data("https://api.example.com")

# one-off background fetch
future = fetch_data.background()("https://api.example.com")

Tenacity only does retry. sk gives you retry + timeout + background + rate_limit + async in one decorator, and lets you choose per call site.

vs asyncio.to_thread

What .asynced() uses under the hood. sk wraps it in a consistent API and prevents you from using it on non-blocking code.

vs concurrent.futures

What .background() uses under the hood. sk wraps it in the same chaining API as everything else.

vs writing it yourself

You could absolutely implement retry + timeout + background manually. The value of sk is that all five modifiers share a consistent interface, chain naturally, and — most importantly — generate _shared_meta for Share compatibility, which you would never build yourself.

Works with the rest of suitkaise

sk is the glue. All suitkaise modules use it internally when applicable, and now your own code benefits from the same modifier system.