Limit API Calls to 40 per minute (Swift)

1.2k Views Asked by At

I have a limit of 40 URL Session calls to my API per minute.

I have timed the number of calls in any 60s and when 40 calls have been reached I introduced sleep(x). Where x is 60 - seconds remaining before new minute start. This works fine and the calls don’t go over 40 in any given minute. However the limit is still exceeded as there might be more calls towards the end of the minute and more at the beginning of the next 60s count. Resulting in an API error.

I could add a:

usleep(x)

Where x would be 60/40 in milliseconds. However as some large data returns take much longer than simple queries that are instant. This would increase the overall download time significantly.

Is there a way to track the actual rate to see by how much to slow the function down?

2

There are 2 best solutions below

1
Paul On BEST ANSWER

Might not be the neatest approach, but it works perfectly. Simply storing the time of each call and comparing it to see if new calls can be made and if not, the delay required.

Using previously suggested approach of delay before each API call of 60/40 = 1.5s (Minute / CallsPerMinute), as each call takes a different time to produce response, total time taken to make 500 calls was 15min 22s. Using the below approach time taken: 11min 52s as no unnecessary delay has been introduced.

Call before each API Request:

API.calls.addCall()

Call in function before executing new API task:

let limit = API.calls.isOverLimit()
                
if limit.isOver {
    sleep(limit.waitTime)
}

Background Support Code:

var globalApiCalls: [Date] = []

public class API {

let limitePerMinute = 40 // Set API limit per minute
let margin = 2 // Margin in case you issue more than one request at a time

static let calls = API()

func addCall() {
    globalApiCalls.append(Date())
}

func isOverLimit() -> (isOver: Bool, waitTime: UInt32)
{
    let callInLast60s = globalApiCalls.filter({ $0 > date60sAgo() })
    
    if callInLast60s.count > limitePerMinute - margin {
        if let firstCallInSequence = callInLast60s.sorted(by: { $0 > $1 }).dropLast(2).last {
            let seconds = Date().timeIntervalSince1970 - firstCallInSequence.timeIntervalSince1970
            if seconds < 60 { return (true, UInt32(60 + margin) - UInt32(seconds.rounded(.up))) }
        }
    }
    return (false, 0)
}

private func date60sAgo() -> Date
{
    var dayComponent = DateComponents(); dayComponent.second = -60
    return Calendar.current.date(byAdding: dayComponent, to: Date())!
}
}
3
Jon_the_developer On

Instead of using sleep have a counter. You can do this with a Semaphore (it is a counter for threads, on x amount of threads allowed at a time).

So if you only allow 40 threads at a time you will never have more. New threads will be blocked. This is much more efficient than calling sleep because it will interactively account for long calls and short calls.

The trick here is that you would call a function like this every sixty second. That would make a new semaphore every minute that would only allow 40 calls. Each semaphore would not affect one another but only it's own threads.

func uploadImages() {
    let uploadQueue = DispatchQueue.global(qos: .userInitiated)
    let uploadGroup = DispatchGroup()
    let uploadSemaphore = DispatchSemaphore(value: 40)

    uploadQueue.async(group: uploadGroup) { [weak self] in
        guard let self = self else { return }

        for (_, image) in images.enumerated() {
            uploadGroup.enter()
            uploadSemaphore.wait()
            self.callAPIUploadImage(image: image) { (success, error) in
                uploadGroup.leave()
                uploadSemaphore.signal()
            }
        }
    }

    uploadGroup.notify(queue: .main) {
        // completion
    }
}