Handling errors in Combine (Swift, iOS) - ios

I don't know how to deal with errors in a Combine flow. I would like to be able to catch errors from a Combine function.
Could anyone help in explaining what I'm doing wrong here and how I should handle catching an error with Combine?
Note: The function below is just an example to illustrate a case where an error could be caught instead of crashing the app.
func dataFromURL<T: Decodable>(_ url: String, _ decoder: JSONDecoder = JSONDecoder()) -> AnyPublisher<T, Error> {
// 1) Example: If the URL is not well-formatted, I would like to /create/raise/return an error (in a Combine way)
// 2) Instead of the forced unwrapping here, I would also prefer to raise a catchable error if the creation of the request fails
let request = URLRequest(url: URL(string:url)!)
// 3) Any kind of example dealing with potential errors, etc
return urlSession
.dataTaskPublisher(for: request)
.tryMap { result -> T in
return try decoder.decode(T.self, from: result.data)
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
// function in another file:
func result() {
// I would like to be able to catch or handle errors in this function
dataFromURL("test").print()
// Example : if error 1), else if error 2) etc
}
As explained in the comments, I would like to be able to catch any error outside the dataFromURL function, but in a "Combine way".
I used a URL data fetching as an example, but it could be with anything else.
What is the recommended way to raise and catch errors with the Combine flow? Is it to return a Publisher with a specific error for example? If so, how can I do it?
EDIT
Without Combine, I would just have thrown an error, added the throws keyword to the function, and would have caught the error in the result function.
But I would have expected Combine to have a simpler or more elegant way to achieve this. For example, maybe something that can be thrown at any time:
guard <url is valid> else {
return PublisherError(URLError.urlNotValid)
}
And could have been caught like this:
dataFromURL
.print()
.onError { error in
// handle error here
}
.sink { result in
// no error
}

If the URL(string:) initializer fails (returning nil), you have to decide what error you want to turn that into. Let's say you want to turn it into a URLError. So, if URL(string:) returns nil, create the URLError and use a Fail publisher to publish it:
func jsonContents<T: Decodable>(
ofUrl urlString: String,
as type: T.Type,
decodedBy decoder: JSONDecoder = JSONDecoder()
) -> AnyPublisher<T, Error> {
guard let url = URL(string: urlString) else {
let error = URLError(.badURL, userInfo: [NSURLErrorKey: urlString])
return Fail(error: error).eraseToAnyPublisher()
}
return URLSession.shared
.dataTaskPublisher(for: url)
.tryMap { result -> T in
return try decoder.decode(T.self, from: result.data)
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
But if you really want to shovel more Combine into it, you can use a Result.Publisher instead of Fail:
func jsonContents<T: Decodable>(
ofUrl urlString: String,
as type: T.Type,
decodedBy decoder: JSONDecoder = JSONDecoder()
) -> AnyPublisher<T, Error> {
return (
URL(string: urlString)
.map { Result.success($0) } // This is Optional.map
?? Result.failure(URLError(.badURL, userInfo: [NSURLErrorKey: urlString]))
)
.publisher
.flatMap({
URLSession.shared
.dataTaskPublisher(for: $0)
.tryMap { result -> T in
return try decoder.decode(T.self, from: result.data)
}
})
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
But things get hard to read. We could factor out the use of Result into a new operator, unwrapOrFail(with:):
extension Publisher {
func unwrapOrFail<Wrapped>(with error: Failure) -> Publishers.FlatMap<Result<Wrapped, Self.Failure>.Publisher, Self> where Output == Wrapped? {
return self
.flatMap ({
$0
.map { Result.success($0).publisher }
?? Result.failure(error).publisher
})
}
}
And then use it like this:
func jsonContents<T: Decodable>(
ofUrl urlString: String,
as type: T.Type,
decodedBy decoder: JSONDecoder = JSONDecoder()
) -> AnyPublisher<T, Error> {
return Result.success(urlString).publisher
.map { URL(string: $0) }
.unwrapOrFail(with: URLError(.badURL, userInfo: [NSURLErrorKey: urlString]))
.flatMap({
URLSession.shared
.dataTaskPublisher(for: $0)
.tryMap { result -> T in
return try decoder.decode(T.self, from: result.data)
}
})
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
Note, though, that if you make any mistake along the way, you'll probably get an inscrutable error message and have to pick apart your long pipeline to get Swift to tell you what's really wrong.

Related

POST request using Swift Combine without return doesn't work with AnyPublisher<Void, Error>

I have a POST request to send a text message to the user that doesn't return anything, but the status code. I try to return AnyPublisher<Void, CustomError> but it won't work.
enter image description here
This is what my generic request method looks like:
func request<T>(_ req: NetworkRequest) -> AnyPublisher<T, NetworkError> where T: Decodable, T: Encodable {
let sessionConfig = URLSessionConfiguration.default
sessionConfig.timeoutIntervalForRequest = TimeInterval(req.requestTimeout ?? requestTimeout)
guard let url = URL(string: req.url) else {
// Return a fail publisher if the url is invalid
return AnyPublisher(
Fail<T, NetworkError>(error: NetworkError.badURL("Invalid Url"))
)
}
// We use the dataTaskPublisher from the URLSession which gives us a publisher to play around with.
return URLSession.shared
.dataTaskPublisher(for: req.buildURLRequest(with: url))
.tryMap { output in
// throw an error if response is nil
guard output.response is HTTPURLResponse else {
throw NetworkError.serverError(code: 0, error: "Server error")
}
return output.data
}
.decode(type: T.self, decoder: JSONDecoder())
.mapError { error in
// return error if json decoding fails
NetworkError.invalidJSON(String(describing: error))
}
.eraseToAnyPublisher()
}
Looks like the primary issue is that you're trying to turn AnyPublisher<T,NetworkError> into AnyPublisher<Void,NetworkError>.
You could do:
service.request(request).map { _ in () }.eraseToAnyPublisher()

refresh token using combine networking ios

this question is straight forward :
My code :
return urlSession.dataTaskPublisher(for: urlRequest)
.tryMap { (data: Data, response: URLResponse) -> Data in
//TODO: hide loader
GRP.hideLoader()
if let httpURLResponse = response as? HTTPURLResponse {
if !(200...299 ~= httpURLResponse.statusCode) {
var error = NetworkingError(errorCode: httpURLResponse.statusCode)
if let json = try? JSONSerialization.jsonObject(with: data, options: []) {
error.jsonPayload = json
}
throw error
}
}
if withErrorMessage, let errorCheckModel = try? JSONDecoder().decode(ErrorModel.self, from: data)
{
if let statusIsSuccess = errorCheckModel.success, let errorMessage = errorCheckModel.message, !errorMessage.isEmpty
{
if(!statusIsSuccess)
{
print(urlString)
GRP.showToast(failure: true, message: errorMessage)
}
}
}
return data
}.mapError { error -> NetworkingError in
return NetworkingError(error: error)
}
.decode(type: T.self, decoder: decoder)
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
i made this task buikder but i am stuck, i want to know how can i implement refresh token i. Thank you.
The question is kind of confusing as written. Do you mean I have a request that returns an AnyPublisher<SomeDecodable, NetworkError> and if it fails for a specific reason then I want to make another call (to refresh) and then retry the request? If so it looks something like this:
let authenticatedRequest = URLSession.shared.dataTaskPublisher(for: urlRequest)
return authenticatedRequest
.map { (data, response) -> AnyPublisher<(Data, URLResponse), Error> in
isUnanthenticated(response)
? refetchToken.map { _ in
authenticatedRequest
}
.switchToLatest()
.eraseToAnyPublisher()
: Just((data, response)).eraseToAnyPublisher()
}
.switchToLatest()
.decode(T.self, from: decoder)
.mapError { error -> NetworkingError in
return NetworkingError(error: error)
}
.eraseToAnyPublisher()
}
We make the authenticated request
We map the request and if it failed then we make a reauthrequest and retry. Otherwise we just return out input.
Either way we now have a Publisher of Publishers and we don't want that so we call switch to latest to flatten it and we continue.

Trouble posting API request with Combine

I'm new to the Combine game and am trying to figure out how to generalize a HTTP POST request.
I created the following APIService class to extend individual resource services from:
import Foundation
import Combine
class APIService {
let decoder: JSONDecoder
let session: URLSession
init(session: URLSession = URLSession.shared, decoder: JSONDecoder = JSONDecoder()) {
self.decoder = decoder
self.session = session
}
}
// MARK: JSON API
extension APIService {
struct Response<T> {
let response: HTTPURLResponse
let value: T
}
func post<T: Codable>(
payload: T,
url: URL
) -> AnyPublisher<Response<T>, APIError> {
return Just(payload)
.setFailureType(to: APIError.self) // <<< THIS WAS MISSING!
.encode(encoder: JSONEncoder())
.flatMap({ [weak self] payload -> AnyPublisher<Data, Error> in
guard let self = self else {
return Fail(error: .default("Failing to establish self.")).eraseToAnyPublisher()
}
var request = URLRequest(url: url)
request.httpMethod = Methods.post
request.setValue(
Mimetypes.json,
forHTTPHeaderField: Headers.contentType
)
request.httpBody = payload
return self.session
.dataTaskPublisher(
for: request
)
.tryMap { response -> Response<T> in
let value = try self.decoder.decode(T.self, from: response.data)
return Response(
value: value,
response: response.response
)
}
.mapError { error in
return APIError.default(error.localizedDescription)
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
})
.eraseToAnyPublisher()
}
}
However, this class won't compile with the following error at the post function.
Type of expression is ambiguous without more context
Being new to Swift in general and Combine in particular, I am unfortunately out of ideas on how to proceed.
Any help is greatly appreciated!
Figured it out myself: Solution
Add a Failure type to the Just, so input and output Failure types to flatMap are equal. Or put differently: flatMap cannot convert the Never failing Just to a Publisher with Failure.
The missing line in my case:
Just(payload)
.setFailureType(to: APIError.self)
You just have a few compile-time mistakes, which the Swift type inference system isn't able to pinpoint when they happen within a notoriously cranky flatMap Combine operator.
First, you're using the wrong order of parameters and the type of URLResponse, in creating a Response object. Correct it to:
return Response(
response: response.response as! HTTPURLResponse,
value: value
)
Second, your flatMap is not actually returning AnyPublisher<Data, Error> - the return type you specified inside its closure. The return type is AnyPublisher<Response<T>, APIError>. So, you can change that, but then you'll run into another problem, which is that the Error type of flatMap has to be the same as its upstream, which currently is not APIError, so I'd suggest just moving the mapError out of flatMap. It would look like this:
return Just(payload)
.encode(encoder: JSONEncoder())
.flatMap({ [weak self] payload -> AnyPublisher<Response<T>, Error> in
guard let self = self else {
return Fail(error: APIError.default("...")).eraseToAnyPublisher()
}
var request = URLRequest(url: url)
request.httpMethod = "Methods.post"
request.setValue(
Mimetypes.json,
forHTTPHeaderField: Headers.contentType
)
request.httpBody = payload
return self.session
.dataTaskPublisher(
for: request
)
.tryMap { response -> Response<T> in
let value = try self.decoder.decode(T.self, from: response.data)
return Response(
response: response.response as! HTTPURLResponse,
value: value
)
}
.eraseToAnyPublisher()
})
.mapError { error in
return APIError.default(error.localizedDescription)
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
Figured it out, thanks to this solid guide to debugging Publishers on another question.
The Just needs to be augmented with a Failure, because flatMap needs the Failure of input and output streams to be the same.
We can use setFailureType(to: <T>) to do so. I have updated my question to reflect this solution.

Swift Combine return AnyPublisher<Void, Error> with FlatMap (POST Request)

I started using combine and it's really cool but currently I have no idea how to fix it. I want to make a POST Request using combine so I have to decode Data, create my request, send it and after that return an
AnyPublisher<Void, Error>
Currently my code looks like this:
func postData<T>(withURL urlRequest: URLRequest, object: T) -> AnyPublisher<Void, Error> where T: Encodable {
return Just(object)
.encode(encoder: JSONEncoder())
.mapError {
let error = self.classifyError($0)
return error
}
.map { data -> URLRequest in
var request = urlRequest
//BUILD REQUEST
return request
}
.flatMap { request in
let dataTaskPublisher: AnyPublisher<URLSession.DataTaskPublisher.Output, URLSession.DataTaskPublisher.Failure> = URLSession.DataTaskPublisher(request: request, session: .shared)
return dataTaskPublisher
.tryMap { try self.throwErrorOrContinue(data: $0, basedOnResponse: $1) }
.decode(type: T.self, decoder: JSONDecoder())
.mapError { return self.handle(error: $0, from: urlRequest, object: T.self) }
}
.eraseToAnyPublisher()
}
And he tells me:
Cannot convert return expression of type 'AnyPublisher<Publishers.FlatMap<_, Publishers.Map<Publishers.MapError<Publishers.Encode<Just<T>, JSONEncoder>, _>, URLRequest>>.Output, Publishers.FlatMap<_, Publishers.Map<Publishers.MapError<Publishers.Encode<Just<T>, JSONEncoder>, _>, URLRequest>>.Failure>' (aka 'AnyPublisher<_.Output, _>')
to return type 'AnyPublisher<Void, Error>'
I tried some mapping but it didn't work and I have no idea what he wants from me. Maybe one of you knows the problem? Thanks :)
You could always map to Void, which is an empty tuple:
return URLSession.DataTaskPublisher(request: request, session: .shared)
.tryMap { try self.throwErrorOrContinue(data: $0, basedOnResponse: $1) }
.decode(type: T.self, decoder: JSONDecoder())
.mapError { return self.handle(error: $0, from: urlRequest, object: T.self) }
.map { _ in return () }
Also T should be constrained to Decodable.
But really though, I think postData should return AnyPublisher<T, Error>. What to do with the data got from the server should be decided by the caller of postData. Therefore, you should change the return type of postData instead. For example, if the caller wants to ignore the result, it could do:
Publishers.IgnoreOutput(upstream: postData(...))
This creates a Publisher with Never, rather than Void, as its Output. The publisher will only produce errors.

Input and output type mismatch in Combine data fetching

I am new to reactive programming and Combine, I have the following method which fetching the weather Data from the API.
First, I am checking that if I get 200 if not then throw an error.
If I get the proper data I am decoding it via JSONDecoder but just to
check if there is some problem JSON decoding I am returning the default Object.
Finally mapping the error which is thrown in the
first step but I am getting the following error in flatMAp function
instance method
flatMap(maxPublishers:_:)' requires the types 'Publishers.TryMap.Failure' (aka 'Error') and 'Just.Failure' (aka 'Never') be equivalent
private func fetchDataFor(urlStr: String) -> AnyPublisher<WeatherData, Error> {
let url = URL(string: urlStr)!
return URLSession.shared.dataTaskPublisher(for: url)
.tryMap({ (data, response) in
let response = (response as? HTTPURLResponse)
if response?.statusCode != 200 {
throw NSError(domain: "Error", code: response!.statusCode, userInfo: .none)
}
return data
})
.flatMap{ data in
Just(data)
.decode(type: WeatherData.self, decoder: JSONDecoder())
.catch{ error in
return Just(defaultWeatherData)
}
}
.mapError{ error in
return error
}
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
Can somebody guide what is wrong here or I am using some wrong approach. Thanks
Change your function to this:
private func fetchDataFor(urlStr: String) -> AnyPublisher<WeatherData, Error> {
let url = URL(string: urlStr)!
return URLSession.shared.dataTaskPublisher(for: url)
.tryMap({ (data, response) in
let response = (response as? HTTPURLResponse)
if response?.statusCode != 200 {
throw NSError(domain: "Error", code: response!.statusCode, userInfo: .none)
}
return data
})
.decode(type: WeatherData.self, decoder: JSONDecoder())
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
Combine has helpful build-in .decode method, where you can convert your data to the local model.
Also, a good practice is not to use .receive(on: RunLoop.main) in the request methods, but let your ViewModel/Interactor (simply a consumer of response) decide on which thread it wants to receive the response.

Resources